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:
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
pub mod commands;
|
||||
pub mod derive;
|
||||
pub mod models;
|
||||
pub mod retention;
|
||||
pub mod schema;
|
||||
pub mod store;
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
pub remind_at: Option<String>,
|
||||
pub recurrence: Option<String>,
|
||||
pub labels: Vec<NoteLabel>,
|
||||
@@ -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
|
||||
|
||||
@@ -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<Utc>,
|
||||
) -> rusqlite::Result<usize> {
|
||||
if retention_days <= 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let cutoff = now - Duration::days(retention_days);
|
||||
let expired: Vec<String> = {
|
||||
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<Option<usize>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<LinkP
|
||||
|
||||
fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
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<Note> {
|
||||
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<Note> {
|
||||
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<Note> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -95,6 +95,12 @@ pub async fn sync_link(input: LinkInput, db: State<'_, Db>) -> Result<LinkResult
|
||||
let status = {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
state::set_link(&conn, &base_url, &token).map_err(|e| e.to_string())?;
|
||||
// Adopt the server's trash-retention window immediately, so the Trash view
|
||||
// stops counting down against this device's offline default the moment it's
|
||||
// no longer the policy in force.
|
||||
if let Some(days) = probe.server.trash_retention_days {
|
||||
state::set_server_retention(&conn, days as i64).map_err(|e| e.to_string())?;
|
||||
}
|
||||
state::status(&conn).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
|
||||
@@ -52,6 +52,11 @@ pub struct ServerInfo {
|
||||
pub min_client_protocol_version: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub sync_features: Vec<String>,
|
||||
/// 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<u32>,
|
||||
}
|
||||
|
||||
impl ServerInfo {
|
||||
@@ -218,6 +223,7 @@ mod tests {
|
||||
.copied()
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
trash_retention_days: Some(30),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String> = 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<String> = 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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub remind_at: Option<String>,
|
||||
#[serde(default)]
|
||||
|
||||
Reference in New Issue
Block a user