/** * Page-version retention plan (T-152) — pure, deterministic. * * Per-page rule (spec: "snapshot frequency, storage overhead, revision * retention policies"): keep the NEWEST `maxVersions` versions AND every * version newer than the `keepRecentDays` grace period (so a freshly * autosaved page is never pruned mid-life). Everything else is prunable. * * prune = version ∉ newest(maxVersions) AND createdAt < now - keepRecentDays * * The plan only returns PRUNABLE version numbers — the worker applies it * per page (the newest set is per-page, not global). */ export interface VersionRow { id: string; version: number; createdAt: Date; } export interface PruneOptions { /** Keep at least this many newest versions per page (default 100). */ maxVersions?: number; /** Never prune versions newer than this many days (default 7). */ keepRecentDays?: number; /** Injected clock (tests) — defaults to `Date.now()`. */ now?: number; } /** Parsed retention policy (from `workspace.settings.versions`). */ export interface VersionRetentionPolicy { maxVersions: number; keepRecentDays: number; } export const VERSION_RETENTION_DEFAULTS = { maxVersions: 100, keepRecentDays: 7 } as const; /** * Which versions of ONE page may be pruned. Deterministic: returns the * prunable rows' ids, sorted by version desc (delete oldest-batch first is * the worker's concern; the plan is order-agnostic but stable for tests). * * Invariants: * - result ⊆ input ids; * - the newest `maxVersions` are NEVER pruned, even when ancient (so a * page at or under the cap loses nothing — the cap is the active force); * - rows inside the grace period are never pruned, even outside the cap; * - idempotent: survivors of a run form the keep set, so a re-run prunes * nothing (unless new versions land). */ export function planPageVersionPrune(rows: VersionRow[], opts: PruneOptions = {}): string[] { const maxVersions = opts.maxVersions ?? VERSION_RETENTION_DEFAULTS.maxVersions; const keepRecentDays = opts.keepRecentDays ?? VERSION_RETENTION_DEFAULTS.keepRecentDays; const now = opts.now ?? Date.now(); const cutoff = now - keepRecentDays * 86_400_000; const byVersionDesc = (a: VersionRow, b: VersionRow) => b.version - a.version || b.createdAt.getTime() - a.createdAt.getTime(); // keep = newest(maxVersions) ∪ recent(grace). A page at or under the cap // keeps EVERY version (the cap is the only active force there); the grace // additionally shields recent rows that fall outside the newest N on busy // pages. Idempotent by construction: the survivors of a run are exactly // the keep set, so a re-run prunes nothing. const sorted = [...rows].sort(byVersionDesc); const protectedIds = new Set(sorted.slice(0, maxVersions).map((r) => r.id)); return rows .filter((r) => !protectedIds.has(r.id) && r.createdAt.getTime() < cutoff) .sort(byVersionDesc) .map((r) => r.id); } /** Parse + clamp a workspace `settings.versions` blob into PruneOptions. * Malformed/absent values fall back to the defaults (fail-open to the * conservative default: keep MORE, never prune beyond policy). */ export function versionRetentionFromSettings(settings: unknown): VersionRetentionPolicy { const out: VersionRetentionPolicy = { maxVersions: VERSION_RETENTION_DEFAULTS.maxVersions, keepRecentDays: VERSION_RETENTION_DEFAULTS.keepRecentDays }; const v = (settings as { versions?: unknown } | null | undefined)?.versions; if (v && typeof v === "object") { const o = v as Record; if (typeof o.maxVersions === "number" && Number.isFinite(o.maxVersions) && o.maxVersions >= 1) { out.maxVersions = Math.floor(o.maxVersions); } if (typeof o.keepRecentDays === "number" && Number.isFinite(o.keepRecentDays) && o.keepRecentDays >= 0) { out.keepRecentDays = Math.floor(o.keepRecentDays); } } return out; }