1/** 2 * Page-version retention plan (T-152) — pure, deterministic. 3 * 4 * Per-page rule (spec: "snapshot frequency, storage overhead, revision 5 * retention policies"): keep the NEWEST `maxVersions` versions AND every 6 * version newer than the `keepRecentDays` grace period (so a freshly 7 * autosaved page is never pruned mid-life). Everything else is prunable. 8 * 9 * prune = version ∉ newest(maxVersions) AND createdAt < now - keepRecentDays 10 * 11 * The plan only returns PRUNABLE version numbers — the worker applies it 12 * per page (the newest set is per-page, not global). 13 */ 14 15export interface VersionRow { 16 id: string; 17 version: number; 18 createdAt: Date; 19} 20 21export interface PruneOptions { 22 /** Keep at least this many newest versions per page (default 100). */ 23 maxVersions?: number; 24 /** Never prune versions newer than this many days (default 7). */ 25 keepRecentDays?: number; 26 /** Injected clock (tests) — defaults to `Date.now()`. */ 27 now?: number; 28} 29 30/** Parsed retention policy (from `workspace.settings.versions`). */ 31export interface VersionRetentionPolicy { 32 maxVersions: number; 33 keepRecentDays: number; 34} 35 36export const VERSION_RETENTION_DEFAULTS = { maxVersions: 100, keepRecentDays: 7 } as const; 37 38/** 39 * Which versions of ONE page may be pruned. Deterministic: returns the 40 * prunable rows' ids, sorted by version desc (delete oldest-batch first is 41 * the worker's concern; the plan is order-agnostic but stable for tests). 42 * 43 * Invariants: 44 * - result ⊆ input ids; 45 * - the newest `maxVersions` are NEVER pruned, even when ancient (so a 46 * page at or under the cap loses nothing — the cap is the active force); 47 * - rows inside the grace period are never pruned, even outside the cap; 48 * - idempotent: survivors of a run form the keep set, so a re-run prunes 49 * nothing (unless new versions land). 50 */ 51export function planPageVersionPrune(rows: VersionRow[], opts: PruneOptions = {}): string[] { 52 const maxVersions = opts.maxVersions ?? VERSION_RETENTION_DEFAULTS.maxVersions; 53 const keepRecentDays = opts.keepRecentDays ?? VERSION_RETENTION_DEFAULTS.keepRecentDays; 54 const now = opts.now ?? Date.now(); 55 const cutoff = now - keepRecentDays * 86_400_000; 56 57 const byVersionDesc = (a: VersionRow, b: VersionRow) => b.version - a.version || b.createdAt.getTime() - a.createdAt.getTime(); 58 59 // keep = newest(maxVersions) ∪ recent(grace). A page at or under the cap 60 // keeps EVERY version (the cap is the only active force there); the grace 61 // additionally shields recent rows that fall outside the newest N on busy 62 // pages. Idempotent by construction: the survivors of a run are exactly 63 // the keep set, so a re-run prunes nothing. 64 const sorted = [...rows].sort(byVersionDesc); 65 const protectedIds = new Set(sorted.slice(0, maxVersions).map((r) => r.id)); 66 return rows 67 .filter((r) => !protectedIds.has(r.id) && r.createdAt.getTime() < cutoff) 68 .sort(byVersionDesc) 69 .map((r) => r.id); 70} 71 72/** Parse + clamp a workspace `settings.versions` blob into PruneOptions. 73 * Malformed/absent values fall back to the defaults (fail-open to the 74 * conservative default: keep MORE, never prune beyond policy). */ 75export function versionRetentionFromSettings(settings: unknown): VersionRetentionPolicy { 76 const out: VersionRetentionPolicy = { 77 maxVersions: VERSION_RETENTION_DEFAULTS.maxVersions, 78 keepRecentDays: VERSION_RETENTION_DEFAULTS.keepRecentDays 79 }; 80 const v = (settings as { versions?: unknown } | null | undefined)?.versions; 81 if (v && typeof v === "object") { 82 const o = v as Record<string, unknown>; 83 if (typeof o.maxVersions === "number" && Number.isFinite(o.maxVersions) && o.maxVersions >= 1) { 84 out.maxVersions = Math.floor(o.maxVersions); 85 } 86 if (typeof o.keepRecentDays === "number" && Number.isFinite(o.keepRecentDays) && o.keepRecentDays >= 0) { 87 out.keepRecentDays = Math.floor(o.keepRecentDays); 88 } 89 } 90 return out; 91} 92