//! The link record: which server this app is paired with, the device token that //! authenticates to it, and how far it has consumed that server's change feed. //! //! One row, enforced by `CHECK (id = 1)` and seeded during migration, so every //! operation here is an UPDATE — there is no create-or-missing case to handle. //! //! The token lives in the app-data SQLite file rather than an OS keyring on purpose: //! the `keyring` crate needs libsecret/DBus on Linux, which adds a C dependency to a //! binary that has to cross-compile, and fails outright on headless or minimal-WM //! setups. Protecting the database file is the portable trade. use rusqlite::{params, Connection}; use serde::Serialize; /// The full link record, token included. Internal to the Rust side. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct SyncState { pub server_url: Option, pub device_token: Option, pub last_cursor: i64, pub last_sync_at: Option, /// The linked server's trash-retention window, as it last advertised it. `None` /// until a probe or sync has learned it. pub server_retention_days: Option, } impl SyncState { /// Linked means BOTH a server and a credential for it. Either one alone is a /// half-written link that nothing can act on, so it must not read as linked. pub fn is_linked(&self) -> bool { self.server_url.is_some() && self.device_token.is_some() } } /// What the UI is allowed to see. /// /// Deliberately has no `device_token` field: this crosses into the webview, and a /// long-lived bearer token has no business being reachable from page scripts. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct Status { pub linked: bool, pub server_url: Option, pub last_cursor: i64, pub last_sync_at: Option, } impl From<&SyncState> for Status { fn from(s: &SyncState) -> Self { Status { linked: s.is_linked(), server_url: s.server_url.clone(), last_cursor: s.last_cursor, last_sync_at: s.last_sync_at.clone(), } } } /// Treat a blank string as absent, so a half-cleared row can't masquerade as linked. fn present(value: Option) -> Option { value.filter(|s| !s.trim().is_empty()) } pub fn read(conn: &Connection) -> rusqlite::Result { conn.query_row( "SELECT server_url, device_token, last_cursor, last_sync_at, server_retention_days FROM sync_state WHERE id = 1", [], |row| { let cursor: Option = row.get(2)?; Ok(SyncState { last_sync_at: present(row.get(3)?), server_retention_days: row.get(4)?, server_url: present(row.get(0)?), device_token: present(row.get(1)?), // Stored TEXT (schema) but used as an integer watermark. Absent or // unparseable means "start from the beginning" — always the safe // reading, because a redundant full sync costs time, never data, // whereas a too-high cursor silently skips changes. last_cursor: cursor.and_then(|c| c.trim().parse().ok()).unwrap_or(0), }) }, ) } /// Record a link. /// /// Resets the change-feed cursor whenever the server differs from the one previously /// linked. A cursor is only meaningful against the server that issued it; carrying /// one across would silently skip every change on the new server below that /// watermark — data loss that looks like a successful sync. Re-linking the SAME /// server (after a token refresh, say) keeps the cursor, so a routine re-auth doesn't /// force a full re-download. pub fn set_link(conn: &Connection, server_url: &str, device_token: &str) -> rusqlite::Result<()> { let keep_cursor = read(conn)?.server_url.as_deref() == Some(server_url); conn.execute( "UPDATE sync_state SET server_url = ?1, device_token = ?2, last_cursor = CASE WHEN ?3 THEN last_cursor ELSE NULL END WHERE id = 1", params![server_url, device_token, keep_cursor], )?; Ok(()) } /// Forget the server entirely. /// /// Clears the cursor as well as the credentials: a cursor left behind would, on the /// next link, be interpreted against a server that never issued it. pub fn clear_link(conn: &Connection) -> rusqlite::Result<()> { conn.execute( "UPDATE sync_state SET server_url = NULL, device_token = NULL, last_cursor = NULL, last_sync_at = NULL, server_retention_days = NULL WHERE id = 1", [], )?; Ok(()) } /// Remember the linked server's trash-retention window (0 = it never purges). /// /// Refreshed on every sync rather than only at link time, so changing the setting on /// the server reaches the desktop's Trash countdown on the next cycle instead of /// waiting for someone to re-link. pub fn set_server_retention(conn: &Connection, days: i64) -> rusqlite::Result<()> { conn.execute( "UPDATE sync_state SET server_retention_days = ?1 WHERE id = 1", params![days], )?; Ok(()) } /// The retention window in force on THIS device: the linked server's if we know it, /// otherwise the caller's offline default. A linked device must never enforce or /// advertise its own window over the server's. pub fn effective_retention_days(conn: &Connection, offline_default: i64) -> rusqlite::Result { let state = read(conn)?; if !state.is_linked() { return Ok(offline_default); } // Linked but the server hasn't told us yet (linked by an older build, or no sync // has completed). Fall back to the default rather than claiming "kept forever". Ok(state.server_retention_days.unwrap_or(offline_default)) } /// Stamp a completed sync. The cursor can't stand in for this: it's a revision /// watermark, and it doesn't move at all when a sync correctly finds nothing new — /// so "synced a moment ago, no changes" would be indistinguishable from "never /// synced" without it. pub fn mark_synced(conn: &Connection, when: &str) -> rusqlite::Result<()> { conn.execute( "UPDATE sync_state SET last_sync_at = ?1 WHERE id = 1", params![when], )?; Ok(()) } /// Advance the consumed-change watermark. Called by the pull loop (M10.7b) only /// after a page has been fully applied. pub fn set_cursor(conn: &Connection, cursor: i64) -> rusqlite::Result<()> { conn.execute( "UPDATE sync_state SET last_cursor = ?1 WHERE id = 1", params![cursor.to_string()], )?; Ok(()) } pub fn status(conn: &Connection) -> rusqlite::Result { Ok(Status::from(&read(conn)?)) } #[cfg(test)] mod tests { use super::*; use crate::local::schema; fn db() -> Connection { let conn = Connection::open_in_memory().expect("in-memory db"); schema::migrate(&conn).expect("migrate"); conn } #[test] fn fresh_store_is_unlinked() { let conn = db(); let state = read(&conn).expect("read"); assert_eq!(state, SyncState::default()); assert!(!state.is_linked()); assert_eq!(state.last_cursor, 0); } #[test] fn link_round_trips() { let conn = db(); set_link(&conn, "https://notes.example.com", "tok-1").expect("link"); let state = read(&conn).expect("read"); assert!(state.is_linked()); assert_eq!( state.server_url.as_deref(), Some("https://notes.example.com") ); assert_eq!(state.device_token.as_deref(), Some("tok-1")); } #[test] fn an_unlinked_device_uses_its_own_retention_window() { let conn = db(); assert_eq!(effective_retention_days(&conn, 30).expect("read"), 30); } #[test] fn a_linked_device_adopts_the_servers_window() { // Including 0 — a server that keeps trash forever must not have this device // showing a 30-day countdown that will never fire. let conn = db(); set_link(&conn, "https://notes.example.com", "tok-1").expect("link"); set_server_retention(&conn, 0).expect("retention"); assert_eq!(effective_retention_days(&conn, 30).expect("read"), 0); set_server_retention(&conn, 90).expect("retention"); assert_eq!(effective_retention_days(&conn, 30).expect("read"), 90); } #[test] fn a_linked_device_that_hasnt_heard_yet_falls_back() { // Linked by an older build, or no cycle has completed. The default is a // safer guess than "forever", which would promise a note is being kept. let conn = db(); set_link(&conn, "https://notes.example.com", "tok-1").expect("link"); assert_eq!(effective_retention_days(&conn, 30).expect("read"), 30); } #[test] fn unlinking_forgets_the_servers_window() { let conn = db(); set_link(&conn, "https://notes.example.com", "tok-1").expect("link"); set_server_retention(&conn, 90).expect("retention"); clear_link(&conn).expect("unlink"); assert_eq!(read(&conn).expect("read").server_retention_days, None); assert_eq!(effective_retention_days(&conn, 30).expect("read"), 30); } #[test] fn relinking_the_same_server_keeps_the_cursor() { let conn = db(); set_link(&conn, "https://a.example.com", "tok-1").expect("link"); set_cursor(&conn, 4242).expect("cursor"); // e.g. the token was revoked and the user re-authenticated. set_link(&conn, "https://a.example.com", "tok-2").expect("relink"); let state = read(&conn).expect("read"); assert_eq!( state.last_cursor, 4242, "a re-auth shouldn't force a full re-sync" ); assert_eq!(state.device_token.as_deref(), Some("tok-2")); } #[test] fn linking_a_different_server_resets_the_cursor() { let conn = db(); set_link(&conn, "https://a.example.com", "tok-1").expect("link"); set_cursor(&conn, 4242).expect("cursor"); set_link(&conn, "https://b.example.com", "tok-2").expect("relink"); assert_eq!( read(&conn).expect("read").last_cursor, 0, "a cursor from another server would skip everything below it" ); } #[test] fn unlink_clears_the_cursor_too() { let conn = db(); set_link(&conn, "https://a.example.com", "tok-1").expect("link"); set_cursor(&conn, 99).expect("cursor"); clear_link(&conn).expect("unlink"); let state = read(&conn).expect("read"); assert!(!state.is_linked()); assert_eq!(state.last_cursor, 0); assert!(state.server_url.is_none()); assert!(state.device_token.is_none()); } #[test] fn unlink_clears_the_last_sync_stamp() { // Otherwise a freshly-linked server would claim it synced at a time that // belonged to a different one. let conn = db(); set_link(&conn, "https://a.example.com", "tok-1").expect("link"); mark_synced(&conn, "2026-07-26T04:00:00.000Z").expect("stamp"); assert!(read(&conn).expect("read").last_sync_at.is_some()); clear_link(&conn).expect("unlink"); assert!(read(&conn).expect("read").last_sync_at.is_none()); } #[test] fn half_written_link_is_not_linked() { let conn = db(); conn.execute( "UPDATE sync_state SET server_url = 'https://a.example.com' WHERE id = 1", [], ) .expect("partial write"); assert!(!read(&conn).expect("read").is_linked()); } #[test] fn blank_strings_count_as_absent() { let conn = db(); conn.execute( "UPDATE sync_state SET server_url = ' ', device_token = '' WHERE id = 1", [], ) .expect("blank write"); let state = read(&conn).expect("read"); assert!(!state.is_linked()); assert!(state.server_url.is_none()); } #[test] fn unparseable_cursor_falls_back_to_a_full_sync() { let conn = db(); conn.execute( "UPDATE sync_state SET last_cursor = 'garbage' WHERE id = 1", [], ) .expect("bad cursor"); assert_eq!(read(&conn).expect("read").last_cursor, 0); } #[test] fn status_never_carries_the_token() { let conn = db(); set_link(&conn, "https://a.example.com", "super-secret").expect("link"); let json = serde_json::to_string(&status(&conn).expect("status")).expect("serialize"); assert!( !json.contains("super-secret"), "token leaked to the webview: {json}" ); assert!(json.contains("\"linked\":true"), "got {json}"); } }