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

+16
View File
@@ -122,6 +122,22 @@ export function extractNoteLinks(doc: DocNode, selfId?: string): string[] {
return [...ids];
}
/**
* Ids of every attachment this document embeds.
*
* The server stores content encrypted and cannot look inside, so this is the
* only account of what a note still uses. It is what lets a picture removed from
* a note eventually be freed — and, just as important, what stops one still in
* use from being deleted.
*/
export function extractAttachmentIds(doc: DocNode): string[] {
const ids = new Set<string>();
walk(doc, (n) => {
if (n.type === "image" && n.attrs?.attachmentId) ids.add(n.attrs.attachmentId);
});
return [...ids];
}
/** Plain text of a document — used for list previews and local search. */
export function docToText(doc: unknown, limit = 400): string {
const parts: string[] = [];
+4 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useCallback } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { buildExtensions } from "./extensions.js";
import { parseNoteContent, serializeNoteContent, extractNoteLinks } from "./content";
import { parseNoteContent, serializeNoteContent, extractNoteLinks, extractAttachmentIds } from "./content";
import { uploadImage, ACCEPTED_TYPES } from "./attachments.js";
import "./editor.css";
@@ -78,6 +78,9 @@ export function useNoteEditor({ noteId, content, editable = true, onChange, sear
onChangeRef.current?.({
content: serializeNoteContent(doc),
links: extractNoteLinks(doc, noteId),
// Travels with every save: the server cannot read the body, so this is
// its only account of which pictures the note still uses.
attachments: extractAttachmentIds(doc),
});
},
// Rebuilt per note so each one starts with its own document and history.