@bygghammar/cms-platform 0.91.1 → 0.92.1

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.
@@ -9,11 +9,12 @@ import { unzipSync, strFromU8 } from "fflate";
9
9
 
10
10
  import {
11
11
  ARCHIVE_FORMAT_VERSION,
12
+ CLEAR_ORDER,
12
13
  DATASET_GROUPS,
13
14
  IMPORT_ORDER,
14
15
  LINEAGE_KEYS,
15
- SCHEMA_VERSION,
16
16
  groupById,
17
+ readSchemaVersion,
17
18
  type ArchiveManifest,
18
19
  type ImportMode,
19
20
  type TableSpec,
@@ -51,14 +52,23 @@ export function readArchive(bytes: Uint8Array): ReadArchiveResult {
51
52
  `Archive format ${manifest.format} cannot be restored — this tool reads format ${ARCHIVE_FORMAT_VERSION}.`,
52
53
  );
53
54
  }
54
- if (manifest.schemaVersion !== SCHEMA_VERSION) {
55
- throw new Error(
56
- `Archive schema version ${manifest.schemaVersion} does not match this tool (${SCHEMA_VERSION}).`,
57
- );
55
+ const reading = readSchemaVersion(manifest.schemaVersion);
56
+ if (!reading.supported) {
57
+ throw new Error(`This archive cannot be restored — ${reading.reason}.`);
58
58
  }
59
59
  return { manifest, entries };
60
60
  }
61
61
 
62
+ /**
63
+ * True when the archive predates the review tables and therefore says nothing
64
+ * about which pages were reviewed. Restoring one leaves content whose
65
+ * classification is unknown until something scans it.
66
+ */
67
+ export function needsReconciliation(manifest: ArchiveManifest): boolean {
68
+ const reading = readSchemaVersion(manifest.schemaVersion);
69
+ return reading.supported && !reading.carriesReviewHistory;
70
+ }
71
+
62
72
  export function tableRows(entries: ArchiveEntries, table: string): Record<string, unknown>[] {
63
73
  const raw = entries[`data/${table}.json`];
64
74
  if (!raw) return [];
@@ -234,6 +244,24 @@ export function columnsOf(rows: Record<string, unknown>[]): string[] {
234
244
  return cols;
235
245
  }
236
246
 
247
+ /**
248
+ * The columns a restore is allowed to name in an INSERT: everything the
249
+ * archive carries for the table, minus the ones the database computes for
250
+ * itself.
251
+ *
252
+ * PostgreSQL rejects an INSERT that names a GENERATED column outright, so
253
+ * carrying `inputs_digest` through from the archive would turn a perfectly
254
+ * valid backup into a failed restore — and gain nothing, because the value is
255
+ * recomputed from the same `inputs` and cannot come out different. Dropping it
256
+ * here is the reason a digest can be trusted at all: there is no code path,
257
+ * restore included, by which a stored digest and the inputs it claims to
258
+ * cover can disagree.
259
+ */
260
+ export function writableColumns(rows: Record<string, unknown>[], spec: TableSpec): string[] {
261
+ const generated = new Set(spec.generated ?? []);
262
+ return columnsOf(rows).filter((c) => !generated.has(c));
263
+ }
264
+
237
265
  function encode(value: unknown): unknown {
238
266
  if (value instanceof Date) return value.toISOString();
239
267
  if (value !== null && typeof value === "object") return JSON.stringify(toJsonSafe(value));
@@ -286,7 +314,8 @@ export function buildInsert(
286
314
  insertOnly: boolean,
287
315
  ): Statement {
288
316
  if (!rows.length) throw new Error("buildInsert called with no rows");
289
- const cols = columnsOf(rows);
317
+ const cols = writableColumns(rows, spec);
318
+ if (!cols.length) throw new Error(`buildInsert called with no writable columns for ${table}`);
290
319
  const quoted = cols.map(quoteIdent);
291
320
  const values: unknown[] = [];
292
321
  const tuples = rows.map((row) => {
@@ -346,10 +375,85 @@ export function buildDeferredUpdates(planned: PlannedTable): Statement[] {
346
375
  return out;
347
376
  }
348
377
 
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: [] };
378
+ /* ----------------------------------------------------------- clear phase */
379
+
380
+ /**
381
+ * One step of the Replace-mode clear phase.
382
+ *
383
+ * Two kinds, because emptying a table is not always a DELETE. `pages` points
384
+ * forward at `page_versions` through `current_version_id`; no ordering of
385
+ * DELETEs can satisfy that, because whichever table goes first is still being
386
+ * referenced by the other. So the pointer is released first, and only then
387
+ * does the reverse-order deletion have a path.
388
+ *
389
+ * A step is deliberately not a `Statement`. The two callers speak different
390
+ * dialects — the standalone tool sends SQL to PostgreSQL, the backoffice
391
+ * import goes through supabase-js and has no SQL channel at all — so what they
392
+ * share is the *plan*: which tables, in which order, and what to do to each.
393
+ * Each renders it in its own terms. Sharing rendered SQL would have meant
394
+ * sharing nothing, because only one of them can execute it.
395
+ */
396
+ export type ClearStep =
397
+ | { kind: "release"; table: string; columns: string[] }
398
+ | { kind: "delete"; table: string };
399
+
400
+ /**
401
+ * The ordered clear phase for a Replace restore of `groups`.
402
+ *
403
+ * Three properties matter, and none of them holds in the code this replaces:
404
+ *
405
+ * 1. It runs once, before the first insert — not interleaved with the inserts,
406
+ * where a later table's DELETE would remove rows an earlier one just wrote.
407
+ * 2. It follows CLEAR_ORDER, the exact reverse of IMPORT_ORDER. Forward order
408
+ * is not merely slower to fail: `cms_publication_review_items.page_id` is
409
+ * ON DELETE RESTRICT, so deleting `pages` first raises instead of quietly
410
+ * cascading history away. Keeping RESTRICT and getting the order right is
411
+ * what turns an ordering mistake into a loud failure.
412
+ * 3. It covers every selected table whether or not the archive carries rows
413
+ * for it. "Replace" means the target ends up holding what the archive holds;
414
+ * a table the archive is silent about must end up empty, and the old code —
415
+ * which cleared inside the per-table loop, after `if (!rows.length)
416
+ * continue;` — left exactly those tables untouched.
417
+ *
418
+ * Insert-only tables are absent from the plan entirely. They are named in
419
+ * CLEAR_ORDER, because a dependency ordering with holes in it is not a
420
+ * dependency ordering, but the append-only review history is never deleted:
421
+ * see `replaceRefusals` for what happens when a caller asks for that anyway.
422
+ */
423
+ export function buildClearPlan(groups: string[]): ClearStep[] {
424
+ const selected = new Map<string, { spec: TableSpec; insertOnly: boolean }>();
425
+ for (const id of groups) {
426
+ const group = groupById(id);
427
+ if (!group) continue;
428
+ for (const spec of group.tables) {
429
+ selected.set(spec.table, { spec, insertOnly: Boolean(group.insertOnly) });
430
+ }
431
+ }
432
+
433
+ const releases: ClearStep[] = [];
434
+ const deletes: ClearStep[] = [];
435
+ for (const table of CLEAR_ORDER) {
436
+ const entry = selected.get(table);
437
+ if (!entry || entry.insertOnly) continue;
438
+ const deferred = entry.spec.deferred ?? [];
439
+ if (deferred.length) releases.push({ kind: "release", table, columns: [...deferred] });
440
+ deletes.push({ kind: "delete", table });
441
+ }
442
+ // Every pointer is released before any row is deleted. Ordering the releases
443
+ // among themselves would be busywork: releasing one cannot make another fail.
444
+ return [...releases, ...deletes];
445
+ }
446
+
447
+ /** One clear step as SQL, for the caller that has a SQL channel. */
448
+ export function buildClear(step: ClearStep): Statement {
449
+ if (step.kind === "release") {
450
+ const assignments = step.columns.map((c) => `${quoteIdent(c)} = NULL`).join(", ");
451
+ return {
452
+ text: `UPDATE public.${quoteIdent(step.table)} SET ${assignments}`,
453
+ values: [],
454
+ };
455
+ }
456
+ return { text: `DELETE FROM public.${quoteIdent(step.table)}`, values: [] };
353
457
  }
354
458
 
355
459
  /* ------------------------------------------------------------ auth users */
@@ -415,6 +519,242 @@ export function stageMedia(entries: ArchiveEntries): {
415
519
  return { files, quarantined, bytes };
416
520
  }
417
521
 
522
+ /* ------------------------------------------------------- review history */
523
+
524
+ /**
525
+ * The parts of the local review history a restore has to consult before it
526
+ * writes anything. Deliberately narrow: three tables, and from each only the
527
+ * columns a conflict can turn on. A caller supplies these by reading the local
528
+ * database; everything below is pure, so the comparison itself is testable
529
+ * without one.
530
+ */
531
+ export type LocalReviewState = {
532
+ runs: { id: string; input_fingerprint: string | null }[];
533
+ items: { id: string; run_id: string; page_id: string; classification: string | null }[];
534
+ decisions: { item_id: string; decision: string | null }[];
535
+ };
536
+
537
+ export const EMPTY_REVIEW_STATE: LocalReviewState = Object.freeze({
538
+ runs: [],
539
+ items: [],
540
+ decisions: [],
541
+ });
542
+
543
+ export type ReviewConflict = {
544
+ /** Which table the disagreement is in. */
545
+ table: string;
546
+ /** How the archived row and the local row were matched to each other. */
547
+ key: string;
548
+ /** What this database currently says. */
549
+ local: string;
550
+ /** What the archive says instead. */
551
+ archived: string;
552
+ };
553
+
554
+ /**
555
+ * Places where the archive's review history and this database's review history
556
+ * describe the same thing differently.
557
+ *
558
+ * Only genuine disagreements are listed. A row that exists in both and says
559
+ * the same thing is not a conflict — it is the ordinary case for an archive
560
+ * taken from this database — and a row that exists in only one of them is not
561
+ * a conflict either, because insert-only restore adds it without touching
562
+ * anything. What is left is the case the append-only triggers cannot resolve
563
+ * on their own: two records claiming the same identity with different content.
564
+ *
565
+ * Note what this refuses to do about it. It reports; it does not pick a
566
+ * winner. A restore that quietly kept the local decision would silently
567
+ * discard imported history, and one that kept the archived decision would
568
+ * overwrite a choice somebody actually made — which is precisely what the
569
+ * decisions table's UNIQUE (item_id) and its append-only trigger exist to
570
+ * prevent. So the restore stops, before the first write, and a person decides.
571
+ */
572
+ export function reviewConflicts(
573
+ entries: ArchiveEntries,
574
+ local: LocalReviewState,
575
+ ): ReviewConflict[] {
576
+ const out: ReviewConflict[] = [];
577
+
578
+ const localRuns = new Map(local.runs.map((r) => [String(r.id), r]));
579
+ for (const row of tableRows(entries, "cms_publication_review_runs")) {
580
+ const id = String(row["id"] ?? "");
581
+ const mine = localRuns.get(id);
582
+ if (!mine) continue;
583
+ const archived = row["input_fingerprint"] == null ? null : String(row["input_fingerprint"]);
584
+ if (archived === (mine.input_fingerprint ?? null)) continue;
585
+ out.push({
586
+ table: "cms_publication_review_runs",
587
+ key: `id="${id}"`,
588
+ local: `fingerprint ${mine.input_fingerprint ?? "(none)"}`,
589
+ archived: `fingerprint ${archived ?? "(none)"}`,
590
+ });
591
+ }
592
+
593
+ const localItemsByRunPage = new Map(
594
+ local.items.map((i) => [`${i.run_id}\u0000${i.page_id}`, i]),
595
+ );
596
+ for (const row of tableRows(entries, "cms_publication_review_items")) {
597
+ const runId = String(row["run_id"] ?? "");
598
+ const pageId = String(row["page_id"] ?? "");
599
+ const mine = localItemsByRunPage.get(`${runId}\u0000${pageId}`);
600
+ if (!mine) continue;
601
+ const archived = row["classification"] == null ? null : String(row["classification"]);
602
+ if (archived === (mine.classification ?? null)) continue;
603
+ out.push({
604
+ table: "cms_publication_review_items",
605
+ key: `run_id="${runId}" page_id="${pageId}"`,
606
+ local: `classified ${mine.classification ?? "(none)"}`,
607
+ archived: `classified ${archived ?? "(none)"}`,
608
+ });
609
+ }
610
+
611
+ const localDecisions = new Map(local.decisions.map((d) => [String(d.item_id), d]));
612
+ for (const row of tableRows(entries, "cms_publication_review_decisions")) {
613
+ const itemId = String(row["item_id"] ?? "");
614
+ const mine = localDecisions.get(itemId);
615
+ if (!mine) continue;
616
+ const archived = row["decision"] == null ? null : String(row["decision"]);
617
+ if (archived === (mine.decision ?? null)) continue;
618
+ out.push({
619
+ table: "cms_publication_review_decisions",
620
+ key: `item_id="${itemId}"`,
621
+ local: `decided ${mine.decision ?? "(none)"}`,
622
+ archived: `decided ${archived ?? "(none)"}`,
623
+ });
624
+ }
625
+
626
+ return out;
627
+ }
628
+
629
+ /**
630
+ * A conflict list as one message an operator can act on.
631
+ *
632
+ * Every conflict is named, not just counted, and capped so a badly matched
633
+ * pair of databases produces a readable refusal rather than a wall of text.
634
+ */
635
+ export function describeReviewConflicts(conflicts: ReviewConflict[]): string {
636
+ const shown = conflicts.slice(0, 10);
637
+ const lines = shown.map(
638
+ (c) => ` ${c.table} ${c.key}: this site says ${c.local}, the archive says ${c.archived}`,
639
+ );
640
+ const more = conflicts.length - shown.length;
641
+ return [
642
+ `This restore was stopped before it wrote anything: ${conflicts.length} piece(s) of ` +
643
+ `publication review history disagree between this site and the archive.`,
644
+ ...lines,
645
+ ...(more > 0 ? [` …and ${more} more.`] : []),
646
+ "Review history is append-only — neither record can be overwritten by the other — " +
647
+ "so somebody has to decide which is right. Restore without the " +
648
+ "\"Publication review history\" dataset to leave this site's own history alone.",
649
+ ].join("\n");
650
+ }
651
+
652
+ /** True when this database holds any publication review history at all. */
653
+ export function hasReviewHistory(local: LocalReviewState): boolean {
654
+ return local.runs.length > 0 || local.items.length > 0 || local.decisions.length > 0;
655
+ }
656
+
657
+ /**
658
+ * Requests this restore will not carry out, stated before it starts rather
659
+ * than discovered halfway through.
660
+ *
661
+ * There is exactly one today, and it is worth being explicit about why it is a
662
+ * refusal and not a repair. Replace mode empties the content tables; every
663
+ * page in a v3 database has a review item pointing at it with ON DELETE
664
+ * RESTRICT; the review tables themselves refuse DELETE unconditionally, by
665
+ * trigger. So Replace over content, in a database that has been reviewed,
666
+ * cannot succeed — the only ways to make it succeed are to weaken a trigger
667
+ * whose entire purpose is that it cannot be weakened from inside the
668
+ * application, or to cascade away the evidence for what the pages were. Merge
669
+ * does what the caller almost certainly wants and is not destructive.
670
+ *
671
+ * Returning the reason as text, rather than throwing here, keeps this usable
672
+ * from the analysis path: an operator sees the refusal alongside the rest of
673
+ * the report, before choosing anything.
674
+ */
675
+ export function replaceRefusals(opts: PlanOptions, localHasReviewHistory: boolean): string[] {
676
+ if (opts.mode !== "replace") return [];
677
+ if (!opts.groups.includes("content")) return [];
678
+ if (!localHasReviewHistory) return [];
679
+ return [
680
+ "Replace cannot be used for Content & pages on this site: it would have to delete " +
681
+ "pages that the publication review history refers to, and that history is append-only " +
682
+ "by design — the database refuses to delete it, and this tool will not ask it twice. " +
683
+ "Restore with Merge instead, which adds and updates content without removing the " +
684
+ "review record of what was published before.",
685
+ ];
686
+ }
687
+
688
+ /* --------------------------------------------------------------- actors */
689
+
690
+ /** An actor column that could not be pointed at a local account. */
691
+ export type UnlinkedActor = { table: string; column: string; email: string };
692
+
693
+ /**
694
+ * Point each actor column at the local account with that address, and drop the
695
+ * pointer where there is no such account.
696
+ *
697
+ * The email travels regardless — it is NOT NULL in the schema and it is the
698
+ * part that stays readable after an account is deleted — so nothing about who
699
+ * did what is lost here. What is dropped is only the join back to a row in
700
+ * `app_users` that this database does not have.
701
+ *
702
+ * Resolved before the insert rather than deferred and written back afterwards,
703
+ * which is the ordinary pattern elsewhere in this file. It has to be: the
704
+ * review tables carry BEFORE UPDATE triggers that raise unconditionally, so
705
+ * the second write of a deferred pair would not fail cleanly — it would fail
706
+ * after the row was already in, leaving a restore that reported success on a
707
+ * table whose actor pointers had all been rejected.
708
+ */
709
+ export function resolveActors(
710
+ table: string,
711
+ spec: TableSpec,
712
+ rows: Record<string, unknown>[],
713
+ usersByEmail: Map<string, string>,
714
+ ): { rows: Record<string, unknown>[]; unlinked: UnlinkedActor[] } {
715
+ const actors = spec.actors ?? [];
716
+ if (!actors.length) return { rows, unlinked: [] };
717
+
718
+ const unlinkedSeen = new Set<string>();
719
+ const unlinked: UnlinkedActor[] = [];
720
+ const out = rows.map((row) => {
721
+ const next = { ...row };
722
+ for (const actor of actors) {
723
+ if (!(actor.column in next) && !(actor.email in next)) continue;
724
+ const raw = next[actor.email];
725
+ const email = typeof raw === "string" ? raw.trim().toLowerCase() : "";
726
+ const localId = email ? usersByEmail.get(email) : undefined;
727
+ if (localId) {
728
+ next[actor.column] = localId;
729
+ continue;
730
+ }
731
+ next[actor.column] = null;
732
+ if (!email) continue;
733
+ const seen = `${table}.${actor.column}\u0000${email}`;
734
+ if (unlinkedSeen.has(seen)) continue;
735
+ unlinkedSeen.add(seen);
736
+ unlinked.push({ table, column: actor.column, email });
737
+ }
738
+ return next;
739
+ });
740
+
741
+ return { rows: out, unlinked };
742
+ }
743
+
744
+ /** Address → id for every local account, keyed the way `resolveActors` looks up. */
745
+ export function indexUsersByEmail(
746
+ users: { id: string; email: string | null }[],
747
+ ): Map<string, string> {
748
+ const out = new Map<string, string>();
749
+ for (const u of users) {
750
+ if (!u.email) continue;
751
+ const key = u.email.trim().toLowerCase();
752
+ if (!key || out.has(key)) continue;
753
+ out.set(key, String(u.id));
754
+ }
755
+ return out;
756
+ }
757
+
418
758
  /* ------------------------------------------------------------- analysis */
419
759
 
420
760
  export type AnalysisReport = {
@@ -423,9 +763,32 @@ export type AnalysisReport = {
423
763
  media: { files: number; bytes: number; quarantined: string[] };
424
764
  authUsers: number;
425
765
  problems: string[];
766
+ /**
767
+ * Archived review history that disagrees with this database's own. Non-empty
768
+ * means the restore does not start. Separate from `problems` because these
769
+ * are not defects in the archive — both records are internally valid, and
770
+ * that is the difficulty.
771
+ */
772
+ reviewConflicts: ReviewConflict[];
773
+ /** Things this restore will not do, and why. Non-empty means it does not start. */
774
+ refusals: string[];
775
+ /** True when the archive predates review history, so a fresh scan is owed. */
776
+ reconciliationNeeded: boolean;
426
777
  };
427
778
 
428
- export function analyzeArchive(bytes: Uint8Array, opts: PlanOptions): AnalysisReport {
779
+ /**
780
+ * Analyse an archive against this database's review history.
781
+ *
782
+ * `local` is the only impure input, and it is passed in rather than read here
783
+ * so the comparison stays testable without a database. Callers that have no
784
+ * local state to offer — the standalone tool inspecting a file, a test — use
785
+ * `analyzeArchive`, which supplies an empty one.
786
+ */
787
+ export function analyzeArchiveCore(
788
+ bytes: Uint8Array,
789
+ opts: PlanOptions,
790
+ local: LocalReviewState,
791
+ ): AnalysisReport {
429
792
  const { manifest, entries } = readArchive(bytes);
430
793
  const plan = planRestore(entries, manifest, opts);
431
794
  const problems: string[] = [];
@@ -461,5 +824,13 @@ export function analyzeArchive(bytes: Uint8Array, opts: PlanOptions): AnalysisRe
461
824
  media: { files: media.files.length, bytes: media.bytes, quarantined: media.quarantined },
462
825
  authUsers,
463
826
  problems,
827
+ reviewConflicts: reviewConflicts(entries, local),
828
+ refusals: replaceRefusals(opts, hasReviewHistory(local)),
829
+ reconciliationNeeded: needsReconciliation(manifest) && opts.groups.includes("content"),
464
830
  };
465
831
  }
832
+
833
+ /** Analysis of an archive on its own, with nothing to compare it against. */
834
+ export function analyzeArchive(bytes: Uint8Array, opts: PlanOptions): AnalysisReport {
835
+ return analyzeArchiveCore(bytes, opts, EMPTY_REVIEW_STATE);
836
+ }
@@ -8,6 +8,53 @@
8
8
  * Bump `PLATFORM_CONTRACT_VERSION` whenever a shared screen starts requiring
9
9
  * schema or adapter capabilities an older site would not have.
10
10
  *
11
+ * ## v3 — five facts, five records
12
+ *
13
+ * v2 settled what is public. v3 settles who may say a release is live, and it
14
+ * does that by refusing to let one sentence stand for five separate facts.
15
+ * "The deployment succeeded" used to mean, all at once: the migration ran, the
16
+ * schema therefore offers a capability, somebody looked at the existing
17
+ * publication rows and understood what the new reader does differently to
18
+ * them, those decisions were applied, and the routes were activated. Each of
19
+ * those fails on its own, and collapsing them is what turns a green deploy
20
+ * into a site serving last month's homepage under a fresh version number.
21
+ *
22
+ * So each has its own record, and the later ones refuse to be written while an
23
+ * earlier one is missing:
24
+ *
25
+ * 1. *migration applied* — the canonical file ran. `SCHEMA_MIGRATIONS` and
26
+ * `missingMigrations` already answered this and still do.
27
+ * 2. *schema capability* — `cms_schema_capabilities` says which contract
28
+ * version the schema itself offers, written by the migration that makes
29
+ * it true, so it cannot drift from the schema it describes.
30
+ * 3. *publication scan* — a review run records what the v3 reader would do
31
+ * differently to every page that already has publication rows, item by
32
+ * item, with the evidence it judged on.
33
+ * 4. *decisions applied* — a decision is recorded against one immutable
34
+ * item, and applying it writes a new publication row rather than editing
35
+ * the row that was reviewed.
36
+ * 5. *route activation, readiness, traffic* — three more, in that order.
37
+ *
38
+ * One canonical migration is new in v3:
39
+ *
40
+ * - `20260905120000_…sql` — the installation identity, the capability
41
+ * record, the canonical digest functions mirrored from `./digest`, the
42
+ * review model, the route claims, and the readiness and activation
43
+ * records.
44
+ *
45
+ * Nothing in it publishes, unpublishes or edits a page. Applying it to a live
46
+ * database changes what the site can be *asked*, not what it currently serves,
47
+ * which is what makes the ordered rollout below safe to start.
48
+ *
49
+ * Release order, and it is not optional:
50
+ *
51
+ * 1. Apply every missing canonical migration.
52
+ * 2. Scan, review and apply the publication decisions. The scan is a read;
53
+ * applying is the first step that changes what visitors see, and it is
54
+ * per-item and recorded.
55
+ * 3. Deploy this package, and the site pinned to contract v3 with it.
56
+ * 4. Activate the routes, declare the database ready, then release traffic.
57
+ *
11
58
  * ## v2 — publication rows decide what is public
12
59
  *
13
60
  * Saving a page version, publishing, unpublishing, scheduling a countdown
@@ -56,73 +103,48 @@
56
103
  * `INSERT`, and two repair `UPDATE`s scoped to pages already in the trash.
57
104
  */
58
105
 
59
- export const PLATFORM_CONTRACT_VERSION = 2 as const;
106
+ import { MIGRATION_NAMES, migrationSlice } from "./migration-manifest";
107
+
108
+ export const PLATFORM_CONTRACT_VERSION = 3 as const;
60
109
 
61
110
  /**
62
111
  * Canonical migration filenames, oldest first. Files live in the published
63
112
  * package at `@bygghammar/cms-platform/migrations/<name>`.
113
+ *
114
+ * Derived from the generated catalog rather than typed out beside it. Two
115
+ * modules used to carry the same sixty names — this one for the deploy-time
116
+ * assertion, `server/schema-drift` for the backoffice version screen — and the
117
+ * test that compared them could only report that they had already drifted
118
+ * apart. Now there is one list, written from the directory itself.
119
+ */
120
+ export const SCHEMA_MIGRATIONS: readonly string[] = MIGRATION_NAMES;
121
+
122
+ /**
123
+ * `migrationDigest` answers "is the file I have the file you published?", and
124
+ * `migrationSlice` names a part of the catalog without copying it. Both are
125
+ * defined in the generated module — it has no imports, so tooling that loads
126
+ * TypeScript sources directly can read it — and re-exported here, which is the
127
+ * subpath (`@bygghammar/cms-platform/platform/contract`) a site already uses.
128
+ */
129
+ export { migrationDigest, migrationSlice } from "./migration-manifest";
130
+
131
+ /**
132
+ * The pair that installs contract v2's transactional publication functions.
133
+ * `pages-core` names them when an RPC comes back "function does not exist",
134
+ * which is what a database that has not run them answers with.
64
135
  */
65
- export const SCHEMA_MIGRATIONS: readonly string[] = [
66
- "20260728113741_f9744b19-078b-46e5-b00f-31c84aaf71ab.sql",
67
- "20260728113757_4156a886-21fb-4a1f-9c12-acb7bb145102.sql",
68
- "20260728113823_6e0237e1-99e3-4e1a-8a68-804b7bcdbbb8.sql",
69
- "20260728115305_506d92b4-129f-4d2b-961f-cf76e592677e.sql",
70
- "20260728115320_717d4447-f433-4a1d-89d2-fc8f028d4ac6.sql",
71
- "20260728115347_c77f5a56-19db-4117-bb60-9ad692b817ab.sql",
72
- "20260728132052_b692dcfe-a944-4a19-afd0-187f7600feb8.sql",
73
- "20260728183318_05b05a47-ba05-4a66-ab8f-69f2688fea84.sql",
74
- "20260728193035_84b68d27-c3f3-4516-a3a9-15fc6f476163.sql",
75
- "20260728193707_8438ee2d-d589-4048-86e2-464fd209af80.sql",
76
- "20260729055312_08862d7e-0279-4d4f-8709-a59b9f068c55.sql",
77
- "20260729055348_418b7e36-4877-4304-831f-290ca73e56f3.sql",
78
- "20260729055358_20b467e2-4efd-42c1-bd34-773ed6b77fc5.sql",
79
- "20260729061039_306859eb-a38c-44ef-af1a-6c02a8fd84db.sql",
80
- "20260730065600_54c25861-3355-4600-a72f-3d67b457ecb1.sql",
81
- "20260730071745_d4904314-15ff-4c58-ac66-85e7371e04b5.sql",
82
- "20260730071914_48163aa3-830e-4dcc-bdd4-c35be282ab87.sql",
83
- "20260730085949_bcfc3270-ea80-4617-86a3-2555ed34a280.sql",
84
- "20260730095256_c977c30e-b285-4041-8804-c0bd72b6d9b3.sql",
85
- "20260730100057_e341b887-eb0e-4a74-850e-0ee5ffb70d7a.sql",
86
- "20260730103703_d5c4e1b7-1e2c-4dd0-be4e-185c159ade69.sql",
87
- "20260730105635_3557b7da-919e-48db-8c92-8221d68d3cf9.sql",
88
- "20260730120308_12ff8cfb-43f9-4070-abde-61531ad3b75f.sql",
89
- "20260730125024_1baf8926-12a2-48f4-8edc-7309941309e9.sql",
90
- "20260730173510_feb974d1-510e-4f52-9e20-5fd0582d8952.sql",
91
- "20260730175409_9f60743f-f5e1-418c-a700-c91fb3704db8.sql",
92
- "20260730181224_1c52ff16-c7a2-4ef1-a0a8-e493c2c9106f.sql",
93
- "20260730183129_933adb75-f985-4d82-ad5d-213b69fd42e5.sql",
94
- "20260730183918_7bc7a827-2100-49f1-bc6e-24509249350b.sql",
95
- "20260730202628_7ceade5e-644c-4bc2-91b8-594133329f31.sql",
96
- "20260730211056_57f42049-00b4-473b-a2cd-65a13570b8d2.sql",
97
- "20260730214340_98af2e55-9031-4c11-9659-c47a37a8d6b7.sql",
98
- "20260731091630_8a6f28ce-5833-4901-ab17-27648cef32ec.sql",
99
- "20260731094716_4c68a1df-83ed-4ba8-8019-fa2fe2b9681e.sql",
100
- "20260731191655_fef97c3c-1397-47b4-8b56-afb020298e97.sql",
101
- "20260731192212_95772a2f-19a1-4013-b463-2eb161e96737.sql",
102
- "20260731192815_c490653e-1232-4ad9-a150-3c83d2b90f5e.sql",
103
- "20260731193135_7966e740-5e86-4b14-8e1d-5119069666ba.sql",
104
- "20260731193245_599790d0-ca5e-4b5d-a3e7-d69b2a79e1a3.sql",
105
- "20260731212959_a748cb0d-4dc7-45dc-be90-f6bacbfab26c.sql",
106
- "20260801003057_1195f4c1-fb54-42dc-9718-638a25e6a82e.sql",
107
- "20260801003300_fc591960-cb41-41cf-837d-2eef23b29ca9.sql",
108
- "20260802151445_a56b0023-eaf7-41e2-a7fb-d4beddfc587c.sql",
109
- "20260802202621_d661af8a-8513-4b54-b375-f963ae824c8f.sql",
110
- "20260802202702_c30cd837-744c-4641-85f1-edd3a7ebbcb6.sql",
111
- "20260802202720_4418d18d-c851-42dc-9345-78fc330128ae.sql",
112
- "20260803132514_78ba1f53-c85b-4457-96b1-7d49c5c8b494.sql",
113
- "20260803181201_28c8aa09-baa8-431c-8338-90b1af9c8172.sql",
114
- "20260803201518_6e8a6028-b70f-4a7d-8bc9-95c86304e7ae.sql",
115
- "20260803201535_32a9a213-cb92-4035-b8cd-8211ca34af68.sql",
116
- "20260811103505_29df753d-14fa-4a05-8fea-f4207785fba3.sql",
117
- "20260811130318_7cb95e95-9baa-40af-8f3f-de1d4e09cec4.sql",
118
- "20260818040722_1bdf1b8f-7fb0-4ae8-8ecf-73f1fadff3c2.sql",
119
- "20260818040808_69a5e832-57e8-4aa7-aff8-7fd7777faa7c.sql",
120
- "20260818040839_e6523163-bbf5-4a39-9233-91759059569a.sql",
121
- "20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql",
122
- "20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql",
136
+ export const CONTRACT_V2_MIGRATIONS = migrationSlice("contract v2", [
123
137
  "20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql",
124
138
  "20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql",
125
- ];
139
+ ]);
140
+
141
+ /**
142
+ * The single file contract v3 adds: the installation identity, the capability
143
+ * record, the review model, the route claims and the readiness records.
144
+ */
145
+ export const CONTRACT_V3_MIGRATIONS = migrationSlice("contract v3", [
146
+ "20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24.sql",
147
+ ]);
126
148
 
127
149
  export function missingMigrations(applied: readonly string[]): string[] {
128
150
  const set = new Set(applied);