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

+49 -6
View File
@@ -71,6 +71,7 @@ interface ServerNotebook {
name: string;
cover_index?: number;
sort_order?: number;
max_versions?: number | null;
}
interface ServerNote {
@@ -104,6 +105,8 @@ export interface NoteUpdate {
notebookId?: string | null;
starred?: boolean;
links?: string[];
/** Attachments the body embeds, so ones nothing uses can be released. */
attachments?: string[];
/**
* The server revision this edit was written on top of.
*
@@ -399,6 +402,7 @@ export async function fullSync(): Promise<void> {
name: nb.name,
cover: nb.cover_index || 0,
sortOrder: nb.sort_order || 0,
maxVersions: nb.max_versions ?? null,
}));
const serverNbIds = new Set(nbs.map((nb) => nb.id));
const unsyncedNbs = localNbs.filter((nb) => nb._pendingCreate && !serverNbIds.has(nb.id));
@@ -459,7 +463,10 @@ function toNoteMeta(n: ServerNote, trashed: boolean): NoteMeta {
/** Every kind of change that can wait offline. */
type QueuedAction =
| { action: "createNotebook"; data: { localId: string; name: string; cover: number; sortOrder: number } }
| { action: "updateNotebook"; data: { id: string; name?: string; cover?: number; sortOrder?: number } }
| {
action: "updateNotebook";
data: { id: string; name?: string; cover?: number; sortOrder?: number; maxVersions?: number | null };
}
| { action: "deleteNotebook"; data: { id: string } }
| { action: "createNote"; data: { localId: string; title: string; folderId: string | null } }
| { action: "updateNote"; data: { id: string; updates: NoteUpdate } }
@@ -542,6 +549,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
name: a.data.name,
coverIndex: a.data.cover,
sortOrder: a.data.sortOrder,
...(a.data.maxVersions !== undefined ? { maxVersions: a.data.maxVersions } : {}),
});
break;
case "deleteNotebook":
@@ -695,6 +703,9 @@ export async function updateNotebook(id: string, updates: Partial<Notebook>): Pr
name: updates.name,
coverIndex: updates.cover,
sortOrder: updates.sortOrder,
// null is meaningful here — it clears the notebook's own setting — so it
// has to travel, while undefined must stay away and change nothing.
...(updates.maxVersions !== undefined ? { maxVersions: updates.maxVersions } : {}),
});
return;
} catch (e) {
@@ -703,7 +714,13 @@ export async function updateNotebook(id: string, updates: Partial<Notebook>): Pr
}
await enqueue({
action: "updateNotebook",
data: { id, name: updates.name, cover: updates.cover, sortOrder: updates.sortOrder },
data: {
id,
name: updates.name,
cover: updates.cover,
sortOrder: updates.sortOrder,
maxVersions: updates.maxVersions,
},
});
notifyStatus("pending");
}
@@ -851,7 +868,12 @@ export async function updateNoteTitle(id: string, title: string): Promise<void>
});
}
export async function updateNoteContent(id: string, content: string, links?: string[]): Promise<void> {
export async function updateNoteContent(
id: string,
content: string,
links?: string[],
attachments?: string[]
): Promise<void> {
const ex = await db.getNoteContent(id);
await db.putNoteContent(id, content, ex?.drawingData || null, ex?.drawingStrokes || null);
const note = await db.getNote(id);
@@ -860,9 +882,9 @@ export async function updateNoteContent(id: string, content: string, links?: str
// The server cannot read the encrypted body, so outgoing links travel with it.
// baseUpdatedAt is the revision this copy came from — see NoteUpdate.
const updates: NoteUpdate = Array.isArray(links)
? { content, links, baseUpdatedAt: ex?.updatedAt ?? null }
: { content, baseUpdatedAt: ex?.updatedAt ?? null };
const updates: NoteUpdate = { content, baseUpdatedAt: ex?.updatedAt ?? null };
if (Array.isArray(links)) updates.links = links;
if (Array.isArray(attachments)) updates.attachments = attachments;
debounce(`content_${id}`, async () => {
if (_online && api.token && !note?._pendingCreate) {
@@ -1081,6 +1103,26 @@ export async function createPdfNote(
* search with almost nothing to look at on a fresh device. This walks the notes
* with no cached body (or a stale one) and fills them in, deliberately slowly so
* it never competes with what the user is doing. */
/**
* Tells the server which attachments a note body uses.
*
* Notes written before attachment references existed have never said, and the
* server cannot look inside an encrypted body to find out. Until every note has
* reported, nothing is ever released — so this pass, riding along with the
* prefetch that fills the search index, is what eventually lets unused pictures
* be freed. Sent even when empty: "uses nothing" is the answer that matters.
*/
async function reportAttachments(noteId: string, content: string): Promise<void> {
try {
const { parseNoteContent, extractAttachmentIds } = await import("./editor/content");
const { parsingExtensions } = await import("./editor/extensions.js");
const ids = extractAttachmentIds(parseNoteContent(content, parsingExtensions));
await api.put(`/notes/${noteId}`, { attachments: ids });
} catch {
/* one note that will not parse must not stop the pass */
}
}
export interface PrefetchProgress {
id: string;
content: string;
@@ -1114,6 +1156,7 @@ export async function prefetchAllContent({
const d = await api.get<ServerNoteBody>(`/notes/${n.id}`);
const c = shapeContent(d);
await db.putNoteContent(n.id, c.content, c.drawingData, c.drawingStrokes, c.updatedAt);
await reportAttachments(n.id, c.content);
fetched++;
onProgress?.({ id: n.id, content: c.content, fetched, total: pending.length });
} catch {