Expire trash after 30 days, and make the deadline something you can see
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
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
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
This commit is contained in:
@@ -19,6 +19,9 @@ pub struct SyncState {
|
||||
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 {
|
||||
@@ -59,12 +62,14 @@ fn present(value: Option<String>) -> Option<String> {
|
||||
|
||||
pub fn read(conn: &Connection) -> rusqlite::Result<SyncState> {
|
||||
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<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
|
||||
@@ -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<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
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user