Named libraries: switch between several, each syncing on its own

The app can keep any number of named libraries and switch between them
from the top bar or Info → Libraries. Each can sync to the account's other
devices or stay on this one (a scratch library to try things in).

The sync server now keeps several libraries per user under
/api/libraries; a migration makes each existing library the user's default
"Main", which /api/library keeps serving for older app builds. Every sync
lines the device's libraries up with the account's: new ones elsewhere are
downloaded, new ones here created on the server, ones deleted elsewhere
flagged rather than thrown away. The existing library keeps its files and
merge base, so upgrading asks nothing.
This commit is contained in:
2026-10-16 21:44:55 -05:00
parent 6072d44ebe
commit 81b1df8c9f
21 changed files with 2451 additions and 202 deletions

View File

@@ -1,8 +1,8 @@
//! mtgc-server — keeps each user's library so their devices can sync it.
//!
//! The server is deliberately dumb about libraries: it stores one versioned
//! document per user and accepts a write only when the writer names the
//! version it is replacing. All merging happens on the devices (see
//! The server is deliberately dumb about libraries: it stores versioned
//! documents — a user can have several, by name — and accepts a write only
//! when the writer names the version it is replacing. All merging happens on the devices (see
//! `mtgc_core::sync::remote`), which is what lets them keep working offline and
//! keeps this side small enough to trust.
//!
@@ -71,7 +71,12 @@ pub fn router(state: AppState) -> Router {
Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/api/info", get(info))
.route("/api/library", get(library::get_library).put(library::put_library))
.route("/api/library", get(library::get_default).put(library::put_default))
.route("/api/libraries", get(library::list).post(library::create))
.route(
"/api/libraries/{id}",
get(library::get_doc).put(library::put_doc).patch(library::rename).delete(library::remove),
)
.route("/api/me", get(auth::me))
.route("/api/devices/{id}", delete(auth::revoke_device))
.route("/auth/start", get(auth::start))
@@ -91,6 +96,9 @@ async fn info(axum::extract::State(state): axum::extract::State<AppState>) -> Js
Json(serde_json::json!({
"server": "mtgc",
"api": 1,
// Named libraries at /api/libraries. Advertised rather than bumping
// `api`, which older apps take as "this server is too new for me".
"libraries": true,
"local_auth": c.local_auth,
"oidc": c.oidc.as_ref().map(|o| o.display_name.clone()),
}))