/** * Version snapshot diff (T-150) — pure, deterministic. * * A page version snapshot is `{ page: { title, icon, description, … }, * blocks: [{ id, type, content, properties, parentId, position }] }`. Block * ids are stable across versions (the editor never rewrites ids on a plain * edit), so the correct primitive is a KEYED JOIN by id — not an LCS over * text (which the word-level `wordDiff` in @factory/editor already owns). * LCS buys nothing for keyed entities and would misclassify a moved block * as delete+add. * * Output is render-ready: each block appears in exactly one bucket * (added / deleted / modified / moved / unchanged is implicit). `modified` * lists WHICH fields changed (content / properties / parentId / type), so * the UI can show "text changed" vs "structure changed" without re-diffing. */ export interface SnapshotPage { title?: string | null; icon?: string | null; description?: string | null; [key: string]: unknown; } export interface SnapshotBlock { id: string; type: string; content: unknown; properties: unknown; parentId: string | null; position: string; } export interface PageSnapshot { page?: SnapshotPage | null; blocks: SnapshotBlock[]; } export interface ModifiedBlock { id: string; /** Which fields differ: "content" | "properties" | "type" | "parentId". */ fields: string[]; } export interface VersionDiff { from: number; to: number; page: Record; added: SnapshotBlock[]; deleted: SnapshotBlock[]; modified: ModifiedBlock[]; /** Present in both, same content/properties/parent, new position. */ moved: Array<{ id: string; from: string; to: string }>; summary: { added: number; deleted: number; modified: number; moved: number; unchanged: number }; } function canonical(v: unknown): string { // Stable serialization for value comparison (keys sorted — JSON object // key order is not meaningful for `content`/`properties`). return JSON.stringify(sortKeys(v)); } function sortKeys(v: unknown): unknown { if (Array.isArray(v)) return v.map(sortKeys); if (v && typeof v === "object") { const out: Record = {}; for (const k of Object.keys(v as Record).sort()) out[k] = sortKeys((v as Record)[k]); return out; } return v; } function blockById(blocks: SnapshotBlock[]): Map { const m = new Map(); for (const b of blocks) m.set(b.id, b); return m; } /** * Diff two snapshots of the SAME page (from = older, to = newer). * Returns an empty diff (all-zero summary) for identical snapshots. */ export function diffSnapshots(from: PageSnapshot, to: PageSnapshot, fromVersion: number, toVersion: number): VersionDiff { const fromBlocks = blockById(from.blocks ?? []); const toBlocks = blockById(to.blocks ?? []); const added: SnapshotBlock[] = []; const deleted: SnapshotBlock[] = []; const modified: ModifiedBlock[] = []; const moved: VersionDiff["moved"] = []; let unchanged = 0; for (const [id, b] of toBlocks) { const prev = fromBlocks.get(id); if (!prev) { added.push(b); continue; } const fields: string[] = []; if (prev.type !== b.type) fields.push("type"); if (canonical(prev.content) !== canonical(b.content)) fields.push("content"); if (canonical(prev.properties) !== canonical(b.properties)) fields.push("properties"); if (prev.parentId !== b.parentId) fields.push("parentId"); if (fields.length > 0) modified.push({ id, fields }); else if (prev.position !== b.position) moved.push({ id, from: prev.position, to: b.position }); else unchanged++; } for (const [id, b] of fromBlocks) { if (!toBlocks.has(id)) deleted.push(b); } // Deterministic order (Map iteration is insertion order — re-sort so the // API contract never depends on DB row order). const byPos = (a: SnapshotBlock, b: SnapshotBlock) => (a.position < b.position ? -1 : a.position > b.position ? 1 : a.id.localeCompare(b.id)); added.sort(byPos); deleted.sort(byPos); moved.sort((a, b) => a.id.localeCompare(b.id)); modified.sort((a, b) => a.id.localeCompare(b.id)); const pageFields = ["title", "icon", "description"] as const; const page: VersionDiff["page"] = {}; for (const f of pageFields) { const a = (from.page as Record | null | undefined)?.[f] ?? null; const b = (to.page as Record | null | undefined)?.[f] ?? null; if (canonical(a) !== canonical(b)) page[f] = { from: a, to: b }; } return { from: fromVersion, to: toVersion, page, added, deleted, modified, moved, summary: { added: added.length, deleted: deleted.length, modified: modified.length, moved: moved.length, unchanged } }; }