From e64d67e904069fddcdde63208b2df0210c9e898d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 26 Jul 2026 16:20:13 -0400 Subject: [PATCH] Expire trash after 30 days, and make the deadline something you can see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/` 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) Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi --- desktop/src-tauri/src/lib.rs | 21 +++ desktop/src-tauri/src/local/commands.rs | 16 +- desktop/src-tauri/src/local/mod.rs | 1 + desktop/src-tauri/src/local/models.rs | 4 + desktop/src-tauri/src/local/retention.rs | 205 +++++++++++++++++++++++ desktop/src-tauri/src/local/schema.rs | 21 +++ desktop/src-tauri/src/local/store.rs | 16 +- desktop/src-tauri/src/sync/commands.rs | 6 + desktop/src-tauri/src/sync/compat.rs | 6 + desktop/src-tauri/src/sync/engine.rs | 13 ++ desktop/src-tauri/src/sync/pull.rs | 68 +++++++- desktop/src-tauri/src/sync/state.rs | 72 +++++++- desktop/src-tauri/src/sync/wire.rs | 5 + docs/sync.md | 36 +++- frontend/src/components/NoteCard.vue | 40 ++++- frontend/src/components/NoteEditor.vue | 1 + frontend/src/notes/datetime.ts | 33 ++++ frontend/src/stores/config.ts | 9 +- frontend/src/stores/notes.ts | 3 + frontend/src/views/BoardView.vue | 20 +++ src/thoughtsync/app.py | 18 ++ src/thoughtsync/models/note.py | 3 + src/thoughtsync/notes/__init__.py | 7 +- src/thoughtsync/notes/helpers.py | 21 ++- src/thoughtsync/retention.py | 170 +++++++++++++++++++ src/thoughtsync/settings.py | 16 +- src/thoughtsync/sync.py | 29 +--- tests/test_retention.py | 83 +++++++++ 28 files changed, 892 insertions(+), 51 deletions(-) create mode 100644 desktop/src-tauri/src/local/retention.rs create mode 100644 src/thoughtsync/retention.py create mode 100644 tests/test_retention.py diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b73ffee..43a7d1d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -45,6 +45,7 @@ pub fn run() { log::info!("opening local store: {}", db_path.display()); let db = local::open(&db_path)?; log::info!("local store ready — {}", local::summary(&db)); + sweep_local_trash(&db); app.manage(db); // Attachment bytes live beside the database, filed by content hash, so a // synced image is readable with no network (M10.7d). @@ -105,6 +106,26 @@ pub fn run() { .expect("error while running the ThoughtSync desktop app"); } +/// Expire old trash at startup, on an unlinked device only (see `local::retention`). +/// +/// At startup rather than on a timer: a desktop app isn't a server, and a sweep the +/// user is present for is one they can see the result of. A failure here is logged and +/// stepped over — housekeeping must never be the reason the app won't open. +fn sweep_local_trash(db: &local::Db) { + let conn = match db.0.lock() { + Ok(conn) => conn, + Err(_) => { + log::warn!("skipping the trash sweep: store lock poisoned"); + return; + } + }; + match local::retention::sweep_if_unlinked(&conn) { + Ok(Some(0)) | Ok(None) => {} + Ok(Some(n)) => log::info!("trash retention: purged {n} expired note(s)"), + Err(e) => log::warn!("trash sweep failed: {e}"), + } +} + /// Frontend logging bridge: routes boot milestones and errors from the webview into /// the same stdout + file log as the Rust side (see frontend/src/desktop/bridge.ts). #[tauri::command] diff --git a/desktop/src-tauri/src/local/commands.rs b/desktop/src-tauri/src/local/commands.rs index f5adae3..338f74e 100644 --- a/desktop/src-tauri/src/local/commands.rs +++ b/desktop/src-tauri/src/local/commands.rs @@ -7,20 +7,34 @@ use serde_json::Value; use tauri::State; use crate::local::models::*; +use crate::local::retention; use crate::local::store; use crate::local::Db; +use crate::sync::state; // A macro would hide the (very regular) locking; kept explicit so each command reads // as an obvious lock -> delegate -> stringify. #[tauri::command] -pub fn config_get() -> PublicConfig { +pub fn config_get(db: State<'_, Db>) -> PublicConfig { + // What the Trash view counts down against: the linked server's window if we know + // it, else this device's own. Reading it here rather than hard-coding the offline + // default is what keeps the deadline on screen equal to the one that will actually + // be enforced. A store error falls back to the default rather than failing the + // call — the app must still boot. + let retention_days = db + .0 + .lock() + .ok() + .and_then(|conn| state::effective_retention_days(&conn, retention::LOCAL_RETENTION_DAYS).ok()) + .unwrap_or(retention::LOCAL_RETENTION_DAYS); // Offline defaults: no signups, no server-side URL unfurling (needs network). PublicConfig { site_name: "ThoughtSync".to_string(), allow_registration: false, version: env!("CARGO_PKG_VERSION").to_string(), enable_url_unfurl: false, + trash_retention_days: retention_days.max(0) as u32, } } diff --git a/desktop/src-tauri/src/local/mod.rs b/desktop/src-tauri/src/local/mod.rs index 54bae1d..ed8e7a7 100644 --- a/desktop/src-tauri/src/local/mod.rs +++ b/desktop/src-tauri/src/local/mod.rs @@ -5,6 +5,7 @@ pub mod commands; pub mod derive; pub mod models; +pub mod retention; pub mod schema; pub mod store; diff --git a/desktop/src-tauri/src/local/models.rs b/desktop/src-tauri/src/local/models.rs index c571ad5..94ac2fd 100644 --- a/desktop/src-tauri/src/local/models.rs +++ b/desktop/src-tauri/src/local/models.rs @@ -19,6 +19,9 @@ pub struct Note { pub pinned: bool, pub archived: bool, pub trashed: bool, + /// When it was trashed (null unless trashed). Named for the server's field so the + /// shared frontend counts down the retention window identically either way. + pub deleted_at: Option, pub remind_at: Option, pub recurrence: Option, pub labels: Vec, @@ -112,6 +115,7 @@ pub struct PublicConfig { pub allow_registration: bool, pub version: String, pub enable_url_unfurl: bool, + pub trash_retention_days: u32, } /// The synthetic single user the offline core reports, so the app's auth-gated diff --git a/desktop/src-tauri/src/local/retention.rs b/desktop/src-tauri/src/local/retention.rs new file mode 100644 index 0000000..0f090bf --- /dev/null +++ b/desktop/src-tauri/src/local/retention.rs @@ -0,0 +1,205 @@ +//! Trash retention for a device with no server (M11.3). +//! +//! The server owns this policy whenever there IS one: a linked client learns about +//! every permanent deletion from the delta feed, as a tombstone, and does exactly +//! what it's told. This module exists for the case the server can't cover — an +//! offline-only install, where trash would otherwise sit forever and the attachment +//! bytes with it. +//! +//! Which is why the sweep refuses to run while linked. If it didn't, a device could +//! decide on its own that a note had expired, destroy it, and then push that delete +//! upstream — overruling a server that was deliberately keeping it (retention off, or +//! a longer window than this constant). A client's local policy must never outrank +//! the server's. + +use chrono::{DateTime, Duration, Utc}; +use rusqlite::Connection; + +use super::store; +use crate::sync::state; + +/// The window an unlinked device uses. Matches the server's default so a device that +/// later links doesn't see its trash behave differently from one that always was. +pub const LOCAL_RETENTION_DAYS: i64 = 30; + +/// Purge trash older than `retention_days`. Returns how many notes went. +/// +/// `now` is a parameter so the window arithmetic is testable without waiting a month. +pub fn sweep_expired_trash( + conn: &Connection, + retention_days: i64, + now: DateTime, +) -> rusqlite::Result { + if retention_days <= 0 { + return Ok(0); + } + let cutoff = now - Duration::days(retention_days); + let expired: Vec = { + let mut stmt = + conn.prepare("SELECT id, trashed_at FROM notes WHERE trashed = 1 AND trashed_at IS NOT NULL")?; + let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?; + rows.filter_map(|row| { + let (id, stamped) = row.ok()?; + // PARSED, not string-compared. The server writes `+00:00` offsets and this + // client writes `Z`, so two timestamps for the same instant don't sort + // against each other as text — and the failure would be silent. + let trashed_at = DateTime::parse_from_rfc3339(&stamped).ok()?; + // An unparseable or missing timestamp means "age unknown", and the only + // safe reading of that is to keep the note. Deleting on a guess is the one + // outcome nobody can undo. + (trashed_at.with_timezone(&Utc) < cutoff).then_some(id) + }) + .collect() + }; + for id in &expired { + // Through delete_forever, so a `pending_deletes` tombstone is recorded. That's + // right even here: while unlinked this device holds the only copy, so if it + // links later the server should learn the note was deleted, not re-send it. + store::delete_forever(conn, id)?; + } + Ok(expired.len()) +} + +/// The startup sweep: runs only on an unlinked device (see the module note). +/// Returns `None` when it didn't run because the device is linked. +pub fn sweep_if_unlinked(conn: &Connection) -> rusqlite::Result> { + if state::read(conn)?.server_url.is_some() { + return Ok(None); + } + sweep_expired_trash(conn, LOCAL_RETENTION_DAYS, Utc::now()).map(Some) +} + +#[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 + } + + /// A trashed note stamped `trashed_at` days ago, in the format the CLIENT writes + /// (`...Z`, millisecond precision — see `store::now`). + fn trashed_note(conn: &Connection, id: &str, days_ago: i64) { + let stamped = (Utc::now() - Duration::days(days_ago)) + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + conn.execute( + "INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at) + VALUES (?1, 'T', 'B', ?2, ?2, 1, ?2)", + rusqlite::params![id, stamped], + ) + .expect("insert"); + } + + fn note_count(conn: &Connection) -> i64 { + conn.query_row("SELECT COUNT(*) FROM notes", [], |r| r.get(0)) + .expect("count") + } + + #[test] + fn purges_trash_past_the_window_and_keeps_the_rest() { + let conn = db(); + trashed_note(&conn, "old", 40); + trashed_note(&conn, "fresh", 3); + let purged = sweep_expired_trash(&conn, 30, Utc::now()).expect("sweep"); + assert_eq!(purged, 1); + assert_eq!(note_count(&conn), 1, "only the expired note should go"); + } + + #[test] + fn a_note_exactly_at_the_boundary_survives() { + // Strictly older than the cutoff, so the note trashed 30 days ago gets its + // full 30 days rather than being cut a moment short. + let conn = db(); + trashed_note(&conn, "boundary", 30); + assert_eq!(sweep_expired_trash(&conn, 30, Utc::now()).expect("sweep"), 0); + } + + #[test] + fn retention_off_purges_nothing() { + let conn = db(); + trashed_note(&conn, "ancient", 4000); + assert_eq!(sweep_expired_trash(&conn, 0, Utc::now()).expect("sweep"), 0); + assert_eq!(sweep_expired_trash(&conn, -1, Utc::now()).expect("sweep"), 0); + assert_eq!(note_count(&conn), 1); + } + + #[test] + fn an_untrashed_note_is_never_swept() { + let conn = db(); + conn.execute( + "INSERT INTO notes (id, title, body, created_at, updated_at, trashed) + VALUES ('live', 'T', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 0)", + [], + ) + .expect("insert"); + assert_eq!(sweep_expired_trash(&conn, 30, Utc::now()).expect("sweep"), 0); + assert_eq!(note_count(&conn), 1); + } + + #[test] + fn an_unparseable_timestamp_keeps_the_note() { + // "Age unknown" must never resolve to "delete it". + let conn = db(); + conn.execute( + "INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at) + VALUES ('weird', 'T', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 1, 'not a date')", + [], + ) + .expect("insert"); + assert_eq!(sweep_expired_trash(&conn, 30, Utc::now()).expect("sweep"), 0); + assert_eq!(note_count(&conn), 1); + } + + #[test] + fn a_server_style_offset_timestamp_is_understood() { + // The server serializes with a `+00:00` offset, not `Z`. Comparing those as + // strings would quietly never match — this is the case that catches it. + let conn = db(); + let stamped = (Utc::now() - Duration::days(40)).to_rfc3339(); + conn.execute( + "INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at) + VALUES ('server', 'T', 'B', ?1, ?1, 1, ?1)", + rusqlite::params![stamped], + ) + .expect("insert"); + assert_eq!(sweep_expired_trash(&conn, 30, Utc::now()).expect("sweep"), 1); + } + + #[test] + fn a_purged_note_leaves_a_pending_delete_behind() { + // Without the tombstone, linking this device later would let the server + // re-send a note the user already destroyed here. + let conn = db(); + trashed_note(&conn, "old", 40); + sweep_expired_trash(&conn, 30, Utc::now()).expect("sweep"); + let pending: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pending_deletes WHERE entity = 'note' AND id = 'old'", + [], + |r| r.get(0), + ) + .expect("count"); + assert_eq!(pending, 1); + } + + #[test] + fn a_linked_device_does_not_sweep() { + // The whole safety rule: with a server present, purging is the server's call. + let conn = db(); + trashed_note(&conn, "old", 400); + state::set_link(&conn, "https://notes.example", "token").expect("link"); + assert_eq!(sweep_if_unlinked(&conn).expect("sweep"), None); + assert_eq!(note_count(&conn), 1, "the note must survive on a linked device"); + } + + #[test] + fn an_unlinked_device_sweeps() { + let conn = db(); + trashed_note(&conn, "old", 400); + assert_eq!(sweep_if_unlinked(&conn).expect("sweep"), Some(1)); + assert_eq!(note_count(&conn), 0); + } +} diff --git a/desktop/src-tauri/src/local/schema.rs b/desktop/src-tauri/src/local/schema.rs index e85c823..e4ff284 100644 --- a/desktop/src-tauri/src/local/schema.rs +++ b/desktop/src-tauri/src/local/schema.rs @@ -130,6 +130,23 @@ const SCHEMA_V3: &str = r#" ALTER TABLE sync_state ADD COLUMN last_sync_at TEXT; "#; +// v4 (M11.3): WHEN a note was trashed. +// +// The table only ever recorded THAT a note was trashed, which is enough to draw a +// Trash view and nothing else. Retention needs an age: without a timestamp there is +// no way to tell a note trashed this morning from one trashed last spring, so an +// offline device could never expire its own trash — and the UI couldn't warn anyone +// before it did. +// It also records the LINKED server's retention window, captured from /api/config. +// Once linked, the server's policy is the one that actually applies, so showing this +// device's offline default would put a countdown on screen that doesn't match what +// happens — a wrong deadline is worse than none. +const SCHEMA_V4: &str = r#" +ALTER TABLE notes ADD COLUMN trashed_at TEXT; +UPDATE notes SET trashed_at = updated_at WHERE trashed = 1; +ALTER TABLE sync_state ADD COLUMN server_retention_days INTEGER; +"#; + /// Bring the database up to the latest schema. Idempotent. pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch("PRAGMA foreign_keys = ON;")?; @@ -146,5 +163,9 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch(SCHEMA_V3)?; conn.execute_batch("PRAGMA user_version = 3;")?; } + if version < 4 { + conn.execute_batch(SCHEMA_V4)?; + conn.execute_batch("PRAGMA user_version = 4;")?; + } Ok(()) } diff --git a/desktop/src-tauri/src/local/store.rs b/desktop/src-tauri/src/local/store.rs index 74b0448..644f5ce 100644 --- a/desktop/src-tauri/src/local/store.rs +++ b/desktop/src-tauri/src/local/store.rs @@ -123,7 +123,7 @@ fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result rusqlite::Result { let mut note = conn.query_row( - "SELECT id, title, body, color, kind, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at + "SELECT id, title, body, color, kind, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at FROM notes WHERE id = ?1", [id], |r| { @@ -141,6 +141,7 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result { pinned: r.get(6)?, archived: r.get(7)?, trashed: r.get(8)?, + deleted_at: r.get(13)?, remind_at: r.get(9)?, recurrence: r.get(10)?, labels: Vec::new(), @@ -621,13 +622,22 @@ pub fn reorder(conn: &Connection, ordered_ids: &[String]) -> rusqlite::Result<() } pub fn trash(conn: &Connection, id: &str) -> rusqlite::Result { - conn.execute("UPDATE notes SET trashed = 1 WHERE id = ?1", [id])?; + // COALESCE, so trashing an already-trashed note doesn't restart its retention + // clock. The server keeps its `deleted_at` the same way — a note shouldn't earn + // another 30 days because something touched it twice. + conn.execute( + "UPDATE notes SET trashed = 1, trashed_at = COALESCE(trashed_at, ?1) WHERE id = ?2", + params![now(), id], + )?; touch(conn, id)?; load_note(conn, id) } pub fn restore(conn: &Connection, id: &str) -> rusqlite::Result { - conn.execute("UPDATE notes SET trashed = 0 WHERE id = ?1", [id])?; + conn.execute( + "UPDATE notes SET trashed = 0, trashed_at = NULL WHERE id = ?1", + [id], + )?; touch(conn, id)?; load_note(conn, id) } diff --git a/desktop/src-tauri/src/sync/commands.rs b/desktop/src-tauri/src/sync/commands.rs index 3f97bc3..e11a60f 100644 --- a/desktop/src-tauri/src/sync/commands.rs +++ b/desktop/src-tauri/src/sync/commands.rs @@ -95,6 +95,12 @@ pub async fn sync_link(input: LinkInput, db: State<'_, Db>) -> Result, #[serde(default)] pub sync_features: Vec, + /// How long the SERVER keeps a trashed note before purging it (0 = forever). + /// Once linked this is the window that actually applies, so the desktop's Trash + /// countdown has to come from here rather than from its own offline default. + #[serde(default)] + pub trash_retention_days: Option, } impl ServerInfo { @@ -218,6 +223,7 @@ mod tests { .copied() .map(String::from) .collect(), + trash_retention_days: Some(30), } } diff --git a/desktop/src-tauri/src/sync/engine.rs b/desktop/src-tauri/src/sync/engine.rs index 0f6b8e0..11a97e3 100644 --- a/desktop/src-tauri/src/sync/engine.rs +++ b/desktop/src-tauri/src/sync/engine.rs @@ -51,8 +51,21 @@ pub async fn run_cycle( ); } + // While we're already talking to this server, re-read what it says about itself. + // Today that's the trash-retention window the Trash view counts down against, and + // it can change under us whenever an admin edits the setting. Best-effort on + // purpose: a config blip must not fail a cycle whose actual work already + // succeeded, and the stored value simply stays as it was. + let retention = super::client::probe(base_url) + .await + .ok() + .and_then(|p| p.server.trash_retention_days); + let status = { let conn = db.0.lock().map_err(|e| e.to_string())?; + if let Some(days) = retention { + state::set_server_retention(&conn, days as i64).map_err(|e| e.to_string())?; + } // Stamped only here, after BOTH halves succeeded. A timestamp written after a // partial cycle would tell the user they're up to date when they aren't. let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); diff --git a/desktop/src-tauri/src/sync/pull.rs b/desktop/src-tauri/src/sync/pull.rs index f6e334c..0e89224 100644 --- a/desktop/src-tauri/src/sync/pull.rs +++ b/desktop/src-tauri/src/sync/pull.rs @@ -227,13 +227,23 @@ fn upsert_label(conn: &Connection, label: &wire::Label) -> rusqlite::Result<()> fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> { let created = note.created_at.clone().unwrap_or_else(now); let updated = note.updated_at.clone().unwrap_or_else(|| created.clone()); + // The server's `deleted_at` is the authority on trash AGE. Taking it from the feed + // rather than stamping "now" locally is what keeps a note trashed three weeks ago + // from looking brand-new to a device that only just heard about it — otherwise + // every fresh install would silently reset the whole retention clock. Falls back + // to the note's updated_at only if an older server omits the field. + let trashed_at = if note.trashed { + note.deleted_at.clone().or_else(|| Some(updated.clone())) + } else { + None + }; // `created_at` is deliberately absent from the UPDATE clause: a note's birth time // never changes, and the server's copy is the same value anyway. conn.execute( "INSERT INTO notes (id, title, body, color, kind, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, - sync_revision, dirty) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, 0) + sync_revision, trashed_at, dirty) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 0) ON CONFLICT(id) DO UPDATE SET title = excluded.title, body = excluded.body, @@ -247,6 +257,7 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> { recurrence = excluded.recurrence, updated_at = excluded.updated_at, sync_revision = excluded.sync_revision, + trashed_at = excluded.trashed_at, dirty = 0", params![ note.id, @@ -263,6 +274,7 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> { created, updated, note.sync_revision, + trashed_at, ], )?; @@ -494,6 +506,7 @@ mod tests { pinned: false, archived: false, trashed: false, + deleted_at: None, remind_at: None, recurrence: None, created_at: Some("2026-07-26T00:00:00.000Z".into()), @@ -559,6 +572,57 @@ mod tests { assert_eq!(count(&conn, "SELECT trashed FROM notes WHERE id = 'n1'"), 1); } + #[test] + fn trash_age_comes_from_the_server_not_from_now() { + // The retention countdown runs off this timestamp. Stamping it locally would + // hand every note a fresh 30 days on any device that syncs it for the first + // time — a note trashed last month would never expire anywhere. + let conn = db(); + let mut trashed = note("n1", 1); + trashed.trashed = true; + trashed.deleted_at = Some("2026-06-01T09:30:00+00:00".into()); + apply_page(&conn, &page(vec![trashed], vec![], 1)).expect("apply"); + let stamped: String = conn + .query_row("SELECT trashed_at FROM notes WHERE id = 'n1'", [], |r| { + r.get(0) + }) + .expect("trashed_at"); + assert_eq!(stamped, "2026-06-01T09:30:00+00:00"); + } + + #[test] + fn restoring_a_note_server_side_clears_its_trash_stamp() { + let conn = db(); + let mut trashed = note("n1", 1); + trashed.trashed = true; + trashed.deleted_at = Some("2026-06-01T09:30:00+00:00".into()); + apply_page(&conn, &page(vec![trashed], vec![], 1)).expect("apply"); + apply_page(&conn, &page(vec![note("n1", 2)], vec![], 2)).expect("apply"); + let stamped: Option = conn + .query_row("SELECT trashed_at FROM notes WHERE id = 'n1'", [], |r| { + r.get(0) + }) + .expect("trashed_at"); + assert_eq!(stamped, None, "an untrashed note must carry no trash stamp"); + } + + #[test] + fn an_older_server_without_deleted_at_still_ages_the_trash() { + // Falls back to updated_at rather than leaving the stamp null, which would + // make the note un-expirable and its countdown blank. + let conn = db(); + let mut trashed = note("n1", 1); + trashed.trashed = true; + trashed.deleted_at = None; + apply_page(&conn, &page(vec![trashed], vec![], 1)).expect("apply"); + let stamped: Option = conn + .query_row("SELECT trashed_at FROM notes WHERE id = 'n1'", [], |r| { + r.get(0) + }) + .expect("trashed_at"); + assert_eq!(stamped.as_deref(), Some("2026-07-26T00:00:00.000Z")); + } + #[test] fn children_are_replaced_not_merged() { let conn = db(); diff --git a/desktop/src-tauri/src/sync/state.rs b/desktop/src-tauri/src/sync/state.rs index b6bf9ba..aa94fae 100644 --- a/desktop/src-tauri/src/sync/state.rs +++ b/desktop/src-tauri/src/sync/state.rs @@ -19,6 +19,9 @@ pub struct SyncState { 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 { @@ -59,12 +62,14 @@ fn present(value: Option) -> Option { pub fn read(conn: &Connection) -> rusqlite::Result { conn.query_row( - "SELECT server_url, device_token, last_cursor, last_sync_at FROM sync_state WHERE id = 1", + "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 @@ -106,13 +111,39 @@ 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 + 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 @@ -172,6 +203,43 @@ mod tests { 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(); diff --git a/desktop/src-tauri/src/sync/wire.rs b/desktop/src-tauri/src/sync/wire.rs index 9ee8ad8..92aedef 100644 --- a/desktop/src-tauri/src/sync/wire.rs +++ b/desktop/src-tauri/src/sync/wire.rs @@ -40,6 +40,11 @@ pub struct Note { /// The server derives this from `deleted_at` — trash, NOT a tombstone. #[serde(default)] pub trashed: bool, + /// WHEN it was trashed. The trash-retention clock runs from here, so it has to be + /// the server's timestamp rather than anything this device invents. Absent from an + /// older server, which is why it's optional rather than required. + #[serde(default)] + pub deleted_at: Option, #[serde(default)] pub remind_at: Option, #[serde(default)] diff --git a/docs/sync.md b/docs/sync.md index ec4597e..3138a1e 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -33,7 +33,8 @@ token, or even has an account: { "site_name": "...", "version": "0.1.0", "sync_protocol_version": 1, "min_client_protocol_version": 1, - "sync_features": ["notes", "labels", "attachments", "tombstones", "revisions"] } + "sync_features": ["notes", "labels", "attachments", "tombstones", "revisions"], + "trash_retention_days": 30 } ``` The client identifies itself on every request with @@ -121,13 +122,31 @@ as `?since=`. `since=0` (or absent) is a **full initial sync**. Two levels, both propagate: -- **Trash** — `deleted_at` is a normal field. A trashed note still syncs with its - content; the client shows it in its Trash. Restoring clears `deleted_at`. +- **Trash** — `deleted_at` is a normal field, and it's **on the wire**: a trashed + note still syncs with its content, the client shows it in its Trash, and the + timestamp is what the client counts the retention window against. Restoring + clears it. - **Purge (permanent delete)** — becomes a **content-less tombstone**: `purged_at` - is set, title/body/items/labels/attachments are cleared/removed, and the row is - kept. A client seeing `purged_at != null` deletes the row from its local store. - Tombstones are retained indefinitely (cheap for a personal store); revisit if - they ever grow large. + is set, title/body/items/labels/attachments/previews/revisions are cleared or + removed, and the row is kept. A client seeing `purged_at != null` deletes the row + from its local store. `deleted_at` deliberately SURVIVES a purge, so ordinary + server-side queries (`deleted_at IS NULL`) never see a tombstone as a live note. + Tombstones themselves are retained indefinitely (cheap for a personal store); + revisit if they ever grow large. + +### Retention — trash expires + +A trashed note is purged automatically once it is older than the server's +`trash_retention_days` setting (default **30**, `0` = keep forever), advertised on +`/api/config` so a client can show the countdown. A background sweep on the server +does the work; clients learn about it as ordinary tombstones and need no special +handling. + +**A linked client must not run its own expiry.** The server owns the policy — one +clock, one window. A client that purged on its own schedule could destroy a note +the server was deliberately keeping and then push that delete upstream. An +*unlinked* client (offline-only, no server to defer to) expires its own trash on +its own default, which is the only case where nothing else can. ## Pull — `GET /api/sync/changes` @@ -137,7 +156,8 @@ Response: ```json { - "notes": [ { "...full note...", "sync_revision": 42, "purged_at": null } ], + "notes": [ { "...full note...", "trashed": false, "deleted_at": null, + "sync_revision": 42, "purged_at": null } ], "labels": [ { "id": "...", "name": "...", "color": "...", "sync_revision": 43, "purged_at": null, "created_at": "..." } ], "cursor": 43, diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue index 1621bf7..4f036d5 100644 --- a/frontend/src/components/NoteCard.vue +++ b/frontend/src/components/NoteCard.vue @@ -14,7 +14,8 @@ import Icon from "./Icon.vue"; import LinkPreview from "./LinkPreview.vue"; import MarkdownText from "./MarkdownText.vue"; import NoteChecklist from "./NoteChecklist.vue"; -import { formatReminder, isOverdue } from "../notes/datetime"; +import { formatReminder, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime"; +import { useConfigStore } from "../stores/config"; const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>(); const emit = defineEmits<{ @@ -24,6 +25,17 @@ const emit = defineEmits<{ (e: "drop", note: Note): void; }>(); const notes = useNotesStore(); +const config = useConfigStore(); + +// --- Retention countdown. A note in Trash is on a clock, and the card is the only +// place someone browsing Trash would ever find that out in time to restore it. +// Null whenever nothing is going to happen: not trashed, or retention turned off. --- +const trashDays = computed(() => + props.note.trashed ? trashDaysLeft(props.note.deleted_at, config.trashRetentionDays) : null, +); +const trashCountdown = computed(() => formatTrashCountdown(trashDays.value)); +// Same red the overdue reminder uses — the last few days are worth noticing. +const trashUrgent = computed(() => trashDays.value !== null && trashDays.value <= 3); // The card previews the first image inline; non-image files show as compact chips. const firstImage = computed(() => props.note.attachments.find((a) => a.mime.startsWith("image/"))); @@ -236,6 +248,32 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown)) +
+ + + + + + {{ trashCountdown }} + +
+