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 account side of the sync server: checking an address, finishing a
//! sign-in, and managing the account's devices.
//! sign-in, and managing the account's devices and libraries.
//!
//! The sign-in itself happens in the browser, on the server's own pages (see
//! the server's `auth` module); the app only starts it and redeems the
@@ -25,6 +25,10 @@ pub struct ServerInfo {
/// The identity provider's display name, when single sign-on is on.
#[serde(default)]
pub oidc: Option<String>,
/// Whether an account can keep several named libraries. Servers from
/// before then keep one, at `/api/library`.
#[serde(default)]
pub libraries: bool,
}
/// The newest server API this build speaks.
@@ -190,6 +194,18 @@ pub struct DeviceInfo {
pub current: bool,
}
/// One of an account's libraries on the server.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RemoteLibrary {
pub id: String,
pub name: String,
/// The library `/api/library` serves — the one the account started with.
#[serde(default)]
pub default: bool,
#[serde(default)]
pub version: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Me {
pub user: AccountUser,
@@ -209,6 +225,36 @@ impl HttpRemote {
Ok(())
}
/// The account's libraries, the default first.
pub fn list_libraries(&self) -> Result<Vec<RemoteLibrary>> {
Ok(self.request("GET", "/api/libraries").call().map_err(super::remote::http_error)?.into_json()?)
}
/// Start a new, empty library on the server.
pub fn create_library(&self, name: &str) -> Result<RemoteLibrary> {
Ok(self
.request("POST", "/api/libraries")
.send_json(serde_json::json!({ "name": name }))
.map_err(super::remote::http_error)?
.into_json()?)
}
pub fn rename_library(&self, id: &str, name: &str) -> Result<RemoteLibrary> {
Ok(self
.request("PATCH", &format!("/api/libraries/{}", super::remote::url_path_segment(id)))
.send_json(serde_json::json!({ "name": name }))
.map_err(super::remote::http_error)?
.into_json()?)
}
/// Delete a library from the server. Devices keep their own copies.
pub fn delete_library(&self, id: &str) -> Result<()> {
self.request("DELETE", &format!("/api/libraries/{}", super::remote::url_path_segment(id)))
.call()
.map_err(super::remote::http_error)?;
Ok(())
}
/// Sign this device out on the server, so its token stops working even if
/// a copy of it survives somewhere.
pub fn logout(&self) -> Result<()> {

View File

@@ -1,7 +1,7 @@
//! Syncing the library with a server that stores it, without ever needing one.
//!
//! The server is deliberately dumb: it keeps one versioned document per user
//! and accepts a write only when the writer names the version it is replacing
//! The server is deliberately dumb: it keeps versioned documents — each of a
//! user's libraries is one — and accepts a write only when the writer names the version it is replacing
//! (compare-and-swap). It never merges. All reconciliation happens here, with
//! the same [`merge3`](super::merge3) the rest of the app already trusts, so
//! the working copy stays fully usable offline and simply catches up the next
@@ -23,8 +23,8 @@
//! on, made enforceable by the server's version numbers.
//!
//! A base is only meaningful against the library it was read from, so it is
//! stored together with the server and account it belongs to ([`AccountBase`])
//! and ignored anywhere else. Pointing a device at a different account then
//! stored together with the server, account and library it belongs to
//! ([`AccountBase`]) and ignored anywhere else. Pointing a device at a different account then
//! starts from an empty ancestor — which never deletes anything — instead of
//! reading every difference between two unrelated libraries as an edit.
@@ -69,16 +69,42 @@ pub struct SyncBase {
pub struct AccountBase {
pub server_url: String,
pub user_id: String,
/// The server's id for the library. `None` for the account's only library
/// on a server from before named libraries — which became its default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub library_id: Option<String>,
#[serde(flatten)]
pub base: SyncBase,
}
impl AccountBase {
/// The base, if it belongs to this server and account. `None` means this
/// device has never synced with that library, which callers treat as a
/// first connection.
/// The base, if it belongs to this server and account's only library (on
/// a server without named libraries). `None` means this device has never
/// synced with that library, which callers treat as a first connection.
pub fn for_account(self, server_url: &str, user_id: &str) -> Option<SyncBase> {
(self.server_url == server_url && self.user_id == user_id).then_some(self.base)
self.for_library(server_url, user_id, None, true)
}
/// The base, if it belongs to this server, account and library.
///
/// `library_id` is `None` when talking to a server without named
/// libraries. A base without an id was saved against one of those, and
/// holds for the account's default library (`is_default`), which is what
/// that one library became when the server was upgraded — and the other
/// way round.
pub fn for_library(
self,
server_url: &str,
user_id: &str,
library_id: Option<&str>,
is_default: bool,
) -> Option<SyncBase> {
let same_library = match (self.library_id.as_deref(), library_id) {
(Some(a), Some(b)) => a == b,
(None, None) => true,
_ => is_default,
};
(self.server_url == server_url && self.user_id == user_id && same_library).then_some(self.base)
}
}
@@ -180,8 +206,14 @@ pub fn first_sync_plan(
pub struct HttpRemote {
base_url: String,
token: String,
/// Which of the account's libraries [`Remote`] reads and writes. `None` is
/// the default one, at the address servers without named libraries use.
library_id: Option<String>,
}
/// The library isn't on the server (any more): another device deleted it.
pub const NOT_FOUND: &str = "That library is no longer on the sync server.";
/// The server no longer accepts this device's token: it was revoked, or the
/// account removed. Distinguished so the app can show "signed out" instead of
/// a generic failure that retrying will never fix.
@@ -189,7 +221,25 @@ pub const UNAUTHORIZED: &str = "This device is no longer signed in to the sync s
impl HttpRemote {
pub fn new(server_url: &str, token: &str) -> Self {
Self { base_url: server_url.trim_end_matches('/').to_string(), token: token.to_string() }
Self {
base_url: server_url.trim_end_matches('/').to_string(),
token: token.to_string(),
library_id: None,
}
}
/// The same server, reading and writing one of the account's named
/// libraries.
pub fn for_library(server_url: &str, token: &str, library_id: &str) -> Self {
Self { library_id: Some(library_id.to_string()), ..Self::new(server_url, token) }
}
/// Where [`Remote`] reads and writes.
fn library_path(&self) -> String {
match &self.library_id {
Some(id) => format!("/api/libraries/{}", url_path_segment(id)),
None => "/api/library".to_string(),
}
}
pub fn server_url(&self) -> &str {
@@ -208,14 +258,14 @@ impl HttpRemote {
impl Remote for HttpRemote {
fn get(&self) -> Result<RemoteDoc> {
let resp = self.request("GET", "/api/library").call().map_err(http_error)?;
let resp = self.request("GET", &self.library_path()).call().map_err(http_error)?;
parse_remote(&resp.into_string()?)
}
fn put(&self, if_match: u64, doc: &LibraryDoc) -> Result<PutOutcome> {
let body = serde_json::to_string(doc)?;
let result = self
.request("PUT", "/api/library")
.request("PUT", &self.library_path())
.set("Content-Type", "application/json")
.set("If-Match", &format!("\"{if_match}\""))
.send_string(&body);
@@ -248,9 +298,21 @@ fn parse_remote(text: &str) -> Result<RemoteDoc> {
Ok(RemoteDoc { version: wire.version, doc: LibraryDoc::from_json(&wire.doc.to_string())? })
}
/// Ids are the server's random hex, but nothing should be able to steer a
/// request elsewhere on the server if one ever isn't.
pub(crate) fn url_path_segment(s: &str) -> String {
s.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => (b as char).to_string(),
_ => format!("%{b:02X}"),
})
.collect()
}
pub(crate) fn http_error(e: ureq::Error) -> CoreError {
match e {
ureq::Error::Status(401, _) => CoreError::Other(UNAUTHORIZED.into()),
ureq::Error::Status(404, _) => CoreError::Other(NOT_FOUND.into()),
ureq::Error::Status(code, resp) => {
let detail = resp.into_string().unwrap_or_default();
let detail = detail.trim();
@@ -465,6 +527,7 @@ mod tests {
let stored = AccountBase {
server_url: "https://sync.example".into(),
user_id: "u1".into(),
library_id: None,
base: SyncBase { version: 3, doc: doc(&[("a", 1)]) },
};
@@ -473,11 +536,46 @@ mod tests {
assert!(stored.for_account("https://other.example", "u1").is_none());
}
#[test]
fn a_base_is_only_used_for_the_library_it_came_from() {
let stored = AccountBase {
server_url: "https://sync.example".into(),
user_id: "u1".into(),
library_id: Some("lib1".into()),
base: SyncBase { version: 3, doc: doc(&[("a", 1)]) },
};
let url = "https://sync.example";
assert!(stored.clone().for_library(url, "u1", Some("lib1"), false).is_some());
assert!(stored.clone().for_library(url, "u1", Some("lib2"), false).is_none());
assert!(stored.clone().for_library(url, "u1", Some("lib2"), true).is_none());
assert!(stored.clone().for_library(url, "u2", Some("lib1"), false).is_none());
}
#[test]
fn a_base_from_before_named_libraries_holds_for_the_default_one() {
// Saved by an older build, which had no library id to save.
let text = r#"{"server_url":"https://sync.example","user_id":"u1","version":3,"doc":{"version":1,"cards":{},"decks":{}}}"#;
let stored: AccountBase = serde_json::from_str(text).unwrap();
assert_eq!(stored.library_id, None);
let url = "https://sync.example";
assert!(stored.clone().for_library(url, "u1", Some("main"), true).is_some());
assert!(stored.clone().for_library(url, "u1", Some("scratch"), false).is_none());
assert!(stored.for_account(url, "u1").is_some());
}
#[test]
fn library_ids_cannot_steer_the_request_path() {
let r = HttpRemote::for_library("https://s.example/", "t", "../me?x");
assert_eq!(r.library_path(), "/api/libraries/..%2Fme%3Fx");
assert_eq!(HttpRemote::new("https://s.example", "t").library_path(), "/api/library");
}
#[test]
fn an_account_base_round_trips_through_json() {
let stored = AccountBase {
server_url: "https://sync.example".into(),
user_id: "u1".into(),
library_id: None,
base: SyncBase { version: 3, doc: doc(&[("a", 1)]) },
};
let text = serde_json::to_string(&stored).unwrap();