Files
thoughtsync/desktop/src-tauri/src/sync/state.rs
T
bvandeusenandClaude Opus 5 e64d67e904
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 44s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m12s
Expire trash after 30 days, and make the deadline something you can see
Trash had no end. A note sat in /trash until someone emptied it by hand, and
its attachment BYTES sat on disk the whole time — the pile-up the operator
asked about. Nothing purged; there was no scheduler at all.

Retention is server-owned: `trash_retention_days` (default 30, 0 = keep
forever) in the settings registry, so it lands in admin Settings with no
migration and takes effect without a restart. A background sweep started in
before_serving does the work. Clients learn about a purge the way they learn
about any deletion — as a tombstone on the delta feed.

An auto-purge nobody can see coming is data loss on a timer, so the window is
now visible: /api/config publishes it, notes carry `deleted_at`, Trash leads
with the policy, and each card counts down. The countdown rounds DOWN — saying
"1 day left" for a note with ten minutes on the clock is the one error here
that actually costs someone a note.

Three things this turned up on the way:

- `DELETE /api/notes/<id>` hard-deleted the row, leaving no tombstone at all.
  A permanent delete in the web UI never reached a linked device, which would
  keep its copy forever and push it back on the next edit. It now purges
  through the same path as everything else.
- The purge left `note_revisions` and `note_link_previews` behind. A revision
  holds the full body, so the text of a "permanently deleted" note was still
  sitting in the database.
- `deleted_at` now SURVIVES a purge instead of being cleared. It's still true,
  and it means every query that says "not trashed" excludes tombstones for
  free — without it a content-less row reads as a perfectly normal active note
  and shows up on the board as a blank card.

Desktop keeps its own clock only when there's nobody else to keep one: the
sweep runs at startup on an UNLINKED device and refuses otherwise. A linked
client that expired notes on its own schedule could destroy something the
server was deliberately keeping, then push that delete upstream. Local policy
must never outrank the server's — so it also adopts the server's window for
the countdown rather than showing its offline default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 16:20:13 -04:00

343 lines
13 KiB
Rust

//! 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<String>,
pub device_token: Option<String>,
pub last_cursor: i64,
pub last_sync_at: Option<String>,
/// 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<i64>,
}
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<String>,
pub last_cursor: i64,
pub last_sync_at: Option<String>,
}
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<String>) -> Option<String> {
value.filter(|s| !s.trim().is_empty())
}
pub fn read(conn: &Connection) -> rusqlite::Result<SyncState> {
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<String> = 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<i64> {
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<Status> {
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}");
}
}