feat(notes): compact the operation log and purge old tombstones
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m17s

- Background job at start and every 6 h: log entries older than 30 days
  (private notes: 7) are removed, documents.log_floor marks the boundary;
  clients behind it get resync and keep their queued changes
- Deleted blocks are purged 30 days after deleted_at via a "purge" log
  effect, so open editors drop them without reloading; a tombstone stays
  while a deleted child still points to it
- Text for a purged block becomes a new block at the end; inserts under a
  purged parent go to the top level (server and browser model)
- doc_op_results kept for 90 days; expired lease/session maps are swept
- Tests: storage, API, engine resync, simulation with compactions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 15:08:13 +01:00
1 parent aea4324c20
commit f26f286fb8
17 files changed
+653 -48

No files matched your search

+15 -4
View File
@@ -36,7 +36,8 @@ export interface PropsPatch {
clearConflict?: boolean;
}
export type OpKind = 'insert' | 'set_content' | 'set_props' | 'move' | 'delete' | 'restore' | 'set_title';
// 'purge' only appears as a log effect: log compaction removed old tombstones for good
export type OpKind = 'insert' | 'set_content' | 'set_props' | 'move' | 'delete' | 'restore' | 'set_title' | 'purge';
export interface Op {
opId: string;
@@ -243,6 +244,9 @@ export function applyEffect(d: Doc, e: Effect): void {
d.title = e.title || '';
d.titleVersion = e.titleVersion || 0;
break;
case 'purge':
for (const id of e.ids || []) d.blocks.delete(id);
break;
}
}
@@ -295,8 +299,8 @@ export function applyOp(d: Doc, op: Op, by = '', conflictId: (opId: string) => s
if (d.blocks.has(op.blockId)) return reject('exists');
let parent = op.parentId || '';
if (parent) {
if (!d.blocks.has(parent)) return reject('parent_missing');
parent = liveAncestor(d, parent);
// A purged parent (compaction) puts the block at the top level, like the server
parent = d.blocks.has(parent) ? liveAncestor(d, parent) : '';
}
if (!op.orderKey || !validOrderKey(op.orderKey)) return reject('invalid_order_key');
emit({
@@ -313,8 +317,15 @@ export function applyOp(d: Doc, op: Op, by = '', conflictId: (opId: string) => s
}
case 'set_content': {
const b = d.blocks.get(op.blockId || '');
if (!b) return reject('missing');
if (op.content === undefined) return reject('no_content');
if (!b) {
// The block was purged (offline for weeks): like the server, the text becomes a new block at the end
if (!op.content || !op.blockId || op.blockId.length > 64) return reject('missing');
const roots = children(d, '');
const key = keyBetween(roots.length ? roots[roots.length - 1].orderKey : '', '');
emit({ kind: 'insert', blockId: op.blockId, parentId: '', orderKey: key, type: 'bullet', content: op.content, props: {}, version: 1 });
return done({ status: 'applied', version: 1, reason: 'revived' });
}
if (b.deleted) restore([b.id]);
if (op.content === b.content) return done({ status: 'applied', version: b.version });
if ((op.baseVersion ?? 0) !== b.version) {