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:
+28
-8
@@ -33,7 +33,8 @@ token, or even has an account:
|
||||
{ "site_name": "...", "version": "0.1.0",
|
||||
"sync_protocol_version": 1,
|
||||
"min_client_protocol_version": 1,
|
||||
"sync_features": ["notes", "labels", "attachments", "tombstones", "revisions"] }
|
||||
"sync_features": ["notes", "labels", "attachments", "tombstones", "revisions"],
|
||||
"trash_retention_days": 30 }
|
||||
```
|
||||
|
||||
The client identifies itself on every request with
|
||||
@@ -121,13 +122,31 @@ as `?since=`. `since=0` (or absent) is a **full initial sync**.
|
||||
|
||||
Two levels, both propagate:
|
||||
|
||||
- **Trash** — `deleted_at` is a normal field. A trashed note still syncs with its
|
||||
content; the client shows it in its Trash. Restoring clears `deleted_at`.
|
||||
- **Trash** — `deleted_at` is a normal field, and it's **on the wire**: a trashed
|
||||
note still syncs with its content, the client shows it in its Trash, and the
|
||||
timestamp is what the client counts the retention window against. Restoring
|
||||
clears it.
|
||||
- **Purge (permanent delete)** — becomes a **content-less tombstone**: `purged_at`
|
||||
is set, title/body/items/labels/attachments are cleared/removed, and the row is
|
||||
kept. A client seeing `purged_at != null` deletes the row from its local store.
|
||||
Tombstones are retained indefinitely (cheap for a personal store); revisit if
|
||||
they ever grow large.
|
||||
is set, title/body/items/labels/attachments/previews/revisions are cleared or
|
||||
removed, and the row is kept. A client seeing `purged_at != null` deletes the row
|
||||
from its local store. `deleted_at` deliberately SURVIVES a purge, so ordinary
|
||||
server-side queries (`deleted_at IS NULL`) never see a tombstone as a live note.
|
||||
Tombstones themselves are retained indefinitely (cheap for a personal store);
|
||||
revisit if they ever grow large.
|
||||
|
||||
### Retention — trash expires
|
||||
|
||||
A trashed note is purged automatically once it is older than the server's
|
||||
`trash_retention_days` setting (default **30**, `0` = keep forever), advertised on
|
||||
`/api/config` so a client can show the countdown. A background sweep on the server
|
||||
does the work; clients learn about it as ordinary tombstones and need no special
|
||||
handling.
|
||||
|
||||
**A linked client must not run its own expiry.** The server owns the policy — one
|
||||
clock, one window. A client that purged on its own schedule could destroy a note
|
||||
the server was deliberately keeping and then push that delete upstream. An
|
||||
*unlinked* client (offline-only, no server to defer to) expires its own trash on
|
||||
its own default, which is the only case where nothing else can.
|
||||
|
||||
## Pull — `GET /api/sync/changes`
|
||||
|
||||
@@ -137,7 +156,8 @@ Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"notes": [ { "...full note...", "sync_revision": 42, "purged_at": null } ],
|
||||
"notes": [ { "...full note...", "trashed": false, "deleted_at": null,
|
||||
"sync_revision": 42, "purged_at": null } ],
|
||||
"labels": [ { "id": "...", "name": "...", "color": "...",
|
||||
"sync_revision": 43, "purged_at": null, "created_at": "..." } ],
|
||||
"cursor": 43,
|
||||
|
||||
Reference in New Issue
Block a user