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,5 +1,5 @@
//! The server driven through its router in-process: sign-in end to end, the
//! library's compare-and-swap, and device revocation.
//! libraries and their compare-and-swap, and device revocation.
use axum::body::Body;
use axum::http::{header, Request, Response, StatusCode};
@@ -110,8 +110,12 @@ impl Server {
}
async fn get_library(&self, token: &str) -> Response<Body> {
self.get_at("/api/library", token).await
}
async fn get_at(&self, uri: &str, token: &str) -> Response<Body> {
self.send(
Request::get("/api/library")
Request::get(uri)
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
@@ -120,7 +124,11 @@ impl Server {
}
async fn put_library(&self, token: &str, if_match: Option<&str>, doc: Value) -> Response<Body> {
let mut req = Request::put("/api/library")
self.put_at("/api/library", token, if_match, doc).await
}
async fn put_at(&self, uri: &str, token: &str, if_match: Option<&str>, doc: Value) -> Response<Body> {
let mut req = Request::put(uri)
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json");
if let Some(v) = if_match {
@@ -130,6 +138,16 @@ impl Server {
}
}
/// An authenticated JSON request.
fn authed(method: &str, uri: &str, token: &str, body: Option<Value>) -> Request<Body> {
let req = Request::builder()
.method(method)
.uri(uri)
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json");
req.body(body.map(|b| Body::from(b.to_string())).unwrap_or_else(Body::empty)).unwrap()
}
fn get(uri: &str) -> Request<Body> {
Request::get(uri).body(Body::empty()).unwrap()
}
@@ -341,6 +359,7 @@ async fn the_server_describes_itself() {
assert_eq!(info["server"], "mtgc");
assert_eq!(info["local_auth"], true);
assert_eq!(info["oidc"], Value::Null);
assert_eq!(info["libraries"], true);
}
// --- devices ------------------------------------------------------------------
@@ -469,7 +488,116 @@ async fn old_versions_are_kept_up_to_a_limit() {
}
let user_id: i64 = s.state.db().query_row("SELECT id FROM users WHERE sub = 'alice'", [], |r| r.get(0)).unwrap();
let kept = db::history_versions(&s.state.db(), user_id).unwrap();
let main = db::default_library(&s.state.db(), user_id).unwrap();
let kept = db::history_versions(&s.state.db(), main.id).unwrap();
assert_eq!(kept.len() as i64, db::HISTORY_KEEP);
assert_eq!(*kept.last().unwrap(), total - 1, "the newest superseded version is kept");
}
// --- named libraries ------------------------------------------------------------
#[tokio::test]
async fn every_account_starts_with_a_default_library() {
let s = Server::local();
let t = s.sign_in("alice", "correct horse battery").await;
let list = body_json(s.send(authed("GET", "/api/libraries", &t, None)).await).await;
let list = list.as_array().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0]["name"], "Main");
assert_eq!(list[0]["default"], true);
assert_eq!(list[0]["version"], 0);
}
#[tokio::test]
async fn libraries_can_be_created_renamed_and_deleted() {
let s = Server::local();
let t = s.sign_in("alice", "correct horse battery").await;
let resp = s.send(authed("POST", "/api/libraries", &t, Some(json!({ "name": " Scratch " })))).await;
assert_eq!(resp.status(), StatusCode::CREATED);
let scratch = body_json(resp).await;
assert_eq!(scratch["name"], "Scratch");
assert_eq!(scratch["default"], false);
let id = scratch["id"].as_str().unwrap().to_string();
let dup = s.send(authed("POST", "/api/libraries", &t, Some(json!({ "name": "scratch" })))).await;
assert_eq!(dup.status(), StatusCode::CONFLICT);
let blank = s.send(authed("POST", "/api/libraries", &t, Some(json!({ "name": " " })))).await;
assert_eq!(blank.status(), StatusCode::BAD_REQUEST);
let resp = s.send(authed("PATCH", &format!("/api/libraries/{id}"), &t, Some(json!({ "name": "Testing" })))).await;
assert_eq!(resp.status(), StatusCode::OK);
let names: Vec<Value> = body_json(s.send(authed("GET", "/api/libraries", &t, None)).await)
.await
.as_array()
.unwrap()
.iter()
.map(|l| l["name"].clone())
.collect();
assert_eq!(names, vec![json!("Main"), json!("Testing")]);
let main_id = body_json(s.send(authed("GET", "/api/libraries", &t, None)).await).await[0]["id"]
.as_str()
.unwrap()
.to_string();
let resp = s.send(authed("DELETE", &format!("/api/libraries/{main_id}"), &t, None)).await;
assert_eq!(resp.status(), StatusCode::CONFLICT, "the default library stays");
let resp = s.send(authed("DELETE", &format!("/api/libraries/{id}"), &t, None)).await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let resp = s.get_at(&format!("/api/libraries/{id}"), &t).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn each_library_has_its_own_versions() {
let s = Server::local();
let t = s.sign_in("alice", "correct horse battery").await;
let scratch = body_json(s.send(authed("POST", "/api/libraries", &t, Some(json!({ "name": "Scratch" })))).await).await;
let uri = format!("/api/libraries/{}", scratch["id"].as_str().unwrap());
assert_eq!(s.put_library(&t, Some("0"), library(&[("a", 1)])).await.status(), StatusCode::OK);
assert_eq!(s.put_library(&t, Some("1"), library(&[("a", 2)])).await.status(), StatusCode::OK);
// Scratch is still at 0, whatever the default library is at.
assert_eq!(s.put_at(&uri, &t, Some("2"), library(&[("x", 1)])).await.status(), StatusCode::PRECONDITION_FAILED);
assert_eq!(s.put_at(&uri, &t, Some("0"), library(&[("x", 1)])).await.status(), StatusCode::OK);
assert_eq!(s.put_at(&uri, &t, None, library(&[("x", 1)])).await.status(), StatusCode::PRECONDITION_REQUIRED);
let got = body_json(s.get_at(&uri, &t).await).await;
assert_eq!(got["version"], 1);
assert_eq!(got["doc"]["cards"], json!({ "x": { "quantity": 1 } }));
let main = body_json(s.get_library(&t).await).await;
assert_eq!(main["version"], 2);
assert_eq!(main["doc"]["cards"]["a"]["quantity"], 2);
}
#[tokio::test]
async fn the_old_address_is_the_default_library() {
let s = Server::local();
let t = s.sign_in("alice", "correct horse battery").await;
s.put_library(&t, Some("0"), library(&[("a", 1)])).await;
let list = body_json(s.send(authed("GET", "/api/libraries", &t, None)).await).await;
let main = &list.as_array().unwrap()[0];
assert_eq!(main["version"], 1);
let got = body_json(s.get_at(&format!("/api/libraries/{}", main["id"].as_str().unwrap()), &t).await).await;
assert_eq!(got["doc"]["cards"]["a"]["quantity"], 1);
}
#[tokio::test]
async fn nobody_can_reach_someone_elses_library() {
let s = Server::local();
s.add_user("bob", "another fine password");
let alice = s.sign_in("alice", "correct horse battery").await;
let bob = s.sign_in("bob", "another fine password").await;
let scratch = body_json(s.send(authed("POST", "/api/libraries", &alice, Some(json!({ "name": "Scratch" })))).await).await;
let uri = format!("/api/libraries/{}", scratch["id"].as_str().unwrap());
assert_eq!(s.get_at(&uri, &bob).await.status(), StatusCode::NOT_FOUND);
assert_eq!(s.put_at(&uri, &bob, Some("0"), library(&[("a", 1)])).await.status(), StatusCode::NOT_FOUND);
let rename = s.send(authed("PATCH", &uri, &bob, Some(json!({ "name": "Mine" })))).await;
assert_eq!(rename.status(), StatusCode::NOT_FOUND);
assert_eq!(s.send(authed("DELETE", &uri, &bob, None)).await.status(), StatusCode::NOT_FOUND);
assert_eq!(s.get_at(&uri, &alice).await.status(), StatusCode::OK);
}

View File

@@ -4,7 +4,7 @@
use std::path::{Path, PathBuf};
use mtgc_core::sync::account::{redeem_code, server_info, start_url, Pkce};
use mtgc_core::sync::remote::{sync_cycle, HttpRemote, SyncBase, UNAUTHORIZED};
use mtgc_core::sync::remote::{sync_cycle, HttpRemote, SyncBase, NOT_FOUND, UNAUTHORIZED};
use mtgc_core::sync::{apply_doc, merge_into, printing_key, read_doc, CardEntry};
use mtgc_server::auth::local::hash_password;
use mtgc_server::{db, router, AppState, Config};
@@ -64,6 +64,16 @@ impl Device {
}
}
/// A second working copy on the same device, for one of the account's
/// named libraries.
fn for_library(dir: &Path, name: &str, server: &str, token: &str, library_id: &str) -> Self {
Self {
db: dir.join(format!("{name}.db")),
base: SyncBase::default(),
remote: HttpRemote::for_library(server, token, library_id),
}
}
fn sync(&mut self) -> mtgc_core::Result<mtgc_core::sync::remote::CycleReport> {
let path = self.db.clone();
sync_cycle(&self.remote, &mut self.base, |base, theirs| {
@@ -197,3 +207,53 @@ fn an_address_that_is_not_an_mtgc_server_is_rejected() {
let err = server_info(&format!("http://{addr}")).unwrap_err();
assert!(err.to_string().contains("doesn't look like an mtgc sync server"), "{err}");
}
#[test]
fn a_named_library_syncs_without_touching_the_default_one() {
let (server, state) = spawn_server();
let hash = hash_password("correct horse battery").unwrap();
db::create_local_user(&state.db(), "carol", "Carol", &hash).unwrap();
let dir = std::env::temp_dir().join(format!("mtgc-e2e-named-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
assert!(server_info(&server).unwrap().libraries);
let phone_login = sign_in(&server, "carol", "correct horse battery");
let desktop_login = sign_in(&server, "carol", "correct horse battery");
let mut phone_main = Device::new(&dir, "phone-main", &server, &phone_login.token);
phone_main.set("a", 1);
phone_main.sync().unwrap();
// The phone starts a scratch library; the desktop finds it in the list.
let scratch = phone_main.remote.create_library("Scratch").unwrap();
let mut phone_scratch = Device::for_library(&dir, "phone-scratch", &server, &phone_login.token, &scratch.id);
phone_scratch.set("x", 5);
assert!(phone_scratch.sync().unwrap().published);
let desktop_account = HttpRemote::new(&server, &desktop_login.token);
let listed = desktop_account.list_libraries().unwrap();
assert_eq!(listed.iter().map(|l| (l.name.as_str(), l.default)).collect::<Vec<_>>(), [("Main", true), ("Scratch", false)]);
let mut desktop_scratch = Device::for_library(&dir, "desktop-scratch", &server, &desktop_login.token, &listed[1].id);
desktop_scratch.sync().unwrap();
assert_eq!(desktop_scratch.qty("x"), Some(5));
assert_eq!(desktop_scratch.qty("a"), None, "nothing from Main");
desktop_scratch.set("x", 6);
desktop_scratch.sync().unwrap();
phone_scratch.sync().unwrap();
assert_eq!(phone_scratch.qty("x"), Some(6));
let mut desktop_main = Device::new(&dir, "desktop-main", &server, &desktop_login.token);
desktop_main.sync().unwrap();
assert_eq!(desktop_main.qty("a"), Some(1));
assert_eq!(desktop_main.qty("x"), None, "nothing from Scratch");
// Deleted from one device: the other's next sync says so distinctly.
desktop_account.delete_library(&scratch.id).unwrap();
let err = phone_scratch.sync().unwrap_err();
assert_eq!(err.to_string(), NOT_FOUND);
assert_eq!(phone_scratch.qty("x"), Some(6), "the local copy is kept");
std::fs::remove_dir_all(&dir).unwrap();
}