M12 — the Android client, end to end #2
@@ -45,6 +45,7 @@ pub fn run() {
|
|||||||
log::info!("opening local store: {}", db_path.display());
|
log::info!("opening local store: {}", db_path.display());
|
||||||
let db = local::open(&db_path)?;
|
let db = local::open(&db_path)?;
|
||||||
log::info!("local store ready — {}", local::summary(&db));
|
log::info!("local store ready — {}", local::summary(&db));
|
||||||
|
sweep_local_trash(&db);
|
||||||
app.manage(db);
|
app.manage(db);
|
||||||
// Attachment bytes live beside the database, filed by content hash, so a
|
// Attachment bytes live beside the database, filed by content hash, so a
|
||||||
// synced image is readable with no network (M10.7d).
|
// synced image is readable with no network (M10.7d).
|
||||||
@@ -105,6 +106,26 @@ pub fn run() {
|
|||||||
.expect("error while running the ThoughtSync desktop app");
|
.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
|
/// 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).
|
/// the same stdout + file log as the Rust side (see frontend/src/desktop/bridge.ts).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -7,20 +7,34 @@ use serde_json::Value;
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::local::models::*;
|
use crate::local::models::*;
|
||||||
|
use crate::local::retention;
|
||||||
use crate::local::store;
|
use crate::local::store;
|
||||||
use crate::local::Db;
|
use crate::local::Db;
|
||||||
|
use crate::sync::state;
|
||||||
|
|
||||||
// A macro would hide the (very regular) locking; kept explicit so each command reads
|
// A macro would hide the (very regular) locking; kept explicit so each command reads
|
||||||
// as an obvious lock -> delegate -> stringify.
|
// as an obvious lock -> delegate -> stringify.
|
||||||
|
|
||||||
#[tauri::command]
|
#[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).
|
// Offline defaults: no signups, no server-side URL unfurling (needs network).
|
||||||
PublicConfig {
|
PublicConfig {
|
||||||
site_name: "ThoughtSync".to_string(),
|
site_name: "ThoughtSync".to_string(),
|
||||||
allow_registration: false,
|
allow_registration: false,
|
||||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
enable_url_unfurl: false,
|
enable_url_unfurl: false,
|
||||||
|
trash_retention_days: retention_days.max(0) as u32,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod derive;
|
pub mod derive;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
pub mod retention;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ pub struct Note {
|
|||||||
pub pinned: bool,
|
pub pinned: bool,
|
||||||
pub archived: bool,
|
pub archived: bool,
|
||||||
pub trashed: 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 remind_at: Option<String>,
|
||||||
pub recurrence: Option<String>,
|
pub recurrence: Option<String>,
|
||||||
pub labels: Vec<NoteLabel>,
|
pub labels: Vec<NoteLabel>,
|
||||||
@@ -112,6 +115,7 @@ pub struct PublicConfig {
|
|||||||
pub allow_registration: bool,
|
pub allow_registration: bool,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
pub enable_url_unfurl: bool,
|
pub enable_url_unfurl: bool,
|
||||||
|
pub trash_retention_days: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The synthetic single user the offline core reports, so the app's auth-gated
|
/// 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;
|
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.
|
/// Bring the database up to the latest schema. Idempotent.
|
||||||
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
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(SCHEMA_V3)?;
|
||||||
conn.execute_batch("PRAGMA user_version = 3;")?;
|
conn.execute_batch("PRAGMA user_version = 3;")?;
|
||||||
}
|
}
|
||||||
|
if version < 4 {
|
||||||
|
conn.execute_batch(SCHEMA_V4)?;
|
||||||
|
conn.execute_batch("PRAGMA user_version = 4;")?;
|
||||||
|
}
|
||||||
Ok(())
|
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> {
|
fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||||
let mut note = conn.query_row(
|
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",
|
FROM notes WHERE id = ?1",
|
||||||
[id],
|
[id],
|
||||||
|r| {
|
|r| {
|
||||||
@@ -141,6 +141,7 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
|||||||
pinned: r.get(6)?,
|
pinned: r.get(6)?,
|
||||||
archived: r.get(7)?,
|
archived: r.get(7)?,
|
||||||
trashed: r.get(8)?,
|
trashed: r.get(8)?,
|
||||||
|
deleted_at: r.get(13)?,
|
||||||
remind_at: r.get(9)?,
|
remind_at: r.get(9)?,
|
||||||
recurrence: r.get(10)?,
|
recurrence: r.get(10)?,
|
||||||
labels: Vec::new(),
|
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> {
|
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)?;
|
touch(conn, id)?;
|
||||||
load_note(conn, id)
|
load_note(conn, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn restore(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
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)?;
|
touch(conn, id)?;
|
||||||
load_note(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 status = {
|
||||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
state::set_link(&conn, &base_url, &token).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())?
|
state::status(&conn).map_err(|e| e.to_string())?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ pub struct ServerInfo {
|
|||||||
pub min_client_protocol_version: Option<u32>,
|
pub min_client_protocol_version: Option<u32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sync_features: Vec<String>,
|
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 {
|
impl ServerInfo {
|
||||||
@@ -218,6 +223,7 @@ mod tests {
|
|||||||
.copied()
|
.copied()
|
||||||
.map(String::from)
|
.map(String::from)
|
||||||
.collect(),
|
.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 status = {
|
||||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
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
|
// 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.
|
// 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);
|
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<()> {
|
fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||||
let created = note.created_at.clone().unwrap_or_else(now);
|
let created = note.created_at.clone().unwrap_or_else(now);
|
||||||
let updated = note.updated_at.clone().unwrap_or_else(|| created.clone());
|
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
|
// `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.
|
// never changes, and the server's copy is the same value anyway.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO notes (id, title, body, color, kind, position, pinned, archived,
|
"INSERT INTO notes (id, title, body, color, kind, position, pinned, archived,
|
||||||
trashed, remind_at, recurrence, created_at, updated_at,
|
trashed, remind_at, recurrence, created_at, updated_at,
|
||||||
sync_revision, dirty)
|
sync_revision, trashed_at, dirty)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, 0)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 0)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
title = excluded.title,
|
title = excluded.title,
|
||||||
body = excluded.body,
|
body = excluded.body,
|
||||||
@@ -247,6 +257,7 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
|||||||
recurrence = excluded.recurrence,
|
recurrence = excluded.recurrence,
|
||||||
updated_at = excluded.updated_at,
|
updated_at = excluded.updated_at,
|
||||||
sync_revision = excluded.sync_revision,
|
sync_revision = excluded.sync_revision,
|
||||||
|
trashed_at = excluded.trashed_at,
|
||||||
dirty = 0",
|
dirty = 0",
|
||||||
params![
|
params![
|
||||||
note.id,
|
note.id,
|
||||||
@@ -263,6 +274,7 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
|||||||
created,
|
created,
|
||||||
updated,
|
updated,
|
||||||
note.sync_revision,
|
note.sync_revision,
|
||||||
|
trashed_at,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -494,6 +506,7 @@ mod tests {
|
|||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
trashed: false,
|
trashed: false,
|
||||||
|
deleted_at: None,
|
||||||
remind_at: None,
|
remind_at: None,
|
||||||
recurrence: None,
|
recurrence: None,
|
||||||
created_at: Some("2026-07-26T00:00:00.000Z".into()),
|
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);
|
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]
|
#[test]
|
||||||
fn children_are_replaced_not_merged() {
|
fn children_are_replaced_not_merged() {
|
||||||
let conn = db();
|
let conn = db();
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ pub struct SyncState {
|
|||||||
pub device_token: Option<String>,
|
pub device_token: Option<String>,
|
||||||
pub last_cursor: i64,
|
pub last_cursor: i64,
|
||||||
pub last_sync_at: Option<String>,
|
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 {
|
impl SyncState {
|
||||||
@@ -59,12 +62,14 @@ fn present(value: Option<String>) -> Option<String> {
|
|||||||
|
|
||||||
pub fn read(conn: &Connection) -> rusqlite::Result<SyncState> {
|
pub fn read(conn: &Connection) -> rusqlite::Result<SyncState> {
|
||||||
conn.query_row(
|
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| {
|
|row| {
|
||||||
let cursor: Option<String> = row.get(2)?;
|
let cursor: Option<String> = row.get(2)?;
|
||||||
Ok(SyncState {
|
Ok(SyncState {
|
||||||
last_sync_at: present(row.get(3)?),
|
last_sync_at: present(row.get(3)?),
|
||||||
|
server_retention_days: row.get(4)?,
|
||||||
server_url: present(row.get(0)?),
|
server_url: present(row.get(0)?),
|
||||||
device_token: present(row.get(1)?),
|
device_token: present(row.get(1)?),
|
||||||
// Stored TEXT (schema) but used as an integer watermark. Absent or
|
// 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(
|
conn.execute(
|
||||||
"UPDATE sync_state
|
"UPDATE sync_state
|
||||||
SET server_url = NULL, device_token = NULL, last_cursor = NULL,
|
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",
|
WHERE id = 1",
|
||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
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
|
/// 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 —
|
/// 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
|
/// 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"));
|
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]
|
#[test]
|
||||||
fn relinking_the_same_server_keeps_the_cursor() {
|
fn relinking_the_same_server_keeps_the_cursor() {
|
||||||
let conn = db();
|
let conn = db();
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ pub struct Note {
|
|||||||
/// The server derives this from `deleted_at` — trash, NOT a tombstone.
|
/// The server derives this from `deleted_at` — trash, NOT a tombstone.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub trashed: bool,
|
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)]
|
#[serde(default)]
|
||||||
pub remind_at: Option<String>,
|
pub remind_at: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
+28
-8
@@ -33,7 +33,8 @@ token, or even has an account:
|
|||||||
{ "site_name": "...", "version": "0.1.0",
|
{ "site_name": "...", "version": "0.1.0",
|
||||||
"sync_protocol_version": 1,
|
"sync_protocol_version": 1,
|
||||||
"min_client_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
|
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:
|
Two levels, both propagate:
|
||||||
|
|
||||||
- **Trash** — `deleted_at` is a normal field. A trashed note still syncs with its
|
- **Trash** — `deleted_at` is a normal field, and it's **on the wire**: a trashed
|
||||||
content; the client shows it in its Trash. Restoring clears `deleted_at`.
|
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`
|
- **Purge (permanent delete)** — becomes a **content-less tombstone**: `purged_at`
|
||||||
is set, title/body/items/labels/attachments are cleared/removed, and the row is
|
is set, title/body/items/labels/attachments/previews/revisions are cleared or
|
||||||
kept. A client seeing `purged_at != null` deletes the row from its local store.
|
removed, and the row is kept. A client seeing `purged_at != null` deletes the row
|
||||||
Tombstones are retained indefinitely (cheap for a personal store); revisit if
|
from its local store. `deleted_at` deliberately SURVIVES a purge, so ordinary
|
||||||
they ever grow large.
|
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`
|
## Pull — `GET /api/sync/changes`
|
||||||
|
|
||||||
@@ -137,7 +156,8 @@ Response:
|
|||||||
|
|
||||||
```json
|
```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": "...",
|
"labels": [ { "id": "...", "name": "...", "color": "...",
|
||||||
"sync_revision": 43, "purged_at": null, "created_at": "..." } ],
|
"sync_revision": 43, "purged_at": null, "created_at": "..." } ],
|
||||||
"cursor": 43,
|
"cursor": 43,
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import Icon from "./Icon.vue";
|
|||||||
import LinkPreview from "./LinkPreview.vue";
|
import LinkPreview from "./LinkPreview.vue";
|
||||||
import MarkdownText from "./MarkdownText.vue";
|
import MarkdownText from "./MarkdownText.vue";
|
||||||
import NoteChecklist from "./NoteChecklist.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 props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -24,6 +25,17 @@ const emit = defineEmits<{
|
|||||||
(e: "drop", note: Note): void;
|
(e: "drop", note: Note): void;
|
||||||
}>();
|
}>();
|
||||||
const notes = useNotesStore();
|
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.
|
// 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/")));
|
const firstImage = computed(() => props.note.attachments.find((a) => a.mime.startsWith("image/")));
|
||||||
@@ -236,6 +248,32 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="trashCountdown" class="mt-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
|
||||||
|
:class="
|
||||||
|
trashUrgent
|
||||||
|
? 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300'
|
||||||
|
: 'bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300'
|
||||||
|
"
|
||||||
|
:title="`Permanently deleted ${config.trashRetentionDays} days after it was trashed`"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3 w-3"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
{{ trashCountdown }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Toolbar overlays the card's top-right on hover/focus as a floating pill
|
<!-- Toolbar overlays the card's top-right on hover/focus as a floating pill
|
||||||
(window-control style) instead of reserving a permanent row — so at rest
|
(window-control style) instead of reserving a permanent row — so at rest
|
||||||
the card is content-sized with even padding, not text pinned to the top
|
the card is content-sized with even padding, not text pinned to the top
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const draftNote = computed<Note>(() => ({
|
|||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
trashed: false,
|
trashed: false,
|
||||||
|
deleted_at: null,
|
||||||
remind_at: null,
|
remind_at: null,
|
||||||
recurrence: null,
|
recurrence: null,
|
||||||
labels: labelList.value,
|
labels: labelList.value,
|
||||||
|
|||||||
@@ -49,3 +49,36 @@ export function formatLocalDay(d: Date): string {
|
|||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Trash retention. The server permanently deletes a trashed note once it's older
|
||||||
|
// than `trash_retention_days` (0 = keep forever). Counting down from the note's own
|
||||||
|
// deleted_at is what turns that from a surprise into a policy: a card in Trash can
|
||||||
|
// say how long it has left while there's still time to restore it. ---
|
||||||
|
|
||||||
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Whole days a trashed note has left, or null when nothing will happen to it
|
||||||
|
// (retention off, or the note isn't trashed).
|
||||||
|
//
|
||||||
|
// Rounds DOWN deliberately. Rounding up would report "1 day left" for a note with
|
||||||
|
// ten minutes on the clock — overstating the time remaining is the one error here
|
||||||
|
// that actually costs someone a note.
|
||||||
|
export function trashDaysLeft(
|
||||||
|
deletedAt: string | null | undefined,
|
||||||
|
retentionDays: number,
|
||||||
|
now: number = Date.now(),
|
||||||
|
): number | null {
|
||||||
|
if (!deletedAt || retentionDays <= 0) return null;
|
||||||
|
const trashedAt = new Date(deletedAt).getTime();
|
||||||
|
if (Number.isNaN(trashedAt)) return null;
|
||||||
|
const remaining = trashedAt + retentionDays * MS_PER_DAY - now;
|
||||||
|
return remaining <= 0 ? 0 : Math.floor(remaining / MS_PER_DAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The countdown as the card shows it. "" when there's nothing to say.
|
||||||
|
export function formatTrashCountdown(daysLeft: number | null): string {
|
||||||
|
if (daysLeft === null) return "";
|
||||||
|
if (daysLeft <= 0) return "Deletes today";
|
||||||
|
if (daysLeft === 1) return "1 day left";
|
||||||
|
return `${daysLeft} days left`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface PublicConfig {
|
|||||||
allow_registration: boolean;
|
allow_registration: boolean;
|
||||||
version: string;
|
version: string;
|
||||||
enable_url_unfurl: boolean;
|
enable_url_unfurl: boolean;
|
||||||
|
// How many days a note survives in Trash before the server purges it. 0 = forever.
|
||||||
|
trash_retention_days: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public, unauthenticated app config (site name, whether signups are open).
|
// Public, unauthenticated app config (site name, whether signups are open).
|
||||||
@@ -15,6 +17,10 @@ export const useConfigStore = defineStore("config", () => {
|
|||||||
const allowRegistration = ref(true);
|
const allowRegistration = ref(true);
|
||||||
const version = ref("");
|
const version = ref("");
|
||||||
const enableUrlUnfurl = ref(true);
|
const enableUrlUnfurl = ref(true);
|
||||||
|
// Mirrors the server default (settings.REGISTRY). Only used if /api/config is
|
||||||
|
// unreachable — and 30 is a safer stand-in than 0, since claiming "kept forever"
|
||||||
|
// when the server is actually purging is the wrong way to be wrong.
|
||||||
|
const trashRetentionDays = ref(30);
|
||||||
const loaded = ref(false);
|
const loaded = ref(false);
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
@@ -25,6 +31,7 @@ export const useConfigStore = defineStore("config", () => {
|
|||||||
allowRegistration.value = cfg.allow_registration;
|
allowRegistration.value = cfg.allow_registration;
|
||||||
version.value = cfg.version;
|
version.value = cfg.version;
|
||||||
enableUrlUnfurl.value = cfg.enable_url_unfurl ?? true;
|
enableUrlUnfurl.value = cfg.enable_url_unfurl ?? true;
|
||||||
|
trashRetentionDays.value = cfg.trash_retention_days ?? 30;
|
||||||
} catch {
|
} catch {
|
||||||
// Keep defaults if the config endpoint is unreachable.
|
// Keep defaults if the config endpoint is unreachable.
|
||||||
} finally {
|
} finally {
|
||||||
@@ -37,5 +44,5 @@ export const useConfigStore = defineStore("config", () => {
|
|||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { siteName, allowRegistration, version, enableUrlUnfurl, loaded, load, reload };
|
return { siteName, allowRegistration, version, enableUrlUnfurl, trashRetentionDays, loaded, load, reload };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ export interface Note {
|
|||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
trashed: boolean;
|
trashed: boolean;
|
||||||
|
// When it was trashed (null unless trashed). The Trash view counts the retention
|
||||||
|
// window from here to show how long the note has left before it's purged.
|
||||||
|
deleted_at: string | null;
|
||||||
remind_at: string | null;
|
remind_at: string | null;
|
||||||
recurrence: string | null;
|
recurrence: string | null;
|
||||||
labels: NoteLabel[];
|
labels: NoteLabel[];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
|
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
|
||||||
|
import { useConfigStore } from "../stores/config";
|
||||||
import { useUiStore } from "../stores/ui";
|
import { useUiStore } from "../stores/ui";
|
||||||
import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
|
import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
|
||||||
import { useNoteEditor } from "../composables/useNoteEditor";
|
import { useNoteEditor } from "../composables/useNoteEditor";
|
||||||
@@ -12,6 +13,7 @@ import NoteCard from "../components/NoteCard.vue";
|
|||||||
import NoteEditor from "../components/NoteEditor.vue";
|
import NoteEditor from "../components/NoteEditor.vue";
|
||||||
|
|
||||||
const notes = useNotesStore();
|
const notes = useNotesStore();
|
||||||
|
const config = useConfigStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const ui = useUiStore();
|
const ui = useUiStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -144,6 +146,17 @@ watch(
|
|||||||
);
|
);
|
||||||
watch([currentView, currentLabel, facetKey], () => (focusedIndex.value = -1));
|
watch([currentView, currentLabel, facetKey], () => (focusedIndex.value = -1));
|
||||||
|
|
||||||
|
// The retention policy, said out loud at the top of Trash. Empty when retention is
|
||||||
|
// off — promising a deletion that never comes is its own kind of lie.
|
||||||
|
const retentionNotice = computed(() => {
|
||||||
|
if (currentView.value !== "trash") return "";
|
||||||
|
const days = config.trashRetentionDays;
|
||||||
|
if (days <= 0) return "Notes stay in Trash until you delete them.";
|
||||||
|
return days === 1
|
||||||
|
? "Notes here are permanently deleted 1 day after you trash them. Restore one to keep it."
|
||||||
|
: `Notes here are permanently deleted ${days} days after you trash them. Restore one to keep it.`;
|
||||||
|
});
|
||||||
|
|
||||||
const emptyState = computed(() => {
|
const emptyState = computed(() => {
|
||||||
if (filtered.value) return { title: "No notes match these filters", subtitle: "Try clearing or loosening a facet." };
|
if (filtered.value) return { title: "No notes match these filters", subtitle: "Try clearing or loosening a facet." };
|
||||||
if (currentView.value === "trash") return { title: "Trash is empty", subtitle: "Notes you delete land here first." };
|
if (currentView.value === "trash") return { title: "Trash is empty", subtitle: "Notes you delete land here first." };
|
||||||
@@ -208,6 +221,13 @@ async function onDrop(target: Note) {
|
|||||||
</button>
|
</button>
|
||||||
<FilterBar v-if="isMainBoard" />
|
<FilterBar v-if="isMainBoard" />
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="retentionNotice"
|
||||||
|
class="mx-auto mb-4 max-w-xl rounded-xl bg-black/5 px-4 py-2.5 text-center text-sm text-neutral-600 dark:bg-white/10 dark:text-neutral-300"
|
||||||
|
>
|
||||||
|
{{ retentionNotice }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<AsyncState
|
<AsyncState
|
||||||
:loading="notes.loading"
|
:loading="notes.loading"
|
||||||
:error="loadError || undefined"
|
:error="loadError || undefined"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
from contextlib import suppress
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from quart import Quart, has_request_context, jsonify, request, send_from_directory
|
from quart import Quart, has_request_context, jsonify, request, send_from_directory
|
||||||
@@ -15,6 +17,7 @@ from .db import session_scope
|
|||||||
from .graph import bp as graph_bp
|
from .graph import bp as graph_bp
|
||||||
from .labels import bp as labels_bp
|
from .labels import bp as labels_bp
|
||||||
from .notes import bp as notes_bp
|
from .notes import bp as notes_bp
|
||||||
|
from .retention import run_sweeper
|
||||||
from .saved_filters import bp as saved_filters_bp
|
from .saved_filters import bp as saved_filters_bp
|
||||||
from .settings import get_public_config, get_setting, load_or_create_secret_key
|
from .settings import get_public_config, get_setting, load_or_create_secret_key
|
||||||
from .settings_api import bp as settings_bp
|
from .settings_api import bp as settings_bp
|
||||||
@@ -80,6 +83,21 @@ def create_app() -> Quart:
|
|||||||
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=days)
|
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=days)
|
||||||
except (ValueError, TypeError, KeyError):
|
except (ValueError, TypeError, KeyError):
|
||||||
pass
|
pass
|
||||||
|
# Expire old trash in the background (retention.py). One task per process is
|
||||||
|
# correct because the image serves with a single hypercorn worker (Dockerfile);
|
||||||
|
# if that ever gains `--workers`, this needs a lock so N workers don't each
|
||||||
|
# sweep. Duplicate sweeps would be harmless but wasteful — a purged row is
|
||||||
|
# skipped by `purged_at IS NULL` — so this is about load, not correctness.
|
||||||
|
app.config["TRASH_SWEEPER"] = asyncio.create_task(run_sweeper())
|
||||||
|
|
||||||
|
@app.after_serving
|
||||||
|
async def _shutdown() -> None:
|
||||||
|
task = app.config.get("TRASH_SWEEPER")
|
||||||
|
if task is not None:
|
||||||
|
task.cancel()
|
||||||
|
# Await the cancellation so shutdown doesn't race a sweep mid-transaction.
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health():
|
async def health():
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ class Note(Base):
|
|||||||
"pinned": self.pinned,
|
"pinned": self.pinned,
|
||||||
"archived": self.archived,
|
"archived": self.archived,
|
||||||
"trashed": self.deleted_at is not None,
|
"trashed": self.deleted_at is not None,
|
||||||
|
# WHEN it was trashed, not just that it was: clients count the retention
|
||||||
|
# window from here to show how long a note has left before it's purged.
|
||||||
|
"deleted_at": iso(self.deleted_at),
|
||||||
"remind_at": iso(self.remind_at),
|
"remind_at": iso(self.remind_at),
|
||||||
"recurrence": self.recurrence,
|
"recurrence": self.recurrence,
|
||||||
"created_at": iso(self.created_at),
|
"created_at": iso(self.created_at),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from ..models.note_link import NoteLink
|
|||||||
from ..models.note_link_preview import NoteLinkPreview
|
from ..models.note_link_preview import NoteLinkPreview
|
||||||
from ..models.note_revision import NoteRevision
|
from ..models.note_revision import NoteRevision
|
||||||
from ..responses import json_error, not_found, parse_uuid
|
from ..responses import json_error, not_found, parse_uuid
|
||||||
|
from ..retention import purge_note
|
||||||
from ..settings import get_setting
|
from ..settings import get_setting
|
||||||
from ..unfurl import UnfurlError, unfurl
|
from ..unfurl import UnfurlError, unfurl
|
||||||
from ._bp import bp
|
from ._bp import bp
|
||||||
@@ -971,6 +972,10 @@ async def delete_note(note_id: str):
|
|||||||
return not_found()
|
return not_found()
|
||||||
if note.deleted_at is None:
|
if note.deleted_at is None:
|
||||||
return json_error("note must be trashed before permanent delete", 409)
|
return json_error("note must be trashed before permanent delete", 409)
|
||||||
await db.delete(note)
|
# A tombstone, not a dropped row. Deleting the row outright would leave the
|
||||||
|
# server with no record the note ever existed, so a linked device that was
|
||||||
|
# offline at the time would keep its copy forever — and push it back the
|
||||||
|
# next time it was edited. The delete has to be something clients can LEARN.
|
||||||
|
await purge_note(db, note)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|||||||
@@ -45,20 +45,33 @@ def parse_list_items(raw: object) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def apply_filter(stmt, filter_name: str):
|
def apply_filter(stmt, filter_name: str):
|
||||||
"""Narrow a notes query to one board view."""
|
"""Narrow a notes query to one board view.
|
||||||
|
|
||||||
|
Every branch excludes purge tombstones — content-less rows kept only so the sync
|
||||||
|
feed can tell offline clients a note is gone (see `retention.purge_note`). The
|
||||||
|
active/archived branches get that for free from `deleted_at IS NULL`, since a
|
||||||
|
tombstone keeps the timestamp; Trash is the one view that has to say so.
|
||||||
|
"""
|
||||||
if filter_name == "archived":
|
if filter_name == "archived":
|
||||||
return stmt.where(Note.deleted_at.is_(None), Note.archived.is_(True))
|
return stmt.where(Note.deleted_at.is_(None), Note.archived.is_(True))
|
||||||
if filter_name == "trash":
|
if filter_name == "trash":
|
||||||
return stmt.where(Note.deleted_at.is_not(None))
|
return stmt.where(Note.deleted_at.is_not(None), Note.purged_at.is_(None))
|
||||||
return stmt.where(Note.deleted_at.is_(None), Note.archived.is_(False))
|
return stmt.where(Note.deleted_at.is_(None), Note.archived.is_(False))
|
||||||
|
|
||||||
|
|
||||||
async def _get_owned(db, note_id: str) -> Note | None:
|
async def _get_owned(db, note_id: str) -> Note | None:
|
||||||
"""Fetch a note the current user OWNS (mutations are owner-only in M1/M2)."""
|
"""Fetch a note the current user OWNS (mutations are owner-only in M1/M2).
|
||||||
|
|
||||||
|
A purged note reads as absent: the REST API must treat it as gone, so opening,
|
||||||
|
editing or restoring one 404s. The sync push path looks rows up directly rather
|
||||||
|
than through here, which is what still lets a client re-create an id it owns.
|
||||||
|
"""
|
||||||
nid = parse_uuid(note_id)
|
nid = parse_uuid(note_id)
|
||||||
if nid is None:
|
if nid is None:
|
||||||
return None
|
return None
|
||||||
return await db.scalar(select(Note).where(Note.id == nid, Note.owner_id == g.user_id))
|
return await db.scalar(
|
||||||
|
select(Note).where(Note.id == nid, Note.owner_id == g.user_id, Note.purged_at.is_(None))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _escape_like(s: str) -> str:
|
def _escape_like(s: str) -> str:
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Trash retention — what "permanently deleted" means, and when it happens by itself.
|
||||||
|
|
||||||
|
Two things live here, deliberately together:
|
||||||
|
|
||||||
|
**`purge_note`** — the single definition of destroying a note. Three callers reach
|
||||||
|
permanent deletion by different routes (the user's Delete forever in the web UI,
|
||||||
|
a client's `op=delete` over sync, and the sweeper below), and if each had its own
|
||||||
|
idea of what to tear down they would drift — one would forget the files, another
|
||||||
|
the revision history, and "permanently deleted" would quietly mean three different
|
||||||
|
things depending on how you got there.
|
||||||
|
|
||||||
|
**The sweeper** — trash that nobody empties is not free: a trashed note keeps its
|
||||||
|
attachment BYTES on disk for as long as it sits there. So trash expires. The window
|
||||||
|
is the `trash_retention_days` setting (default 30, `0` = keep forever), re-read on
|
||||||
|
every pass so a change in admin Settings takes effect without a restart.
|
||||||
|
|
||||||
|
A purged note is not a deleted ROW — it's a content-less tombstone. That's what lets
|
||||||
|
an offline client that reappears next month learn the note is gone instead of
|
||||||
|
faithfully resurrecting it on the next push.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import delete as sa_delete
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .db import session_scope
|
||||||
|
from .models.label import NoteLabel
|
||||||
|
from .models.note import Note
|
||||||
|
from .models.note_attachment import NoteAttachment
|
||||||
|
from .models.note_item import NoteItem
|
||||||
|
from .models.note_link import NoteLink
|
||||||
|
from .models.note_link_preview import NoteLinkPreview
|
||||||
|
from .models.note_revision import NoteRevision
|
||||||
|
from .settings import get_setting
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# How often the sweeper wakes. Retention is measured in days, so anything under
|
||||||
|
# "a few times a day" buys nothing but load — a note trashed at 09:00 expiring at
|
||||||
|
# 14:00 rather than 09:00 thirty days later is not a difference anyone can feel.
|
||||||
|
SWEEP_INTERVAL_SECONDS = 6 * 60 * 60
|
||||||
|
|
||||||
|
# Let the app finish booting (migrations, first requests) before the first sweep.
|
||||||
|
SWEEP_STARTUP_DELAY_SECONDS = 60
|
||||||
|
|
||||||
|
# Rows purged per transaction. A long-neglected install could have thousands of
|
||||||
|
# expired notes on the first sweep; committing in batches keeps that from becoming
|
||||||
|
# one enormous transaction holding locks while it deletes files.
|
||||||
|
SWEEP_BATCH = 200
|
||||||
|
|
||||||
|
|
||||||
|
def expired_before(now: datetime, retention_days: int) -> datetime | None:
|
||||||
|
"""The cutoff: trash older than this has expired. `None` = retention is off.
|
||||||
|
|
||||||
|
Kept separate from the query so the window arithmetic — including the two ways
|
||||||
|
to say "never" (0 and negative, the latter reachable by typing a stray minus in
|
||||||
|
Settings) — is testable without a database.
|
||||||
|
"""
|
||||||
|
if retention_days <= 0:
|
||||||
|
return None
|
||||||
|
return now - timedelta(days=retention_days)
|
||||||
|
|
||||||
|
|
||||||
|
async def purge_note(db, note: Note, edited_at: datetime | None = None) -> None:
|
||||||
|
"""Turn a note into a content-less tombstone: delete its children (and the
|
||||||
|
attachment files on disk), clear its content, stamp `purged_at`.
|
||||||
|
|
||||||
|
The row survives on purpose — offline clients read it off the delta feed and
|
||||||
|
learn the note is gone. Everything that carries the note's CONTENT goes, and
|
||||||
|
that includes history: a revision row holds the full body, so leaving revisions
|
||||||
|
behind would mean the text of a "permanently deleted" note is still on the
|
||||||
|
server, recoverable by anyone who can read the table.
|
||||||
|
"""
|
||||||
|
atts = (await db.scalars(select(NoteAttachment).where(NoteAttachment.note_id == note.id))).all()
|
||||||
|
for a in atts:
|
||||||
|
try:
|
||||||
|
(Config.media_root() / a.path).unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
# A missing or unreadable file must not strand the row: the DB record is
|
||||||
|
# what the user asked us to destroy, and a failed unlink leaving it in
|
||||||
|
# place would make the note reappear whole on the next sweep.
|
||||||
|
logger.warning("couldn't remove attachment file %s during purge", a.path, exc_info=True)
|
||||||
|
await db.execute(sa_delete(NoteAttachment).where(NoteAttachment.note_id == note.id))
|
||||||
|
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
|
||||||
|
await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id))
|
||||||
|
await db.execute(sa_delete(NoteLink).where(NoteLink.source_id == note.id))
|
||||||
|
await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))
|
||||||
|
await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id))
|
||||||
|
note.title = None
|
||||||
|
note.body = ""
|
||||||
|
note.display_title = ""
|
||||||
|
# `deleted_at` deliberately SURVIVES. It's still true — that is when the note was
|
||||||
|
# deleted — and keeping it means every ordinary query, present and future, that
|
||||||
|
# says "not trashed" (`deleted_at IS NULL`) excludes tombstones for free. Clearing
|
||||||
|
# it would leave a content-less row looking like a perfectly normal active note,
|
||||||
|
# and it would surface on the board as a blank card. Only the Trash view, which
|
||||||
|
# asks for `deleted_at IS NOT NULL`, has to name `purged_at` explicitly.
|
||||||
|
note.remind_at = None
|
||||||
|
note.purged_at = datetime.now(timezone.utc)
|
||||||
|
if edited_at is not None:
|
||||||
|
note.updated_at = edited_at
|
||||||
|
|
||||||
|
|
||||||
|
async def sweep_expired_trash(db, retention_days: int, *, now: datetime | None = None) -> int:
|
||||||
|
"""Purge every note whose trash has expired. Returns how many were purged.
|
||||||
|
|
||||||
|
Runs across ALL owners — it's a server-wide policy, not a per-user action, and
|
||||||
|
the sweeper has no session to scope it by (rule 47 is about honoring the ACL on
|
||||||
|
user-initiated reads, not about exempting rows from server maintenance).
|
||||||
|
"""
|
||||||
|
cutoff = expired_before(now or datetime.now(timezone.utc), retention_days)
|
||||||
|
if cutoff is None:
|
||||||
|
return 0
|
||||||
|
total = 0
|
||||||
|
while True:
|
||||||
|
expired = (
|
||||||
|
await db.scalars(
|
||||||
|
select(Note)
|
||||||
|
.where(
|
||||||
|
Note.deleted_at.is_not(None),
|
||||||
|
Note.deleted_at < cutoff,
|
||||||
|
# Already a tombstone. Without this the purge would re-run on
|
||||||
|
# every sweep forever, bumping sync_revision each time and
|
||||||
|
# handing clients an endless stream of "news" about one note.
|
||||||
|
Note.purged_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Note.deleted_at)
|
||||||
|
.limit(SWEEP_BATCH)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
if not expired:
|
||||||
|
return total
|
||||||
|
for note in expired:
|
||||||
|
await purge_note(db, note)
|
||||||
|
await db.commit()
|
||||||
|
total += len(expired)
|
||||||
|
|
||||||
|
|
||||||
|
async def sweep_once() -> int:
|
||||||
|
"""One sweep against the live retention setting, in its own session."""
|
||||||
|
async with session_scope() as db:
|
||||||
|
try:
|
||||||
|
days = int(await get_setting(db, "trash_retention_days"))
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
return await sweep_expired_trash(db, days)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_sweeper() -> None:
|
||||||
|
"""The background loop. Started in `before_serving`, cancelled on shutdown.
|
||||||
|
|
||||||
|
A sweep failure (DB blip, unreadable media directory) must never take the loop
|
||||||
|
down with it — the next pass simply finds the same expired rows and tries again.
|
||||||
|
"""
|
||||||
|
await asyncio.sleep(SWEEP_STARTUP_DELAY_SECONDS)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
purged = await sweep_once()
|
||||||
|
if purged:
|
||||||
|
logger.info("trash retention: purged %d expired note(s)", purged)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("trash retention sweep failed; will retry next pass")
|
||||||
|
await asyncio.sleep(SWEEP_INTERVAL_SECONDS)
|
||||||
@@ -43,6 +43,15 @@ REGISTRY: list[SettingDef] = [
|
|||||||
"How long a signed-in session stays valid before another login is required.",
|
"How long a signed-in session stays valid before another login is required.",
|
||||||
"Access",
|
"Access",
|
||||||
),
|
),
|
||||||
|
SettingDef(
|
||||||
|
"trash_retention_days",
|
||||||
|
"int",
|
||||||
|
30,
|
||||||
|
"Trash retention (days)",
|
||||||
|
"How long a note stays in Trash before it's permanently deleted, freeing its "
|
||||||
|
"attachments from disk. Set to 0 to keep trashed notes until they're deleted by hand.",
|
||||||
|
"Notes",
|
||||||
|
),
|
||||||
SettingDef(
|
SettingDef(
|
||||||
"max_attachment_mb",
|
"max_attachment_mb",
|
||||||
"int",
|
"int",
|
||||||
@@ -117,11 +126,16 @@ async def get_setting(db, key: str) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
async def get_public_config(db) -> dict:
|
async def get_public_config(db) -> dict:
|
||||||
"""Non-sensitive settings the unauthenticated login/register screen needs."""
|
"""Non-sensitive settings every client reads — the login/register screen before
|
||||||
|
sign-in, and the app itself afterwards. Nothing here is owner-scoped."""
|
||||||
return {
|
return {
|
||||||
"site_name": await get_setting(db, "site_name"),
|
"site_name": await get_setting(db, "site_name"),
|
||||||
"allow_registration": await get_setting(db, "allow_registration"),
|
"allow_registration": await get_setting(db, "allow_registration"),
|
||||||
"enable_url_unfurl": await get_setting(db, "enable_url_unfurl"),
|
"enable_url_unfurl": await get_setting(db, "enable_url_unfurl"),
|
||||||
|
# Server policy, not user data: clients need it to say how long a note has
|
||||||
|
# left in Trash. A native client also reads it BEFORE linking, which is why
|
||||||
|
# it belongs on the unauthenticated config rather than behind login.
|
||||||
|
"trash_retention_days": await get_setting(db, "trash_retention_days"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-27
@@ -20,14 +20,11 @@ from sqlalchemy import func, select
|
|||||||
|
|
||||||
from .auth import login_required
|
from .auth import login_required
|
||||||
from .common import iso, parse_dt
|
from .common import iso, parse_dt
|
||||||
from .config import Config
|
|
||||||
from .db import session_scope
|
from .db import session_scope
|
||||||
from .labeling import reconcile_manual_labels, resolve_owned_label_ids
|
from .labeling import reconcile_manual_labels, resolve_owned_label_ids
|
||||||
from .models.label import Label, NoteLabel
|
from .models.label import Label, NoteLabel
|
||||||
from .models.note import Note
|
from .models.note import Note
|
||||||
from .models.note_attachment import NoteAttachment
|
|
||||||
from .models.note_item import NoteItem
|
from .models.note_item import NoteItem
|
||||||
from .models.note_link import NoteLink
|
|
||||||
from .models.note_revision import NoteRevision
|
from .models.note_revision import NoteRevision
|
||||||
from .notes import (
|
from .notes import (
|
||||||
_reconcile_tags,
|
_reconcile_tags,
|
||||||
@@ -38,6 +35,7 @@ from .notes import (
|
|||||||
normalize_color,
|
normalize_color,
|
||||||
normalize_recurrence,
|
normalize_recurrence,
|
||||||
)
|
)
|
||||||
|
from .retention import purge_note
|
||||||
from .serialize import serialize_label_sync
|
from .serialize import serialize_label_sync
|
||||||
|
|
||||||
bp = Blueprint("sync", __name__, url_prefix="/api/sync")
|
bp = Blueprint("sync", __name__, url_prefix="/api/sync")
|
||||||
@@ -245,29 +243,6 @@ async def _apply_note_manual_labels(db, note: Note, ch: dict) -> None:
|
|||||||
await reconcile_manual_labels(db, note, owned)
|
await reconcile_manual_labels(db, note, owned)
|
||||||
|
|
||||||
|
|
||||||
async def _purge_note(db, note: Note, edited_at: datetime | None) -> None:
|
|
||||||
"""Turn a note into a content-less tombstone: delete children (+ attachment files),
|
|
||||||
clear content, set purged_at. Kept so offline clients learn it's gone."""
|
|
||||||
atts = (await db.scalars(select(NoteAttachment).where(NoteAttachment.note_id == note.id))).all()
|
|
||||||
for a in atts:
|
|
||||||
try:
|
|
||||||
(Config.media_root() / a.path).unlink(missing_ok=True)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
await db.execute(sa_delete(NoteAttachment).where(NoteAttachment.note_id == note.id))
|
|
||||||
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
|
|
||||||
await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id))
|
|
||||||
await db.execute(sa_delete(NoteLink).where(NoteLink.source_id == note.id))
|
|
||||||
note.title = None
|
|
||||||
note.body = ""
|
|
||||||
note.display_title = ""
|
|
||||||
note.deleted_at = None
|
|
||||||
note.remind_at = None
|
|
||||||
note.purged_at = datetime.now(timezone.utc)
|
|
||||||
if edited_at is not None:
|
|
||||||
note.updated_at = edited_at
|
|
||||||
|
|
||||||
|
|
||||||
async def _apply_note(db, ch: dict) -> dict:
|
async def _apply_note(db, ch: dict) -> dict:
|
||||||
raw_id = ch.get("id")
|
raw_id = ch.get("id")
|
||||||
try:
|
try:
|
||||||
@@ -290,7 +265,7 @@ async def _apply_note(db, ch: dict) -> dict:
|
|||||||
return {"id": str(nid), "entity": "note", "status": "noop"}
|
return {"id": str(nid), "entity": "note", "status": "noop"}
|
||||||
if not client_wins(edited_at, note.updated_at):
|
if not client_wins(edited_at, note.updated_at):
|
||||||
return {"id": str(nid), "entity": "note", "status": "kept", "sync_revision": note.sync_revision}
|
return {"id": str(nid), "entity": "note", "status": "kept", "sync_revision": note.sync_revision}
|
||||||
await _purge_note(db, note, edited_at)
|
await purge_note(db, note, edited_at)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(note, ["sync_revision"])
|
await db.refresh(note, ["sync_revision"])
|
||||||
return {"id": str(nid), "entity": "note", "status": "applied", "sync_revision": note.sync_revision}
|
return {"id": str(nid), "entity": "note", "status": "applied", "sync_revision": note.sync_revision}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from thoughtsync.retention import (
|
||||||
|
SWEEP_BATCH,
|
||||||
|
SWEEP_INTERVAL_SECONDS,
|
||||||
|
SWEEP_STARTUP_DELAY_SECONDS,
|
||||||
|
expired_before,
|
||||||
|
sweep_expired_trash,
|
||||||
|
)
|
||||||
|
from thoughtsync.settings import REGISTRY, get_public_config, validate_updates
|
||||||
|
|
||||||
|
NOW = datetime(2026, 7, 26, 12, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_before_is_the_window_ago():
|
||||||
|
assert expired_before(NOW, 30) == NOW - timedelta(days=30)
|
||||||
|
assert expired_before(NOW, 1) == NOW - timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_means_keep_forever():
|
||||||
|
# The opt-out. A user who wants Trash to be an indefinite archive gets one, and
|
||||||
|
# `None` is what stops the sweep before it builds a query at all.
|
||||||
|
assert expired_before(NOW, 0) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_negative_window_also_means_never():
|
||||||
|
# Reachable by typing a stray minus into the Settings field. The dangerous reading
|
||||||
|
# of -1 would be "expired a day in the FUTURE", which purges the entire trash on
|
||||||
|
# the next sweep; refusing to run is the only safe interpretation.
|
||||||
|
assert expired_before(NOW, -1) is None
|
||||||
|
assert expired_before(NOW, -3650) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sweep_is_a_noop_when_retention_is_off():
|
||||||
|
# Passing None as the session proves it: retention off must return before it so
|
||||||
|
# much as touches the database.
|
||||||
|
assert await sweep_expired_trash(None, 0) == 0
|
||||||
|
assert await sweep_expired_trash(None, -1) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_retention_setting_is_registered_with_a_30_day_default():
|
||||||
|
defn = next((d for d in REGISTRY if d.key == "trash_retention_days"), None)
|
||||||
|
assert defn is not None, "the setting must appear in the admin Settings UI"
|
||||||
|
assert defn.type == "int"
|
||||||
|
assert defn.default == 30
|
||||||
|
# The operator has to be able to tell what it does without reading the code.
|
||||||
|
assert "0" in defn.description, "the keep-forever escape hatch must be documented"
|
||||||
|
|
||||||
|
|
||||||
|
def test_retention_setting_accepts_an_int_and_rejects_nonsense():
|
||||||
|
clean, err = validate_updates({"trash_retention_days": "7"})
|
||||||
|
assert err is None
|
||||||
|
assert clean == {"trash_retention_days": 7}
|
||||||
|
_, err = validate_updates({"trash_retention_days": "soon"})
|
||||||
|
assert err is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_public_config_publishes_the_window():
|
||||||
|
# Clients need it to say how long a note has left in Trash, and a native client
|
||||||
|
# reads it before it holds any credential — so it rides the unauthenticated
|
||||||
|
# config. A stub session stands in for the DB: no row set => registry default.
|
||||||
|
class _NoRows:
|
||||||
|
async def get(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
cfg = await get_public_config(_NoRows())
|
||||||
|
assert cfg["trash_retention_days"] == 30
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value", [SWEEP_INTERVAL_SECONDS, SWEEP_STARTUP_DELAY_SECONDS, SWEEP_BATCH]
|
||||||
|
)
|
||||||
|
def test_sweeper_pacing_constants_are_positive(value):
|
||||||
|
# A zero interval would turn the background loop into a busy spin against the DB.
|
||||||
|
assert value > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sweep_interval_is_well_under_a_day():
|
||||||
|
# Retention is measured in days, but the sweep still has to run often enough that
|
||||||
|
# "30 days" doesn't quietly become 31.
|
||||||
|
assert SWEEP_INTERVAL_SECONDS <= 12 * 60 * 60
|
||||||
Reference in New Issue
Block a user