@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.
@@ -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;