@bygghammar/cms-platform 0.91.0 → 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.
- package/CHANGELOG.md +151 -0
- package/README.md +28 -4
- 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 +79 -46
- 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
|
@@ -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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
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
|
|
12
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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;
|
package/src/server/pages-core.ts
CHANGED
|
@@ -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
|
-
`${
|
|
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
|
-
|
|
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
|
|
737
|
-
.
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
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(
|