Free unused attachments, and keep versions by count rather than by age

Two rules governed version retention: the newest 50 per note, and a sweep
of anything past 90 days. The second was wrong. The count already bounds
what a note can accumulate, so age only ever deleted history nothing had
replaced — and it bit hardest exactly where history is most wanted, a
note left alone for a quarter of a year losing its whole past for having
done nothing. It is off unless VERSION_RETENTION_DAYS is set.

How many versions to keep is now a notebook's own setting, falling back
to the server default when left empty. A reference notebook can keep
fifty of everything while a scratch one keeps three.

That makes freeing attachments answerable. A picture taken out of a note
cannot be deleted straight away: the versions from before still show it,
and restoring one would find a hole. It is released once no current note
and no kept version refers to it — which, with retention by count, is
when the last version holding it is pushed out.

The server cannot look for itself, bodies being encrypted, so the client
reports a note's attachments on every save as it already does for links,
and a version inherits what the note held when it was snapshotted — by
definition what that version contains.

Two things make it safe rather than merely plausible. Silence is not
"uses nothing": a note written before this existed has no rows, which
looks identical to one with no pictures, so nothing is released for a
user while any of their notes has yet to report — the prefetch that fills
the search index reports for them on its first pass. And an upload has a
row before the note using it does, so recent attachments are left alone.

POST /api/admin/housekeeping runs the sweep on demand.

Caught while testing: the same block of markup had been pasted into the
create-notebook dialog as well, where the state it referenced did not
exist — the dialog crashed on open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-08 22:22:46 +02:00
1 parent a32d6eba99
commit 3acb8b66a4
12 files changed
+507 -28

No files matched your search

+43 -4
View File
@@ -331,6 +331,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
| `drawing` | 7 | strokes survive the poller, and match across four viewport widths |
| `pdf` | 41 | import, page proportions, lazy loading, and reading with the network off |
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
@@ -595,6 +596,33 @@ one, never a half-written one that would fail its tag forever after.
---
## Freeing attachments
Take a picture out of a note and the file cannot simply be deleted: the versions
from before still show it, and restoring one would find a hole where the image
was. So an attachment is released only once **no current note and no kept
version** refers to it — which, with retention by count, is the moment the last
version holding it is pushed out by newer ones.
The server cannot look for itself: bodies are encrypted. So the client reports
which attachments a note uses on every save, exactly as it already does for note
links, and a version inherits whatever the note held at the moment it was
snapshotted — which is by definition what that version contains.
Two things make this safe rather than merely plausible:
- **Silence is not "uses nothing".** A note written before references existed has
no rows, which looks identical to a note with no pictures. Nothing is released
for a user while any of their notes has yet to report; the background prefetch
that fills the search index reports for them on its first pass.
- **An upload in flight has a row before the note that uses it does.** Attachments
younger than the grace period are left alone.
`POST /api/admin/housekeeping` runs the sweep on demand instead of waiting for
its timer — admin only, and it accepts `graceSeconds` to shorten that wait.
---
## Attachments
Images used to be inlined into the note body as base64 data URIs — a few
@@ -638,13 +666,24 @@ Restoring snapshots the current state first (`before-restore`), so picking the
wrong version is not a one-way door. A restored body also rebuilds its entries
in the backlink index.
**Retention** — the newest 50 versions per note, trimmed on every write, plus a
daily sweep of anything older than 90 days. Both are configurable:
**Retention is by count, not by age.** The newest N versions of a note are kept
and trimmed on every write; nothing expires merely for being old.
There used to be a second rule dropping anything past 90 days, and it was a bad
one: the count already bounds how much a note can accumulate, so age only ever
deleted history that nothing had replaced — and it bit hardest exactly where
history is most wanted. A note left alone for a quarter of a year lost its whole
past for having done nothing at all. `VERSION_RETENTION_DAYS` still exists and
still works, but it is off unless set.
**How many is decided per notebook.** Edit a notebook to give it its own number;
leave the field empty and it follows the server's default. A reference notebook
can keep fifty versions of everything while a scratch one keeps three.
| Variable | Default | Meaning |
|----------------------------|---------|------------------------------------------|
| `MAX_VERSIONS_PER_NOTE` | `50` | Versions kept per note |
| `VERSION_RETENTION_DAYS` | `90` | Age after which a version is dropped |
| `MAX_VERSIONS_PER_NOTE` | `50` | Default for notebooks with no setting |
| `VERSION_RETENTION_DAYS` | *off* | Optional age limit; unset means never |
| `VERSION_COALESCE_MINUTES` | `5` | Window in which saves share one snapshot |
**Deleting a note** — moving one to the trash keeps its history, because the