@bygghammar/cms-platform 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ import {
2
+ buildBrowserMediaArchive as buildSharedBrowserMediaArchive,
3
+ type MediaExportProgress,
4
+ } from "./browser-media";
5
+
6
+ import { ARCHIVE_FORMAT_VERSION, SCHEMA_VERSION, type ArchiveManifest } from "./datasets";
7
+ import type { MediaStorage } from "../media/storage";
8
+
9
+ export type { MediaExportProgress };
10
+
11
+ type MediaExportOptions = {
12
+ baseArchive: Uint8Array;
13
+ groups: string[];
14
+ storage: Pick<MediaStorage, "list" | "download">;
15
+ onProgress?: (progress: MediaExportProgress) => void;
16
+ };
17
+
18
+ export function buildBrowserMediaArchive(options: MediaExportOptions) {
19
+ return buildSharedBrowserMediaArchive<ArchiveManifest>({
20
+ ...options,
21
+ expectedFormat: ARCHIVE_FORMAT_VERSION,
22
+ expectedSchemaVersion: SCHEMA_VERSION,
23
+ });
24
+ }
@@ -0,0 +1,465 @@
1
+ /**
2
+ * Pure restore logic shared by the standalone PostgreSQL tool and the tests.
3
+ *
4
+ * Nothing here opens a connection: it turns an archive into a validated,
5
+ * ordered list of SQL statements. That keeps every rule (order, conflict
6
+ * targets, sanitising, media verification) testable without a database.
7
+ */
8
+ import { unzipSync, strFromU8 } from "fflate";
9
+
10
+ import {
11
+ ARCHIVE_FORMAT_VERSION,
12
+ DATASET_GROUPS,
13
+ IMPORT_ORDER,
14
+ LINEAGE_KEYS,
15
+ SCHEMA_VERSION,
16
+ groupById,
17
+ type ArchiveManifest,
18
+ type ImportMode,
19
+ type TableSpec,
20
+ } from "./datasets";
21
+ import {
22
+ isSafeArchivePath,
23
+ neutralizeInjection,
24
+ rewriteMediaUrls,
25
+ verifyMediaFile,
26
+ MAX_TOTAL_UNCOMPRESSED_BYTES,
27
+ toJsonSafe,
28
+ } from "./safety";
29
+
30
+ export type ArchiveEntries = Record<string, Uint8Array>;
31
+
32
+ export type ReadArchiveResult = {
33
+ manifest: ArchiveManifest;
34
+ entries: ArchiveEntries;
35
+ };
36
+
37
+ /** Unzip, validate entry paths and versions. Throws on anything suspicious. */
38
+ export function readArchive(bytes: Uint8Array): ReadArchiveResult {
39
+ const entries = unzipSync(bytes);
40
+ let total = 0;
41
+ for (const [name, value] of Object.entries(entries)) {
42
+ if (!isSafeArchivePath(name)) throw new Error(`Unsafe entry in archive: ${name}`);
43
+ total += value.byteLength;
44
+ if (total > MAX_TOTAL_UNCOMPRESSED_BYTES) throw new Error("Archive is too large to restore.");
45
+ }
46
+ const raw = entries["manifest.json"];
47
+ if (!raw) throw new Error("Not a valid backup archive — manifest.json is missing.");
48
+ const manifest = JSON.parse(strFromU8(raw)) as ArchiveManifest;
49
+ if (manifest.format !== ARCHIVE_FORMAT_VERSION) {
50
+ throw new Error(
51
+ `Archive format ${manifest.format} cannot be restored — this tool reads format ${ARCHIVE_FORMAT_VERSION}.`,
52
+ );
53
+ }
54
+ if (manifest.schemaVersion !== SCHEMA_VERSION) {
55
+ throw new Error(
56
+ `Archive schema version ${manifest.schemaVersion} does not match this tool (${SCHEMA_VERSION}).`,
57
+ );
58
+ }
59
+ return { manifest, entries };
60
+ }
61
+
62
+ export function tableRows(entries: ArchiveEntries, table: string): Record<string, unknown>[] {
63
+ const raw = entries[`data/${table}.json`];
64
+ if (!raw) return [];
65
+ const parsed = JSON.parse(strFromU8(raw)) as unknown;
66
+ if (!Array.isArray(parsed)) throw new Error(`data/${table}.json is not an array`);
67
+ return parsed as Record<string, unknown>[];
68
+ }
69
+
70
+ export type PlannedTable = {
71
+ table: string;
72
+ spec: TableSpec;
73
+ insertOnly: boolean;
74
+ rows: Record<string, unknown>[];
75
+ };
76
+
77
+ export type PlanOptions = {
78
+ groups: string[];
79
+ mode: ImportMode;
80
+ /** Base URL that hosted media URLs are rewritten to. */
81
+ mediaBase?: string | null;
82
+ /** Keep restored tracking/code injections enabled (off by default). */
83
+ keepInjectionsEnabled?: boolean;
84
+ };
85
+
86
+ /** Ordered, prepared tables for a restore. Order follows IMPORT_ORDER. */
87
+ export function planRestore(
88
+ entries: ArchiveEntries,
89
+ manifest: ArchiveManifest,
90
+ opts: PlanOptions,
91
+ ): PlannedTable[] {
92
+ const selected = opts.groups.filter((g) => (manifest.groups ?? []).includes(g));
93
+ const specs = new Map<string, { spec: TableSpec; insertOnly: boolean }>();
94
+ for (const id of selected) {
95
+ const group = groupById(id);
96
+ if (!group) continue;
97
+ for (const spec of group.tables) {
98
+ specs.set(spec.table, { spec, insertOnly: Boolean(group.insertOnly) });
99
+ }
100
+ }
101
+
102
+ const plan: PlannedTable[] = [];
103
+ for (const table of IMPORT_ORDER) {
104
+ const entry = specs.get(table);
105
+ if (!entry) continue;
106
+ const rows = tableRows(entries, table).map((row) => {
107
+ let out: Record<string, unknown> = opts.mediaBase
108
+ ? rewriteMediaUrls({ ...row }, opts.mediaBase)
109
+ : { ...row };
110
+ if (table === "site_injections") {
111
+ out = neutralizeInjection(out, Boolean(opts.keepInjectionsEnabled));
112
+ }
113
+ return out;
114
+ });
115
+ if (!rows.length) continue;
116
+ plan.push({ table, spec: entry.spec, insertOnly: entry.insertOnly, rows });
117
+ }
118
+ return plan;
119
+ }
120
+
121
+ /** Tables whose dataset file exists in the archive, even when it holds no rows. */
122
+ export function datasetsPresent(entries: ArchiveEntries): Set<string> {
123
+ const out = new Set<string>();
124
+ for (const key of Object.keys(entries)) {
125
+ if (!key.startsWith("data/") || !key.endsWith(".json")) continue;
126
+ out.add(key.slice("data/".length).replace(/\.json$/, ""));
127
+ }
128
+ return out;
129
+ }
130
+
131
+ /**
132
+ * Tables in the plan whose declared dependencies are missing.
133
+ *
134
+ * A parent that was exported but happens to be empty is NOT missing: pass the
135
+ * archive's dataset set (or its manifest counts) so an empty-but-present
136
+ * parent still satisfies the requirement. Rows that actually point at an
137
+ * absent parent row are caught by `danglingReferences`.
138
+ */
139
+ export function missingDependencies(
140
+ plan: PlannedTable[],
141
+ present?: Set<string> | Iterable<string>,
142
+ ): { table: string; missing: string[] }[] {
143
+ const exported = present ? new Set(present) : null;
144
+ const inPlan = new Set(plan.map((p) => p.table));
145
+ const out: { table: string; missing: string[] }[] = [];
146
+ for (const p of plan) {
147
+ const missing = (p.spec.requires ?? []).filter(
148
+ (t) => !inPlan.has(t) && !(exported?.has(t) ?? false),
149
+ );
150
+ if (missing.length) out.push({ table: p.table, missing });
151
+ }
152
+ return out;
153
+ }
154
+
155
+ /** Non-null foreign keys in the plan whose parent row is absent from the archive. */
156
+ export function danglingReferences(
157
+ plan: PlannedTable[],
158
+ entries: ArchiveEntries,
159
+ ): { table: string; column: string; value: string; parent: string }[] {
160
+ const out: { table: string; column: string; value: string; parent: string }[] = [];
161
+ const parentIds = new Map<string, Set<string>>();
162
+ for (const p of plan) {
163
+ for (const ref of p.spec.references ?? []) {
164
+ const pk = ref.pk ?? "id";
165
+ const cacheKey = `${ref.table}.${pk}`;
166
+ let ids = parentIds.get(cacheKey);
167
+ if (!ids) {
168
+ ids = new Set(tableRows(entries, ref.table).map((r) => String(r[pk])));
169
+ parentIds.set(cacheKey, ids);
170
+ }
171
+ for (const row of p.rows) {
172
+ const value = row[ref.column];
173
+ if (value == null || value === "") continue;
174
+ if (!ids.has(String(value))) {
175
+ out.push({ table: p.table, column: ref.column, value: String(value), parent: ref.table });
176
+ }
177
+ }
178
+ }
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /** True when the plan's order never places a child before its parent. */
184
+ export function planOrderIsValid(plan: PlannedTable[]): boolean {
185
+ const seen = new Set<string>();
186
+ for (const p of plan) {
187
+ for (const req of p.spec.requires ?? []) {
188
+ if (!seen.has(req) && plan.some((q) => q.table === req)) return false;
189
+ }
190
+ seen.add(p.table);
191
+ }
192
+ return true;
193
+ }
194
+
195
+ /* --------------------------------------------------------------- lineage */
196
+
197
+ export type LineageRow = { key: string; id: string };
198
+
199
+ /**
200
+ * Compare business keys already in the target database with the archive.
201
+ * A key that exists under a different primary key means the archive comes
202
+ * from another database lineage and Merge would corrupt references.
203
+ */
204
+ export function lineageClashes(
205
+ table: string,
206
+ spec: TableSpec,
207
+ incoming: Record<string, unknown>[],
208
+ existing: LineageRow[],
209
+ ): string[] {
210
+ const keyCol = LINEAGE_KEYS[table];
211
+ if (!keyCol || spec.pk === keyCol) return [];
212
+ const byKey = new Map(incoming.map((r) => [String(r[keyCol]), String(r[spec.pk])]));
213
+ const clashes: string[] = [];
214
+ for (const row of existing) {
215
+ const incomingId = byKey.get(row.key);
216
+ if (incomingId && incomingId !== row.id) clashes.push(`${table}.${keyCol}="${row.key}"`);
217
+ }
218
+ return clashes;
219
+ }
220
+
221
+ /* ------------------------------------------------------------------- SQL */
222
+
223
+ export function quoteIdent(name: string): string {
224
+ if (!/^[a-z_][a-z0-9_]*$/i.test(name)) throw new Error(`Unsafe identifier: ${name}`);
225
+ return `"${name}"`;
226
+ }
227
+
228
+ export type Statement = { text: string; values: unknown[] };
229
+
230
+ /** Columns present across the rows, in stable first-seen order. */
231
+ export function columnsOf(rows: Record<string, unknown>[]): string[] {
232
+ const cols: string[] = [];
233
+ for (const row of rows) for (const k of Object.keys(row)) if (!cols.includes(k)) cols.push(k);
234
+ return cols;
235
+ }
236
+
237
+ function encode(value: unknown): unknown {
238
+ if (value instanceof Date) return value.toISOString();
239
+ if (value !== null && typeof value === "object") return JSON.stringify(toJsonSafe(value));
240
+ return value;
241
+ }
242
+
243
+ /**
244
+ * Turn a failed chunk insert into an actionable message: which table, which
245
+ * chunk, which columns, and which specific `table.column` values look wrong
246
+ * (an object where the database expects a scalar, e.g. `{}` for a timestamp).
247
+ */
248
+ export function describeInsertFailure(
249
+ table: string,
250
+ chunkIndex: number,
251
+ rows: Record<string, unknown>[],
252
+ error: unknown,
253
+ ): string {
254
+ const cols = columnsOf(rows);
255
+ const message = error instanceof Error ? error.message : String(error);
256
+ const suspects: string[] = [];
257
+ for (const col of cols) {
258
+ for (const row of rows) {
259
+ const v = row[col];
260
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
261
+ const empty = Object.keys(v as Record<string, unknown>).length === 0;
262
+ suspects.push(`${table}.${col}${empty ? " (empty object — lost value)" : " (object)"}`);
263
+ break;
264
+ }
265
+ }
266
+ }
267
+ const parts = [
268
+ `Insert failed on ${table} (chunk ${chunkIndex + 1}, ${rows.length} rows)`,
269
+ `columns: ${cols.join(", ")}`,
270
+ ];
271
+ if (suspects.length) parts.push(`suspect values: ${suspects.join(", ")}`);
272
+ parts.push(`postgres: ${message}`);
273
+ return parts.join("\n ");
274
+ }
275
+
276
+ /**
277
+ * Build a parameterised INSERT for one chunk of rows.
278
+ * - insertOnly tables use DO NOTHING so customer data is never overwritten.
279
+ * - other tables upsert on their conflict target (business key when they have
280
+ * one), which keeps primary keys stable across restores.
281
+ */
282
+ export function buildInsert(
283
+ table: string,
284
+ spec: TableSpec,
285
+ rows: Record<string, unknown>[],
286
+ insertOnly: boolean,
287
+ ): Statement {
288
+ if (!rows.length) throw new Error("buildInsert called with no rows");
289
+ const cols = columnsOf(rows);
290
+ const quoted = cols.map(quoteIdent);
291
+ const values: unknown[] = [];
292
+ const tuples = rows.map((row) => {
293
+ const placeholders = cols.map((c) => {
294
+ values.push(encode(row[c] ?? null));
295
+ return `$${values.length}`;
296
+ });
297
+ return `(${placeholders.join(", ")})`;
298
+ });
299
+
300
+ const conflictCols = (spec.conflict ?? spec.pk).split(",").map((c) => quoteIdent(c.trim()));
301
+ const target = `(${conflictCols.join(", ")})`;
302
+ const updatable = cols.filter((c) => !conflictCols.includes(quoteIdent(c)));
303
+ const action =
304
+ insertOnly || !updatable.length
305
+ ? "DO NOTHING"
306
+ : `DO UPDATE SET ${updatable.map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`).join(", ")}`;
307
+
308
+ return {
309
+ text:
310
+ `INSERT INTO public.${quoteIdent(table)} (${quoted.join(", ")}) VALUES ${tuples.join(", ")} ` +
311
+ `ON CONFLICT ${target} ${action}`,
312
+ values,
313
+ };
314
+ }
315
+
316
+ /** Deferred self-references (pages.current_version_id) written in a 2nd pass. */
317
+ /**
318
+ * Columns whose value must survive the deferred UPDATE. The schema keeps
319
+ * `touch_*` BEFORE UPDATE triggers that would otherwise rewrite them, which
320
+ * would silently change migrated audit data. Restoring never needs superuser
321
+ * rights to disable triggers — it simply re-asserts the exported values.
322
+ */
323
+ const PRESERVED_ON_UPDATE = ["updated_at", "updated_by"] as const;
324
+
325
+ export function buildDeferredUpdates(planned: PlannedTable): Statement[] {
326
+ const cols = planned.spec.deferred ?? [];
327
+ if (!cols.length) return [];
328
+ const out: Statement[] = [];
329
+ for (const row of planned.rows) {
330
+ const set = cols.filter((c) => row[c] != null);
331
+ if (!set.length) continue;
332
+ for (const c of PRESERVED_ON_UPDATE) {
333
+ if (c in row && !set.includes(c)) set.push(c);
334
+ }
335
+ const values: unknown[] = [];
336
+ const assignments = set.map((c) => {
337
+ values.push(encode(row[c]));
338
+ return `${quoteIdent(c)} = $${values.length}`;
339
+ });
340
+ values.push(row[planned.spec.pk]);
341
+ out.push({
342
+ text: `UPDATE public.${quoteIdent(planned.table)} SET ${assignments.join(", ")} WHERE ${quoteIdent(planned.spec.pk)} = $${values.length}`,
343
+ values,
344
+ });
345
+ }
346
+ return out;
347
+ }
348
+
349
+ /** Statement used by Replace mode. Insert-only tables are never cleared. */
350
+ export function buildClear(planned: PlannedTable): Statement | null {
351
+ if (planned.insertOnly) return null;
352
+ return { text: `DELETE FROM public.${quoteIdent(planned.table)}`, values: [] };
353
+ }
354
+
355
+ /* ------------------------------------------------------------ auth users */
356
+
357
+ export type AuthUserRow = {
358
+ id: string;
359
+ email: string | null;
360
+ created_at?: string | null;
361
+ user_metadata?: Record<string, unknown> | null;
362
+ };
363
+
364
+ /**
365
+ * Plain PostgreSQL has no `auth.users`, so exported accounts are mapped into
366
+ * `public.app_users`, which owns every foreign key in the portable schema.
367
+ * UUIDs are preserved so existing references keep resolving.
368
+ */
369
+ export function mapAuthUsersToAppUsers(users: AuthUserRow[]): Record<string, unknown>[] {
370
+ return users
371
+ .filter((u) => typeof u.id === "string" && u.id.length > 0)
372
+ .map((u) => ({
373
+ id: u.id,
374
+ email: u.email ?? null,
375
+ display_name:
376
+ (u.user_metadata && typeof u.user_metadata["display_name"] === "string"
377
+ ? (u.user_metadata["display_name"] as string)
378
+ : null) ??
379
+ (u.user_metadata && typeof u.user_metadata["full_name"] === "string"
380
+ ? (u.user_metadata["full_name"] as string)
381
+ : null),
382
+ created_at: u.created_at ?? new Date().toISOString(),
383
+ }));
384
+ }
385
+
386
+ /* ----------------------------------------------------------------- media */
387
+
388
+ export type MediaEntry = { name: string; bytes: Uint8Array };
389
+
390
+ /** Verified media staged out of the archive, plus everything rejected. */
391
+ export function stageMedia(entries: ArchiveEntries): {
392
+ files: MediaEntry[];
393
+ quarantined: string[];
394
+ bytes: number;
395
+ } {
396
+ const files: MediaEntry[] = [];
397
+ const quarantined: string[] = [];
398
+ let bytes = 0;
399
+ for (const [key, value] of Object.entries(entries)) {
400
+ if (!key.startsWith("media/")) continue;
401
+ const name = key.slice("media/".length);
402
+ if (!name) continue;
403
+ if (!isSafeArchivePath(name)) {
404
+ quarantined.push(`${name} (unsafe path)`);
405
+ continue;
406
+ }
407
+ const verdict = verifyMediaFile(name, value);
408
+ if (!verdict.ok) {
409
+ quarantined.push(`${name} (${verdict.reason})`);
410
+ continue;
411
+ }
412
+ files.push({ name, bytes: value });
413
+ bytes += value.byteLength;
414
+ }
415
+ return { files, quarantined, bytes };
416
+ }
417
+
418
+ /* ------------------------------------------------------------- analysis */
419
+
420
+ export type AnalysisReport = {
421
+ manifest: ArchiveManifest;
422
+ tables: { table: string; rows: number; insertOnly: boolean; conflict: string }[];
423
+ media: { files: number; bytes: number; quarantined: string[] };
424
+ authUsers: number;
425
+ problems: string[];
426
+ };
427
+
428
+ export function analyzeArchive(bytes: Uint8Array, opts: PlanOptions): AnalysisReport {
429
+ const { manifest, entries } = readArchive(bytes);
430
+ const plan = planRestore(entries, manifest, opts);
431
+ const problems: string[] = [];
432
+ const present = datasetsPresent(entries);
433
+ for (const c of Object.keys(manifest.counts ?? {})) present.add(c);
434
+ for (const { table, missing } of missingDependencies(plan, present)) {
435
+ problems.push(`${table} depends on ${missing.join(", ")}, which is not in this restore.`);
436
+ }
437
+ for (const d of danglingReferences(plan, entries)) {
438
+ problems.push(
439
+ `${d.table}.${d.column} points at ${d.parent} "${d.value}", which is not in this restore.`,
440
+ );
441
+ }
442
+ if (!planOrderIsValid(plan))
443
+ problems.push("Import order places a child table before its parent.");
444
+ const known = new Set(DATASET_GROUPS.flatMap((g) => g.tables.map((t) => t.table)));
445
+ for (const key of Object.keys(entries)) {
446
+ if (!key.startsWith("data/")) continue;
447
+ const table = key.slice("data/".length).replace(/\.json$/, "");
448
+ if (table !== "auth_users" && !known.has(table))
449
+ problems.push(`Unknown table in archive: ${table}`);
450
+ }
451
+ const media = stageMedia(entries);
452
+ const authUsers = tableRows(entries, "auth_users").length;
453
+ return {
454
+ manifest,
455
+ tables: plan.map((p) => ({
456
+ table: p.table,
457
+ rows: p.rows.length,
458
+ insertOnly: p.insertOnly,
459
+ conflict: p.spec.conflict ?? p.spec.pk,
460
+ })),
461
+ media: { files: media.files.length, bytes: media.bytes, quarantined: media.quarantined },
462
+ authUsers,
463
+ problems,
464
+ };
465
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Pending edits to shared content sections made from inside the page builder
3
+ * inspector. Kept in a module store so:
4
+ * - the builder canvas can preview them live (pushed into the iframe), and
5
+ * - Save / Publish on the page can flush them to the database in one go.
6
+ *
7
+ * Nothing here is destructive: flushing writes through the normal
8
+ * `saveContentSection` server function (append/upsert on content_sections).
9
+ */
10
+ type Listener = (drafts: Record<string, unknown>) => void;
11
+
12
+ const drafts: Record<string, unknown> = {};
13
+ const listeners = new Set<Listener>();
14
+
15
+ function emit() {
16
+ const snap = { ...drafts };
17
+ listeners.forEach((l) => l(snap));
18
+ }
19
+
20
+ export function setSharedDraft(key: string, value: unknown) {
21
+ drafts[key] = value;
22
+ emit();
23
+ }
24
+
25
+ export function clearSharedDraft(key: string) {
26
+ delete drafts[key];
27
+ emit();
28
+ }
29
+
30
+ export function clearAllSharedDrafts() {
31
+ for (const k of Object.keys(drafts)) delete drafts[k];
32
+ emit();
33
+ }
34
+
35
+ export function getSharedDrafts(): Record<string, unknown> {
36
+ return { ...drafts };
37
+ }
38
+
39
+ export function hasSharedDrafts() {
40
+ return Object.keys(drafts).length > 0;
41
+ }
42
+
43
+ export function subscribeSharedDrafts(l: Listener) {
44
+ listeners.add(l);
45
+ l({ ...drafts });
46
+ return () => {
47
+ listeners.delete(l);
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Persist every pending shared-content edit. Returns the keys written.
53
+ */
54
+ export async function flushSharedDrafts(
55
+ save: (args: { data: { key: string; data: unknown } }) => Promise<unknown>,
56
+ ): Promise<string[]> {
57
+ const entries = Object.entries(drafts);
58
+ const written: string[] = [];
59
+ for (const [key, data] of entries) {
60
+ await save({ data: { key, data } });
61
+ written.push(key);
62
+ delete drafts[key];
63
+ }
64
+ if (written.length) emit();
65
+ return written;
66
+ }
@@ -0,0 +1,89 @@
1
+ import type { BlockInstance } from "../blocks/types";
2
+
3
+ export type VersionChange = {
4
+ kind: "added" | "removed" | "edited" | "moved" | "shown" | "hidden";
5
+ blockType: string;
6
+ detail?: string;
7
+ };
8
+
9
+ function stableStringify(value: unknown): string {
10
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
11
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
12
+ const obj = value as Record<string, unknown>;
13
+ const keys = Object.keys(obj).sort();
14
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
15
+ }
16
+
17
+ /**
18
+ * Compare two block lists and produce a human-readable change list.
19
+ * Blocks are matched by id; falls back to index when ids are missing.
20
+ */
21
+ export function diffBlocks(prev: BlockInstance[], next: BlockInstance[]): VersionChange[] {
22
+ const changes: VersionChange[] = [];
23
+ const prevById = new Map<string, { block: BlockInstance; index: number }>();
24
+ prev.forEach((block, index) => prevById.set(block.id ?? `#${index}`, { block, index }));
25
+ const nextIds = new Set<string>();
26
+
27
+ next.forEach((block, index) => {
28
+ const id = block.id ?? `#${index}`;
29
+ nextIds.add(id);
30
+ const before = prevById.get(id);
31
+ if (!before) {
32
+ changes.push({ kind: "added", blockType: block.type });
33
+ return;
34
+ }
35
+ const wasVisible = before.block.visible !== false;
36
+ const isVisible = block.visible !== false;
37
+ if (wasVisible !== isVisible) {
38
+ changes.push({ kind: isVisible ? "shown" : "hidden", blockType: block.type });
39
+ }
40
+ if ((before.block.name ?? "") !== (block.name ?? "")) {
41
+ changes.push({
42
+ kind: "edited",
43
+ blockType: block.type,
44
+ detail: `renamed to "${block.name || "(default)"}"`,
45
+ });
46
+ }
47
+ if (stableStringify(before.block.props ?? {}) !== stableStringify(block.props ?? {})) {
48
+ changes.push({
49
+ kind: "edited",
50
+ blockType: block.type,
51
+ detail: changedPropKeys(before.block, block),
52
+ });
53
+ }
54
+ if (before.index !== index) {
55
+ changes.push({
56
+ kind: "moved",
57
+ blockType: block.type,
58
+ detail: `position ${before.index + 1} → ${index + 1}`,
59
+ });
60
+ }
61
+ });
62
+
63
+ prev.forEach((block, index) => {
64
+ const id = block.id ?? `#${index}`;
65
+ if (!nextIds.has(id)) changes.push({ kind: "removed", blockType: block.type });
66
+ });
67
+
68
+ return changes;
69
+ }
70
+
71
+ function changedPropKeys(a: BlockInstance, b: BlockInstance): string | undefined {
72
+ const pa = (a.props ?? {}) as Record<string, unknown>;
73
+ const pb = (b.props ?? {}) as Record<string, unknown>;
74
+ const keys = Array.from(new Set([...Object.keys(pa), ...Object.keys(pb)]));
75
+ const changed = keys.filter((k) => stableStringify(pa[k]) !== stableStringify(pb[k]));
76
+ if (changed.length === 0) return undefined;
77
+ return (
78
+ changed.slice(0, 6).join(", ") + (changed.length > 6 ? `, +${changed.length - 6} more` : "")
79
+ );
80
+ }
81
+
82
+ export function summarizeChanges(changes: VersionChange[]): string {
83
+ if (changes.length === 0) return "No block changes";
84
+ const counts: Record<string, number> = {};
85
+ for (const c of changes) counts[c.kind] = (counts[c.kind] ?? 0) + 1;
86
+ return Object.entries(counts)
87
+ .map(([kind, n]) => `${n} ${kind}`)
88
+ .join(" · ");
89
+ }