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

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:
2026-07-26 16:20:13 -04:00
co-authored by Claude Opus 5
parent 6f35e6e6d8
commit e64d67e904
28 changed files with 892 additions and 51 deletions
+2 -27
View File
@@ -20,14 +20,11 @@ from sqlalchemy import func, select
from .auth import login_required
from .common import iso, parse_dt
from .config import Config
from .db import session_scope
from .labeling import reconcile_manual_labels, resolve_owned_label_ids
from .models.label import Label, 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_revision import NoteRevision
from .notes import (
_reconcile_tags,
@@ -38,6 +35,7 @@ from .notes import (
normalize_color,
normalize_recurrence,
)
from .retention import purge_note
from .serialize import serialize_label_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)
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:
raw_id = ch.get("id")
try:
@@ -290,7 +265,7 @@ async def _apply_note(db, ch: dict) -> dict:
return {"id": str(nid), "entity": "note", "status": "noop"}
if not client_wins(edited_at, note.updated_at):
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.refresh(note, ["sync_revision"])
return {"id": str(nid), "entity": "note", "status": "applied", "sync_revision": note.sync_revision}