@bygghammar/cms-platform 0.91.1 → 0.92.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.
@@ -8,10 +8,25 @@ import {
8
8
  LINEAGE_KEYS,
9
9
  SCHEMA_VERSION,
10
10
  groupById,
11
+ readSchemaVersion,
11
12
  type ArchiveManifest,
12
13
  type ImportMode,
13
14
  type TableSpec,
14
15
  } from "../backup/datasets";
16
+ import {
17
+ buildClearPlan,
18
+ describeReviewConflicts,
19
+ indexUsersByEmail,
20
+ needsReconciliation,
21
+ replaceRefusals,
22
+ resolveActors,
23
+ reviewConflicts,
24
+ writableColumns,
25
+ type ClearStep,
26
+ type LocalReviewState,
27
+ type ReviewConflict,
28
+ type UnlinkedActor,
29
+ } from "../backup/restore-core";
15
30
  import {
16
31
  hasAllowedMediaExtension,
17
32
  isSafeArchivePath,
@@ -348,10 +363,9 @@ export async function analyzeArchiveCore(deps: BackupDeps, bytes: Uint8Array): P
348
363
  `Archive format ${manifest.format} does not match this site (${ARCHIVE_FORMAT_VERSION}).`,
349
364
  );
350
365
  }
351
- if (manifest.schemaVersion !== SCHEMA_VERSION) {
352
- problems.push(
353
- `Archive was made for database version ${manifest.schemaVersion}, this site runs version ${SCHEMA_VERSION}.`,
354
- );
366
+ const reading = readSchemaVersion(manifest.schemaVersion);
367
+ if (!reading.supported) {
368
+ problems.push(`This archive cannot be restored — ${reading.reason}.`);
355
369
  }
356
370
 
357
371
  const media = measureMedia(entries);
@@ -408,6 +422,168 @@ export async function analyzeArchiveCore(deps: BackupDeps, bytes: Uint8Array): P
408
422
  };
409
423
  }
410
424
 
425
+ /* ------------------------------------------------- local review history */
426
+
427
+ const REVIEW_LOOKUP_CHUNK = 200;
428
+
429
+ /** Read rows whose key is in `values`, in chunks small enough for a URL. */
430
+ async function selectByKey(
431
+ supabaseAdmin: SupabaseClient,
432
+ table: string,
433
+ columns: string,
434
+ keyColumn: string,
435
+ values: string[],
436
+ ): Promise<Record<string, unknown>[]> {
437
+ const out: Record<string, unknown>[] = [];
438
+ for (let i = 0; i < values.length; i += REVIEW_LOOKUP_CHUNK) {
439
+ const chunk = values.slice(i, i + REVIEW_LOOKUP_CHUNK);
440
+ const { data, error } = await supabaseAdmin
441
+ .from(table as never)
442
+ .select(columns as never)
443
+ .in(keyColumn as never, chunk as never);
444
+ if (error) throw new Error(`Could not read ${table} to check for conflicts: ${error.message}`);
445
+ out.push(...((data ?? []) as unknown as Record<string, unknown>[]));
446
+ }
447
+ return out;
448
+ }
449
+
450
+ function archivedIds(
451
+ entries: Record<string, Uint8Array>,
452
+ table: string,
453
+ column: string,
454
+ ): string[] {
455
+ const seen = new Set<string>();
456
+ for (const row of tableRows(entries, table)) {
457
+ const v = row[column];
458
+ if (typeof v === "string" && v) seen.add(v);
459
+ }
460
+ return [...seen];
461
+ }
462
+
463
+ /**
464
+ * The local review rows the archive could possibly disagree with.
465
+ *
466
+ * Scoped to the ids the archive actually mentions rather than the whole
467
+ * history, which on a busy site is one row per page per scan and grows without
468
+ * bound. A row the archive says nothing about cannot conflict with it, so
469
+ * reading it would cost time to learn nothing.
470
+ */
471
+ async function readLocalReviewState(
472
+ supabaseAdmin: SupabaseClient,
473
+ entries: Record<string, Uint8Array>,
474
+ ): Promise<LocalReviewState> {
475
+ const runIds = archivedIds(entries, "cms_publication_review_runs", "id");
476
+ const itemRunIds = archivedIds(entries, "cms_publication_review_items", "run_id");
477
+ const decisionItemIds = archivedIds(entries, "cms_publication_review_decisions", "item_id");
478
+
479
+ const runs = runIds.length
480
+ ? await selectByKey(
481
+ supabaseAdmin,
482
+ "cms_publication_review_runs",
483
+ "id,input_fingerprint",
484
+ "id",
485
+ runIds,
486
+ )
487
+ : [];
488
+ const items = itemRunIds.length
489
+ ? await selectByKey(
490
+ supabaseAdmin,
491
+ "cms_publication_review_items",
492
+ "id,run_id,page_id,classification",
493
+ "run_id",
494
+ itemRunIds,
495
+ )
496
+ : [];
497
+ const decisions = decisionItemIds.length
498
+ ? await selectByKey(
499
+ supabaseAdmin,
500
+ "cms_publication_review_decisions",
501
+ "item_id,decision",
502
+ "item_id",
503
+ decisionItemIds,
504
+ )
505
+ : [];
506
+
507
+ return {
508
+ runs: runs.map((r) => ({
509
+ id: String(r["id"]),
510
+ input_fingerprint: r["input_fingerprint"] == null ? null : String(r["input_fingerprint"]),
511
+ })),
512
+ items: items.map((r) => ({
513
+ id: String(r["id"]),
514
+ run_id: String(r["run_id"]),
515
+ page_id: String(r["page_id"]),
516
+ classification: r["classification"] == null ? null : String(r["classification"]),
517
+ })),
518
+ decisions: decisions.map((r) => ({
519
+ item_id: String(r["item_id"]),
520
+ decision: r["decision"] == null ? null : String(r["decision"]),
521
+ })),
522
+ };
523
+ }
524
+
525
+ /**
526
+ * Whether this database holds any review history at all.
527
+ *
528
+ * Asked separately from `readLocalReviewState`, and about the whole table
529
+ * rather than the archived ids, because the Replace refusal does not turn on
530
+ * overlap: a Replace that empties `pages` is blocked by *any* review item
531
+ * pointing at any page, whether or not the archive has ever heard of it.
532
+ */
533
+ async function localHasReviewHistory(supabaseAdmin: SupabaseClient): Promise<boolean> {
534
+ const { count, error } = await supabaseAdmin
535
+ .from("cms_publication_review_items" as never)
536
+ .select("id" as never, { count: "exact", head: true });
537
+ if (error) {
538
+ // A database without the review tables has no history to protect, and a
539
+ // site that has not migrated to v3 yet must still be able to restore.
540
+ return false;
541
+ }
542
+ return (count ?? 0) > 0;
543
+ }
544
+
545
+ /**
546
+ * One clear step through supabase-js.
547
+ *
548
+ * `buildClear` renders the same step as SQL for the standalone tool; this is
549
+ * the same plan spoken to PostgREST, which has no SQL channel. Both need a
550
+ * row filter — PostgREST refuses an unqualified DELETE or UPDATE, on purpose —
551
+ * and `pk IS NOT NULL` is the one that matches every row, since a primary key
552
+ * is never null.
553
+ */
554
+ async function runClearStep(
555
+ supabaseAdmin: SupabaseClient,
556
+ step: ClearStep,
557
+ pk: string,
558
+ ): Promise<string | null> {
559
+ if (step.kind === "release") {
560
+ const patch: Record<string, unknown> = {};
561
+ for (const c of step.columns) patch[c] = null;
562
+ const { error } = await supabaseAdmin
563
+ .from(step.table as never)
564
+ .update(patch as never)
565
+ .not(pk, "is", null);
566
+ return error ? error.message : null;
567
+ }
568
+ const { error } = await supabaseAdmin
569
+ .from(step.table as never)
570
+ .delete()
571
+ .not(pk, "is", null);
572
+ return error ? error.message : null;
573
+ }
574
+
575
+ /** Address → local account id, read once and reused for every actor column. */
576
+ async function readUsersByEmail(supabaseAdmin: SupabaseClient): Promise<Map<string, string>> {
577
+ const { data, error } = await supabaseAdmin.from("app_users" as never).select("id,email" as never);
578
+ if (error) return new Map();
579
+ return indexUsersByEmail(
580
+ ((data ?? []) as unknown as Record<string, unknown>[]).map((r) => ({
581
+ id: String(r["id"]),
582
+ email: r["email"] == null ? null : String(r["email"]),
583
+ })),
584
+ );
585
+ }
586
+
411
587
  export type ImportResult = {
412
588
  /** False when any row, table or media file could not be restored. */
413
589
  ok: boolean;
@@ -429,6 +605,23 @@ export type ImportResult = {
429
605
  mediaBytesInArchive: number;
430
606
  /** Archive entries that were not media and were deliberately not restored. */
431
607
  quarantinedMedia: string[];
608
+ /**
609
+ * Actor columns whose address matched no account here. The address itself
610
+ * was restored — it is the part that stays readable — but the pointer back
611
+ * to `app_users` was dropped, and an operator may want to relink these.
612
+ */
613
+ unlinkedActors: UnlinkedActor[];
614
+ /**
615
+ * Whether a fresh publication review scan was owed after this restore, and
616
+ * whether it ran. A schema-2 archive carries no review history, so the
617
+ * content it restores has no classification until something looks at it.
618
+ */
619
+ reconciliation: {
620
+ needed: boolean;
621
+ ran: boolean;
622
+ /** Present when it was owed and did not run. */
623
+ reason?: string;
624
+ };
432
625
  warnings: string[];
433
626
  };
434
627
 
@@ -454,10 +647,9 @@ export async function importArchiveCore(
454
647
  `Incompatible archive — this site reads format ${ARCHIVE_FORMAT_VERSION} archives, this one is format ${manifest.format}. Re-export it from the site that produced it.`,
455
648
  );
456
649
  }
457
- if (manifest.schemaVersion !== SCHEMA_VERSION) {
458
- throw new Error(
459
- `Incompatible archive — it was made for database version ${manifest.schemaVersion}, this site runs version ${SCHEMA_VERSION}.`,
460
- );
650
+ const reading = readSchemaVersion(manifest.schemaVersion);
651
+ if (!reading.supported) {
652
+ throw new Error(`Incompatible archive — ${reading.reason}.`);
461
653
  }
462
654
 
463
655
  const selected = opts.groups.filter((g) => (manifest.groups ?? []).includes(g));
@@ -500,9 +692,23 @@ export async function importArchiveCore(
500
692
  }
501
693
  }
502
694
 
695
+ // Nothing below this point is reversible, so everything that can refuse the
696
+ // restore outright happens here, before the first write. The old code could
697
+ // report "completed with conflicts" after it had already applied half the
698
+ // content; there is no such outcome any more.
699
+ const refusals = replaceRefusals(
700
+ { groups: selected, mode: opts.mode },
701
+ await localHasReviewHistory(supabaseAdmin),
702
+ );
703
+ if (refusals.length) throw new Error(refusals.join(" "));
704
+
705
+ const conflicts = reviewConflicts(entries, await readLocalReviewState(supabaseAdmin, entries));
706
+ if (conflicts.length) throw new Error(describeReviewConflicts(conflicts));
707
+
503
708
  const applied: ImportResult["applied"] = [];
504
709
  const aborted: string[] = [];
505
710
  const warnings: string[] = [];
711
+ const unlinkedActors: UnlinkedActor[] = [];
506
712
  const failedTables = new Set<string>();
507
713
  const deferredWork: {
508
714
  table: string;
@@ -511,6 +717,35 @@ export async function importArchiveCore(
511
717
  cols: string[];
512
718
  }[] = [];
513
719
 
720
+ // Replace mode: empty the selected tables once, here, in reverse dependency
721
+ // order — not inside the per-table loop below, where a later table's DELETE
722
+ // would remove rows an earlier one had just written, and where a table the
723
+ // archive is silent about was skipped by `if (!rows.length) continue;` and
724
+ // so never cleared at all.
725
+ if (opts.mode === "replace") {
726
+ const pkOf = new Map([...specs].map(([table, e]) => [table, e.spec.pk]));
727
+ for (const step of buildClearPlan(selected)) {
728
+ const pk = pkOf.get(step.table);
729
+ if (!pk) continue;
730
+ const error = await runClearStep(supabaseAdmin, step, pk);
731
+ if (!error) continue;
732
+ // A clear that fails is not a warning. Everything downstream of it would
733
+ // be written into a table still holding the rows this restore was
734
+ // supposed to replace, so the table is marked failed and its children
735
+ // are aborted by the dependency check in the loop.
736
+ warnings.push(`Could not clear ${step.table}: ${error}`);
737
+ failedTables.add(step.table);
738
+ }
739
+ }
740
+
741
+ // Read at most once, and only if some selected table actually has actor
742
+ // columns. Most restores do not touch the review tables at all.
743
+ let usersByEmail: Map<string, string> | null = null;
744
+ const users = async (): Promise<Map<string, string>> => {
745
+ usersByEmail ??= await readUsersByEmail(supabaseAdmin);
746
+ return usersByEmail;
747
+ };
748
+
514
749
  const base = opts.rewriteMediaBase?.trim();
515
750
  const prepare = (table: string, row: Record<string, unknown>) => {
516
751
  let out = base ? rewriteMediaUrls(row, base) : row;
@@ -556,21 +791,23 @@ export async function importArchiveCore(
556
791
  const mode: ImportMode = insertOnly ? "merge" : opts.mode;
557
792
  const conflict = entry.spec.conflict ?? entry.spec.pk;
558
793
 
559
- if (mode === "replace" && !insertOnly) {
560
- const { error } = await supabaseAdmin
561
- .from(table as never)
562
- .delete()
563
- .not(entry.spec.pk, "is", null);
564
- if (error) {
565
- warnings.push(`Could not clear ${table}: ${error.message}`);
566
- failedTables.add(table);
567
- }
568
- }
794
+ // Actor pointers are settled before the insert, never after it. The review
795
+ // tables refuse UPDATE by trigger, so a deferred write-back would fail
796
+ // *after* the row was already in — a restore reporting success on a table
797
+ // whose every actor pointer had been rejected.
798
+ const resolved = (entry.spec.actors ?? []).length
799
+ ? resolveActors(table, entry.spec, rows, await users())
800
+ : { rows, unlinked: [] as UnlinkedActor[] };
801
+ rows = resolved.rows;
802
+ unlinkedActors.push(...resolved.unlinked);
569
803
 
570
804
  const deferred = entry.spec.deferred ?? [];
805
+ const generated = new Set(entry.spec.generated ?? []);
571
806
  const payload = rows.map((r) => {
572
807
  const copy = prepare(table, { ...r });
573
808
  for (const c of deferred) copy[c] = null;
809
+ // PostgreSQL rejects a write that names a generated column outright.
810
+ for (const c of generated) delete copy[c];
574
811
  return copy;
575
812
  });
576
813
  if (deferred.length) {
@@ -684,6 +921,36 @@ export async function importArchiveCore(
684
921
 
685
922
  const ok = failedTables.size === 0 && aborted.length === 0;
686
923
 
924
+ // Reconciliation. A schema-2 archive carries no review history, so the
925
+ // content it just restored has no classification and nothing else will ever
926
+ // give it one.
927
+ //
928
+ // It runs only when the restore actually finished: a run states
929
+ // expected_page_count, which is a claim about a database that has stopped
930
+ // changing, and half a restore is not that. A partial restore therefore
931
+ // leaves the site unclassified and the result says so, rather than recording
932
+ // a run that would read as "this database has been reviewed".
933
+ const reconciliationNeeded = needsReconciliation(manifest) && selected.includes("content");
934
+ let reconciliationRan = false;
935
+ let reconciliationReason: string | undefined;
936
+ if (reconciliationNeeded) {
937
+ if (!ok) {
938
+ reconciliationReason =
939
+ "the restore did not complete cleanly, so no review scan was recorded — " +
940
+ "the pages that did arrive have no publication classification until one is run.";
941
+ } else {
942
+ const { error } = await supabaseAdmin.rpc("cms_publication_review_reconcile" as never, {
943
+ _operator_email: caller.email,
944
+ } as never);
945
+ if (error) {
946
+ reconciliationReason = `the review scan could not be recorded: ${error.message}`;
947
+ } else {
948
+ reconciliationRan = true;
949
+ }
950
+ }
951
+ if (reconciliationReason) warnings.push(reconciliationReason);
952
+ }
953
+
687
954
  await audit(deps, caller, "backup.import", {
688
955
  groups: selected,
689
956
  mode: opts.mode,
@@ -708,6 +975,12 @@ export async function importArchiveCore(
708
975
  mediaFilesInArchive: archiveMedia.files,
709
976
  mediaBytesInArchive: archiveMedia.bytes,
710
977
  quarantinedMedia: archiveMedia.quarantined,
978
+ unlinkedActors,
979
+ reconciliation: {
980
+ needed: reconciliationNeeded,
981
+ ran: reconciliationRan,
982
+ ...(reconciliationReason ? { reason: reconciliationReason } : {}),
983
+ },
711
984
  warnings,
712
985
  };
713
986
  }
@@ -4,34 +4,80 @@
4
4
  */
5
5
  import { normalizePath } from "./redirects-core";
6
6
  import type { PageSlugRegistry } from "./redirects-core";
7
+ import { normalizeRoutePath, servedPath, type RoutePathPolicy } from "../site/route-index";
7
8
 
8
- export type SlugConflict = { locale: string; pageKey: string };
9
+ /**
10
+ * A page whose address is already taken.
11
+ *
12
+ * `path` is the address as the route authority spells it, and `heldBy` names
13
+ * the page that reached it first. "That slug is taken" is not something an
14
+ * editor can act on: the two pages need not look alike, and on a site that
15
+ * folds case or normalizes Unicode they need not even be spelled alike.
16
+ */
17
+ export type SlugConflict = {
18
+ locale: string;
19
+ pageKey: string;
20
+ path: string;
21
+ heldBy: string;
22
+ };
9
23
 
10
24
  /**
11
- * No two pages may resolve to the same URL in the same locale.
12
- * `existing` is the stored override map, `pending` the edit being saved.
25
+ * No two pages may resolve to the same URL.
26
+ *
27
+ * Not "in the same locale": in the same *site*. The locale prefix is part of
28
+ * the address, so a Swedish page whose slug is `en` and the English front page
29
+ * both answer at `/en`, and a comparison run once per locale calls that pair
30
+ * fine. Every locale's addresses go into one table here for that reason.
31
+ *
32
+ * The addresses come from `servedPath` and `normalizeRoutePath` — the two
33
+ * functions the site builds its own links with — so this check cannot disagree
34
+ * with what the site serves. It used to compare bare slug strings, which made
35
+ * `/Om-Oss` and `/om-oss` two pages to the editor and one page to the browser.
36
+ *
37
+ * It does not consult a list of reserved paths. The release's own claims are
38
+ * the authority on those, and a second list of forbidden strings kept here
39
+ * would be a second authority waiting to disagree with the first.
40
+ *
41
+ * `existing` is the stored override map, `slugs` the edit being saved.
13
42
  */
14
43
  export function detectSlugConflictsCore(input: {
15
44
  pages: PageSlugRegistry;
16
45
  locales: readonly string[];
46
+ defaultLocale: string;
17
47
  existing: Record<string, Record<string, string>>;
18
48
  pageKey: string;
19
49
  slugs: Record<string, string>;
50
+ policy?: RoutePathPolicy;
20
51
  }): SlugConflict[] {
21
52
  const pendingByKey = new Map<string, Record<string, string>>(Object.entries(input.existing));
22
53
  pendingByKey.set(input.pageKey, input.slugs);
23
54
 
55
+ // Registry pages, pages that exist only as an override row, and the page
56
+ // being saved. A builder-created page is in no static registry, and leaving
57
+ // it out of the comparison is exactly how it comes to take an address the
58
+ // registry already serves.
59
+ const keys = [...new Set([...Object.keys(input.pages), ...pendingByKey.keys()])];
60
+
61
+ const policy: RoutePathPolicy = { locales: input.locales, ...(input.policy ?? {}) };
24
62
  const conflicts: SlugConflict[] = [];
63
+ const seen = new Map<string, string>();
64
+
25
65
  for (const locale of input.locales) {
26
- const seen = new Map<string, string>();
27
- for (const key of Object.keys(input.pages)) {
28
- const base = input.pages[key].slug[locale];
66
+ for (const key of keys) {
29
67
  const override = pendingByKey.get(key)?.[locale];
30
- const effective = override ?? base;
68
+ const base = input.pages[key]?.slug[locale];
69
+ // A page with no slug in this locale is still served here when it has
70
+ // one in the default locale: path building falls back, so the address
71
+ // exists and can collide whether or not anyone translated the slug.
72
+ const fallback =
73
+ pendingByKey.get(key)?.[input.defaultLocale] ?? input.pages[key]?.slug[input.defaultLocale];
74
+ const effective = override ?? base ?? fallback;
31
75
  if (effective === undefined) continue;
32
- const existing = seen.get(effective);
33
- if (existing && existing !== key) conflicts.push({ locale, pageKey: key });
34
- else seen.set(effective, key);
76
+
77
+ const path = normalizeRoutePath(servedPath(effective, locale, input.defaultLocale), policy);
78
+ const holder = seen.get(path);
79
+ if (holder === undefined) seen.set(path, key);
80
+ else if (holder !== key) conflicts.push({ locale, pageKey: key, path, heldBy: holder });
35
81
  }
36
82
  }
37
83
  return conflicts;
@@ -37,6 +37,8 @@ import {
37
37
  type PublicationWindow,
38
38
  } from "../cms/page-publication";
39
39
  import { resolveGlobalsDeep } from "./resolve-globals";
40
+ import { CONTRACT_V2_MIGRATIONS, CONTRACT_V3_MIGRATIONS } from "../platform/contract";
41
+ import { digestJson, type DigestValue } from "../platform/digest";
40
42
 
41
43
  /* eslint-disable @typescript-eslint/no-explicit-any */
42
44
 
@@ -381,12 +383,6 @@ export async function getPageForEditCore({ context }: PagesDeps, key: string) {
381
383
  */
382
384
  const FUNCTION_MISSING = new Set(["PGRST202", "PGRST203", "42883", "42P01"]);
383
385
 
384
- /** The migrations that install the functions below, named in the error. */
385
- const PUBLICATION_MIGRATIONS = [
386
- "20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql",
387
- "20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql",
388
- ] as const;
389
-
390
386
  /**
391
387
  * One database-function call, for the writes that change what the public sees.
392
388
  *
@@ -409,6 +405,13 @@ async function publicationRpc(
409
405
  fn: string,
410
406
  args: Record<string, unknown>,
411
407
  whenFailed: string,
408
+ /**
409
+ * Which migrations install `fn`. Defaults to the contract-v2 pair, which
410
+ * installs every publication function except the ones contract v3 adds —
411
+ * those call sites name their own, because sending an operator after the
412
+ * wrong file is worse than telling them nothing.
413
+ */
414
+ installedBy: readonly string[] = CONTRACT_V2_MIGRATIONS,
412
415
  ): Promise<any> {
413
416
  const { data, error } = await client.rpc(fn, args);
414
417
  if (!error) return data;
@@ -418,9 +421,12 @@ async function publicationRpc(
418
421
  FUNCTION_MISSING.has(code) ||
419
422
  /could not find the function|does not exist/i.test(message)
420
423
  ) {
424
+ // A slice of the generated migration manifest rather than a second copy of
425
+ // the names: an operator sent after a file this package has stopped
426
+ // shipping is worse off than one told nothing.
421
427
  throw new Error(
422
428
  `${whenFailed}: this database does not have ${fn}. Apply the migrations ` +
423
- `${PUBLICATION_MIGRATIONS.join(" and ")} shipped with this package and try again.`,
429
+ `${installedBy.join(" and ")} shipped with this package and try again.`,
424
430
  );
425
431
  }
426
432
  throw new Error(message || whenFailed);
@@ -690,6 +696,9 @@ export async function setPageStatusCore(
690
696
  return { ok: true as const, status };
691
697
  }
692
698
 
699
+ /** A key the database will accept as `uuid`, checked here to say why. */
700
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
701
+
693
702
  /**
694
703
  * Copy a page, content and all, into a new draft.
695
704
  *
@@ -698,14 +707,34 @@ export async function setPageStatusCore(
698
707
  * copied — canonical URLs, slugs and meta titles are per-page facts, and
699
708
  * duplicating them would publish two pages claiming the same canonical address.
700
709
  * The copy starts as a draft whatever the source's status is.
710
+ *
711
+ * Three reads decide what to propose, and one transaction decides whether the
712
+ * proposal still describes the page. The reads cannot be inside that
713
+ * transaction — a PostgREST client has no way to hold one open — so nothing
714
+ * here is trusted afterwards: the database re-checks the address, re-checks
715
+ * which version was on screen, and recomputes the digest of that version's
716
+ * content under its own lock. It has to recompute rather than compare ids,
717
+ * because content under a stable version id is not immutable in this schema.
718
+ *
719
+ * `idempotencyKey` is which duplication this is. A caller that wants a retry to
720
+ * be recognised as a retry has to supply it and reuse the same value; one is
721
+ * generated when it is absent, which is honest about what that buys — it makes
722
+ * this call unique, and it makes a second call a second copy.
701
723
  */
702
724
  export async function duplicatePageCore(
703
725
  { context }: PagesDeps,
704
- input: { pageId: string; name: string; key?: string },
726
+ input: { pageId: string; name: string; key?: string; idempotencyKey?: string },
705
727
  ) {
706
728
  await assertStaff(context);
707
729
  const name = input.name.trim();
708
730
  if (!name) throw new Error("A name is required for the copy.");
731
+ const idempotencyKey = (input.idempotencyKey ?? crypto.randomUUID()).trim();
732
+ if (!UUID.test(idempotencyKey)) {
733
+ throw new Error(
734
+ "An idempotency key for a copy must be a UUID, so that a retry can be told " +
735
+ "apart from a second copy.",
736
+ );
737
+ }
709
738
 
710
739
  const { data: source, error: srcErr } = await context.supabase
711
740
  .from("pages")
@@ -722,55 +751,59 @@ export async function duplicatePageCore(
722
751
  const taken = ((existing ?? []) as Array<{ key: string }>).map((row) => row.key);
723
752
  const key = uniquePageKey(input.key?.trim() || name, taken);
724
753
 
754
+ // What the source holds and what the copy will hold are two different values.
755
+ // The digest binds the first; the second is the first with every block
756
+ // re-identified, so the two pages never share a block id.
725
757
  let blocks: BlockInstance[] = [];
758
+ let blocksDigest: string | null = null;
726
759
  if (source.current_version_id) {
727
- const { data: version } = await context.supabase
760
+ const { data: version, error: versionReadErr } = await context.supabase
728
761
  .from("page_versions")
729
762
  .select("blocks")
730
763
  .eq("id", source.current_version_id)
731
764
  .maybeSingle();
732
- blocks = duplicateBlockTree(blocksOf(version?.blocks));
765
+ if (versionReadErr) throw new Error(versionReadErr.message);
766
+ // A page pointing at a version that is not there is broken, and copying it
767
+ // as an empty page would invent content rather than report the breakage.
768
+ if (!version) throw new Error("Version not found");
769
+ blocksDigest = await digestJson(version.blocks as DigestValue);
770
+ blocks = duplicateBlockTree(blocksOf(version.blocks));
733
771
  }
734
772
 
735
773
  invalidatePublishedCache();
736
- const { data: page, error } = await context.supabase
737
- .from("pages")
738
- .insert({ key, name, is_system: false, status: DEFAULT_PAGE_STATUS })
739
- .select("id")
740
- .single();
741
- if (error) throw new Error(error.message);
742
-
743
- const { data: created, error: versionErr } = await context.supabase
744
- .from("page_versions")
745
- .insert({
746
- page_id: page.id,
747
- version_no: 1,
748
- blocks: blocks as unknown as any,
749
- note: `Copied from "${source.name}"`,
750
- created_by: context.userId,
751
- created_by_email: actorEmail(context),
752
- })
753
- .select("id")
754
- .single();
755
- if (versionErr) throw new Error(versionErr.message);
756
- await context.supabase
757
- .from("pages")
758
- .update({ current_version_id: created.id })
759
- .eq("id", page.id);
760
-
761
- await context.supabase.from("audit_logs").insert({
762
- actor_id: context.userId,
763
- action: "page.duplicate",
764
- entity: "pages",
765
- entity_id: page.id,
766
- meta: {
767
- key,
768
- source_key: source.key,
769
- description: `Duplicated the page "${source.name}" as the draft "${name}" (${key}).`,
774
+ const copy = await publicationRpc(
775
+ context.supabase,
776
+ "cms_duplicate_page",
777
+ {
778
+ _source_page_id: input.pageId,
779
+ _expected_version_id: source.current_version_id ?? null,
780
+ _expected_blocks_digest: blocksDigest,
781
+ _idempotency_key: idempotencyKey,
782
+ _key: key,
783
+ _name: name,
784
+ _blocks: blocks as unknown,
785
+ // The description is composed in the function, from the names it read
786
+ // under the lock, so there is nothing to say here that it does not know.
787
+ _audit: {},
788
+ _actor_email: actorEmail(context),
770
789
  },
771
- });
790
+ "Could not copy the page",
791
+ CONTRACT_V3_MIGRATIONS,
792
+ );
772
793
 
773
- return { id: page.id as string, key, name, status: DEFAULT_PAGE_STATUS };
794
+ const id = typeof copy?.pageId === "string" ? copy.pageId : "";
795
+ if (!id) throw new Error("The database made no copy of this page.");
796
+ return {
797
+ id,
798
+ key: String(copy.key ?? key),
799
+ name: String(copy.name ?? name),
800
+ status: String(copy.status ?? DEFAULT_PAGE_STATUS),
801
+ /**
802
+ * True when this key had already been spent, so the copy being returned is
803
+ * the one the first call made rather than a new one.
804
+ */
805
+ idempotent: Boolean(copy.idempotent),
806
+ };
774
807
  }
775
808
 
776
809
  export async function renamePageCore(