@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.
- package/CHANGELOG.md +125 -0
- package/README.md +20 -9
- package/docs/NEW-SITE.md +29 -14
- package/docs/PAGES.md +79 -21
- package/docs/PUBLICATION-REVIEW.md +298 -0
- package/migrations/20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24.sql +3298 -0
- package/package.json +8 -2
- package/scripts/cms-review.mjs +832 -0
- package/scripts/review-fingerprint.sql +43 -0
- package/scripts/review-scan.sql +56 -0
- package/src/backup/datasets.ts +203 -2
- package/src/backup/restore-core.ts +382 -11
- package/src/platform/contract.ts +82 -60
- package/src/platform/digest.ts +203 -0
- package/src/platform/migration-manifest.ts +310 -0
- package/src/platform/publication-review.ts +413 -0
- package/src/server/backup-core.ts +291 -18
- package/src/server/page-seo-core.ts +56 -10
- package/src/server/pages-core.ts +90 -53
- package/src/server/redirects-core.ts +51 -9
- package/src/server/schema-drift.ts +21 -65
- package/src/site/route-index.ts +641 -0
- package/src/site/routes.ts +2 -8
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 =
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/platform/contract.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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);
|