@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.
@@ -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
 
@@ -375,18 +377,12 @@ export async function getPageForEditCore({ context }: PagesDeps, key: string) {
375
377
  }
376
378
 
377
379
  /**
378
- * Codes and wording that mean "this installation has not run the publication
379
- * migration yet". `PGRST202`/`PGRST203` come from PostgREST, `42883`/`42P01`
380
- * from PostgreSQL itself.
380
+ * Codes and wording that mean "this installation has no such function, or none
381
+ * that takes these arguments". `PGRST202`/`PGRST203` come from PostgREST, which
382
+ * answers the same way for both; `42883`/`42P01` from PostgreSQL itself.
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,16 @@ 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. Only a code from the set
427
+ // above is repeated; the backend's own message is not.
421
428
  throw new Error(
422
- `${whenFailed}: this database does not have ${fn}. Apply the migrations ` +
423
- `${PUBLICATION_MIGRATIONS.join(" and ")} shipped with this package and try again.`,
429
+ `${whenFailed}: the database has no ${fn} that accepts this call` +
430
+ `${FUNCTION_MISSING.has(code) ? ` (${code})` : ""}. Either the migrations ` +
431
+ `${installedBy.join(" and ")} shipped with this package are not installed, ` +
432
+ `or the installed ${fn} takes different arguments than this package sends. ` +
433
+ `Compare the database's schema with this package before applying anything.`,
424
434
  );
425
435
  }
426
436
  throw new Error(message || whenFailed);
@@ -468,10 +478,10 @@ export async function savePageVersionCore(
468
478
  _note: input.note ?? null,
469
479
  _publish: Boolean(input.publish),
470
480
  _actor_email: actorEmail(context),
481
+ // No `_source_page_id`: the function takes none, and names `_page_id` as
482
+ // the source of every reveal itself, so a countdown pointing at the page
483
+ // being published is still refused rather than hiding it.
471
484
  _reveals: reveals,
472
- // Named so a countdown pointing at the page being published is refused
473
- // rather than hiding it until its own deadline.
474
- _source_page_id: input.pageId,
475
485
  _audit: input.publish
476
486
  ? {
477
487
  description:
@@ -690,6 +700,9 @@ export async function setPageStatusCore(
690
700
  return { ok: true as const, status };
691
701
  }
692
702
 
703
+ /** A key the database will accept as `uuid`, checked here to say why. */
704
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
705
+
693
706
  /**
694
707
  * Copy a page, content and all, into a new draft.
695
708
  *
@@ -698,14 +711,34 @@ export async function setPageStatusCore(
698
711
  * copied — canonical URLs, slugs and meta titles are per-page facts, and
699
712
  * duplicating them would publish two pages claiming the same canonical address.
700
713
  * The copy starts as a draft whatever the source's status is.
714
+ *
715
+ * Three reads decide what to propose, and one transaction decides whether the
716
+ * proposal still describes the page. The reads cannot be inside that
717
+ * transaction — a PostgREST client has no way to hold one open — so nothing
718
+ * here is trusted afterwards: the database re-checks the address, re-checks
719
+ * which version was on screen, and recomputes the digest of that version's
720
+ * content under its own lock. It has to recompute rather than compare ids,
721
+ * because content under a stable version id is not immutable in this schema.
722
+ *
723
+ * `idempotencyKey` is which duplication this is. A caller that wants a retry to
724
+ * be recognised as a retry has to supply it and reuse the same value; one is
725
+ * generated when it is absent, which is honest about what that buys — it makes
726
+ * this call unique, and it makes a second call a second copy.
701
727
  */
702
728
  export async function duplicatePageCore(
703
729
  { context }: PagesDeps,
704
- input: { pageId: string; name: string; key?: string },
730
+ input: { pageId: string; name: string; key?: string; idempotencyKey?: string },
705
731
  ) {
706
732
  await assertStaff(context);
707
733
  const name = input.name.trim();
708
734
  if (!name) throw new Error("A name is required for the copy.");
735
+ const idempotencyKey = (input.idempotencyKey ?? crypto.randomUUID()).trim();
736
+ if (!UUID.test(idempotencyKey)) {
737
+ throw new Error(
738
+ "An idempotency key for a copy must be a UUID, so that a retry can be told " +
739
+ "apart from a second copy.",
740
+ );
741
+ }
709
742
 
710
743
  const { data: source, error: srcErr } = await context.supabase
711
744
  .from("pages")
@@ -722,55 +755,59 @@ export async function duplicatePageCore(
722
755
  const taken = ((existing ?? []) as Array<{ key: string }>).map((row) => row.key);
723
756
  const key = uniquePageKey(input.key?.trim() || name, taken);
724
757
 
758
+ // What the source holds and what the copy will hold are two different values.
759
+ // The digest binds the first; the second is the first with every block
760
+ // re-identified, so the two pages never share a block id.
725
761
  let blocks: BlockInstance[] = [];
762
+ let blocksDigest: string | null = null;
726
763
  if (source.current_version_id) {
727
- const { data: version } = await context.supabase
764
+ const { data: version, error: versionReadErr } = await context.supabase
728
765
  .from("page_versions")
729
766
  .select("blocks")
730
767
  .eq("id", source.current_version_id)
731
768
  .maybeSingle();
732
- blocks = duplicateBlockTree(blocksOf(version?.blocks));
769
+ if (versionReadErr) throw new Error(versionReadErr.message);
770
+ // A page pointing at a version that is not there is broken, and copying it
771
+ // as an empty page would invent content rather than report the breakage.
772
+ if (!version) throw new Error("Version not found");
773
+ blocksDigest = await digestJson(version.blocks as DigestValue);
774
+ blocks = duplicateBlockTree(blocksOf(version.blocks));
733
775
  }
734
776
 
735
777
  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}).`,
778
+ const copy = await publicationRpc(
779
+ context.supabase,
780
+ "cms_duplicate_page",
781
+ {
782
+ _source_page_id: input.pageId,
783
+ _expected_version_id: source.current_version_id ?? null,
784
+ _expected_blocks_digest: blocksDigest,
785
+ _idempotency_key: idempotencyKey,
786
+ _key: key,
787
+ _name: name,
788
+ _blocks: blocks as unknown,
789
+ // The description is composed in the function, from the names it read
790
+ // under the lock, so there is nothing to say here that it does not know.
791
+ _audit: {},
792
+ _actor_email: actorEmail(context),
770
793
  },
771
- });
794
+ "Could not copy the page",
795
+ CONTRACT_V3_MIGRATIONS,
796
+ );
772
797
 
773
- return { id: page.id as string, key, name, status: DEFAULT_PAGE_STATUS };
798
+ const id = typeof copy?.pageId === "string" ? copy.pageId : "";
799
+ if (!id) throw new Error("The database made no copy of this page.");
800
+ return {
801
+ id,
802
+ key: String(copy.key ?? key),
803
+ name: String(copy.name ?? name),
804
+ status: String(copy.status ?? DEFAULT_PAGE_STATUS),
805
+ /**
806
+ * True when this key had already been spent, so the copy being returned is
807
+ * the one the first call made rather than a new one.
808
+ */
809
+ idempotent: Boolean(copy.idempotent),
810
+ };
774
811
  }
775
812
 
776
813
  export async function renamePageCore(
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { auditAs } from "./audit-core";
10
+ import { normalizeRoutePath, servedPath, type RoutePathPolicy } from "../site/route-index";
10
11
 
11
12
  /** Structural subset of a Supabase-shaped client used by these cores. */
12
13
  export type RedirectClient = {
@@ -22,7 +23,7 @@ export type RedirectRow = {
22
23
  };
23
24
 
24
25
  export type PathResolution =
25
- | { kind: "page"; pageKey: string }
26
+ | { kind: "page"; pageKey: string; locale: string }
26
27
  | { kind: "redirect"; to: string; status: number }
27
28
  | { kind: "notfound" };
28
29
 
@@ -106,6 +107,23 @@ export type PageSlugRegistry = Record<string, { slug: Record<string, string | un
106
107
 
107
108
  /**
108
109
  * Resolve a URL path to a CMS page key, a redirect, or nothing.
110
+ *
111
+ * `locale` is the locale the host worked out from the domain; a locale prefix
112
+ * in the path overrides it, because the address the visitor typed is the
113
+ * address they asked for. The answer carries the locale it resolved under, so
114
+ * the caller renders the page in the language the URL named rather than the one
115
+ * the host guessed.
116
+ *
117
+ * Slugs are compared as addresses, not as strings: both sides go through
118
+ * `servedPath` and `normalizeRoutePath`, the same pair the site builds its own
119
+ * links with. Before this, every locale's slugs went into one flat map and the
120
+ * first one entered won, so a Swedish and an English page sharing a slug were
121
+ * one page and the loser was unreachable — and `/Om-Oss` reached nothing at all.
122
+ *
123
+ * Within the resolved locale a slug answers both prefixed and bare, because a
124
+ * locale served from its own domain has no prefix to strip. Across locales
125
+ * nothing is shared, which is the whole point.
126
+ *
109
127
  * Slug overrides from `page_seo` win over the static registry; builder-created
110
128
  * pages resolve by bare key through the `lookupPageByKey` callback.
111
129
  */
@@ -115,15 +133,31 @@ export async function resolvePathCore(
115
133
  client: RedirectClient;
116
134
  pages: PageSlugRegistry;
117
135
  locales: readonly string[];
136
+ locale: string;
137
+ defaultLocale: string;
118
138
  lookupPageByKey: (key: string) => Promise<string | null>;
139
+ policy?: RoutePathPolicy;
119
140
  },
120
141
  ): Promise<PathResolution> {
121
- const target = normalizePath(path);
142
+ const policy: RoutePathPolicy = { locales: deps.locales, ...(deps.policy ?? {}) };
143
+ const target = normalizeRoutePath(path, policy);
144
+
145
+ // The prefix in the URL beats the locale the host derived from the domain.
146
+ const first = target.split("/")[1] ?? "";
147
+ const prefixed = deps.locales.find((l) => l.toLowerCase() === first.toLowerCase());
148
+ const locale =
149
+ prefixed ?? (deps.locales.includes(deps.locale) ? deps.locale : deps.defaultLocale);
150
+
122
151
  const bySlug = new Map<string, string>();
123
152
  const put = (slug: string | undefined, key: string) => {
124
153
  if (slug === undefined || slug === null) return;
125
- const p = slug === "" ? "/" : `/${slug.replace(/^\/+|\/+$/g, "")}`;
126
- if (!bySlug.has(p)) bySlug.set(p, key);
154
+ for (const candidate of [
155
+ servedPath(slug, locale, deps.defaultLocale),
156
+ servedPath(slug, deps.defaultLocale, deps.defaultLocale),
157
+ ]) {
158
+ const p = normalizeRoutePath(candidate, policy);
159
+ if (!bySlug.has(p)) bySlug.set(p, key);
160
+ }
127
161
  };
128
162
 
129
163
  const { data: seoRows } = await deps.client.from("page_seo").select("page_key, slugs");
@@ -131,27 +165,35 @@ export async function resolvePathCore(
131
165
  for (const row of (seoRows ?? []) as { page_key: string; slugs: unknown }[]) {
132
166
  overrides.set(row.page_key, (row.slugs as Record<string, string>) ?? {});
133
167
  }
168
+ // Only this locale's slugs are consulted. A page with none falls back to its
169
+ // default-locale slug, exactly as path building does, so a page nobody has
170
+ // translated stays reachable under the prefix.
134
171
  for (const [key, slugs] of overrides) {
135
- for (const locale of deps.locales) put(slugs[locale], key);
172
+ put(slugs[locale] ?? slugs[deps.defaultLocale], key);
136
173
  }
137
174
  for (const key of Object.keys(deps.pages)) {
138
175
  if (overrides.has(key)) continue;
139
- for (const locale of deps.locales) put(deps.pages[key].slug[locale], key);
176
+ const slugs = deps.pages[key].slug;
177
+ put(slugs[locale] ?? slugs[deps.defaultLocale], key);
140
178
  }
141
179
 
142
180
  let pageKey = bySlug.get(target);
143
181
  if (!pageKey) {
144
- const candidate = target.replace(/^\//, "");
182
+ const rest = prefixed ? target.slice(`/${prefixed}`.length) || "/" : target;
183
+ const candidate = rest.replace(/^\//, "");
145
184
  if (/^[a-z0-9_-]+$/.test(candidate)) {
146
185
  pageKey = (await deps.lookupPageByKey(candidate)) ?? undefined;
147
186
  }
148
187
  }
149
- if (pageKey) return { kind: "page", pageKey };
188
+ if (pageKey) return { kind: "page", pageKey, locale };
150
189
 
190
+ // Redirect rows are literal strings an editor typed, matched as they were
191
+ // stored. Only page addresses go through the route identity; re-spelling a
192
+ // stored row here would look it up at an address the table does not hold.
151
193
  const { data: hit } = await deps.client
152
194
  .from("redirects")
153
195
  .select("to_path, status")
154
- .eq("from_path", target)
196
+ .eq("from_path", normalizePath(path))
155
197
  .maybeSingle();
156
198
  if (hit) return { kind: "redirect", to: hit.to_path, status: hit.status ?? 301 };
157
199
 
@@ -2,73 +2,29 @@
2
2
  * Canonical schema baseline shipped with this package version.
3
3
  *
4
4
  * `SCHEMA_MIGRATIONS` is the ordered list of migration files under
5
- * `@bygghammar/cms-platform/migrations`. A site compares it to the migrations
6
- * its database has actually applied so drift is visible in the backoffice
7
- * version screen instead of surfacing as a runtime error months later.
5
+ * `@bygghammar/cms-platform/migrations`, read from the generated manifest. A
6
+ * site compares it to the migrations its database has actually applied so
7
+ * drift is visible in the backoffice version screen instead of surfacing as a
8
+ * runtime error months later.
9
+ *
10
+ * The comparison is by name, because that is what migration tooling records.
11
+ * The manifest also carries each file's digest, and `platform/contract` exposes
12
+ * it as `migrationDigest`, for the separate question of whether a file by the
13
+ * right name holds the right bytes.
8
14
  */
9
15
 
10
- /** Every canonical migration filename, oldest first. */
11
- export const SCHEMA_MIGRATIONS: readonly string[] = [
12
- "20260728113741_f9744b19-078b-46e5-b00f-31c84aaf71ab.sql",
13
- "20260728113757_4156a886-21fb-4a1f-9c12-acb7bb145102.sql",
14
- "20260728113823_6e0237e1-99e3-4e1a-8a68-804b7bcdbbb8.sql",
15
- "20260728115305_506d92b4-129f-4d2b-961f-cf76e592677e.sql",
16
- "20260728115320_717d4447-f433-4a1d-89d2-fc8f028d4ac6.sql",
17
- "20260728115347_c77f5a56-19db-4117-bb60-9ad692b817ab.sql",
18
- "20260728132052_b692dcfe-a944-4a19-afd0-187f7600feb8.sql",
19
- "20260728183318_05b05a47-ba05-4a66-ab8f-69f2688fea84.sql",
20
- "20260728193035_84b68d27-c3f3-4516-a3a9-15fc6f476163.sql",
21
- "20260728193707_8438ee2d-d589-4048-86e2-464fd209af80.sql",
22
- "20260729055312_08862d7e-0279-4d4f-8709-a59b9f068c55.sql",
23
- "20260729055348_418b7e36-4877-4304-831f-290ca73e56f3.sql",
24
- "20260729055358_20b467e2-4efd-42c1-bd34-773ed6b77fc5.sql",
25
- "20260729061039_306859eb-a38c-44ef-af1a-6c02a8fd84db.sql",
26
- "20260730065600_54c25861-3355-4600-a72f-3d67b457ecb1.sql",
27
- "20260730071745_d4904314-15ff-4c58-ac66-85e7371e04b5.sql",
28
- "20260730071914_48163aa3-830e-4dcc-bdd4-c35be282ab87.sql",
29
- "20260730085949_bcfc3270-ea80-4617-86a3-2555ed34a280.sql",
30
- "20260730095256_c977c30e-b285-4041-8804-c0bd72b6d9b3.sql",
31
- "20260730100057_e341b887-eb0e-4a74-850e-0ee5ffb70d7a.sql",
32
- "20260730103703_d5c4e1b7-1e2c-4dd0-be4e-185c159ade69.sql",
33
- "20260730105635_3557b7da-919e-48db-8c92-8221d68d3cf9.sql",
34
- "20260730120308_12ff8cfb-43f9-4070-abde-61531ad3b75f.sql",
35
- "20260730125024_1baf8926-12a2-48f4-8edc-7309941309e9.sql",
36
- "20260730173510_feb974d1-510e-4f52-9e20-5fd0582d8952.sql",
37
- "20260730175409_9f60743f-f5e1-418c-a700-c91fb3704db8.sql",
38
- "20260730181224_1c52ff16-c7a2-4ef1-a0a8-e493c2c9106f.sql",
39
- "20260730183129_933adb75-f985-4d82-ad5d-213b69fd42e5.sql",
40
- "20260730183918_7bc7a827-2100-49f1-bc6e-24509249350b.sql",
41
- "20260730202628_7ceade5e-644c-4bc2-91b8-594133329f31.sql",
42
- "20260730211056_57f42049-00b4-473b-a2cd-65a13570b8d2.sql",
43
- "20260730214340_98af2e55-9031-4c11-9659-c47a37a8d6b7.sql",
44
- "20260731091630_8a6f28ce-5833-4901-ab17-27648cef32ec.sql",
45
- "20260731094716_4c68a1df-83ed-4ba8-8019-fa2fe2b9681e.sql",
46
- "20260731191655_fef97c3c-1397-47b4-8b56-afb020298e97.sql",
47
- "20260731192212_95772a2f-19a1-4013-b463-2eb161e96737.sql",
48
- "20260731192815_c490653e-1232-4ad9-a150-3c83d2b90f5e.sql",
49
- "20260731193135_7966e740-5e86-4b14-8e1d-5119069666ba.sql",
50
- "20260731193245_599790d0-ca5e-4b5d-a3e7-d69b2a79e1a3.sql",
51
- "20260731212959_a748cb0d-4dc7-45dc-be90-f6bacbfab26c.sql",
52
- "20260801003057_1195f4c1-fb54-42dc-9718-638a25e6a82e.sql",
53
- "20260801003300_fc591960-cb41-41cf-837d-2eef23b29ca9.sql",
54
- "20260802151445_a56b0023-eaf7-41e2-a7fb-d4beddfc587c.sql",
55
- "20260802202621_d661af8a-8513-4b54-b375-f963ae824c8f.sql",
56
- "20260802202702_c30cd837-744c-4641-85f1-edd3a7ebbcb6.sql",
57
- "20260802202720_4418d18d-c851-42dc-9345-78fc330128ae.sql",
58
- "20260803132514_78ba1f53-c85b-4457-96b1-7d49c5c8b494.sql",
59
- "20260803181201_28c8aa09-baa8-431c-8338-90b1af9c8172.sql",
60
- "20260803201518_6e8a6028-b70f-4a7d-8bc9-95c86304e7ae.sql",
61
- "20260803201535_32a9a213-cb92-4035-b8cd-8211ca34af68.sql",
62
- "20260811103505_29df753d-14fa-4a05-8fea-f4207785fba3.sql",
63
- "20260811130318_7cb95e95-9baa-40af-8f3f-de1d4e09cec4.sql",
64
- "20260818040722_1bdf1b8f-7fb0-4ae8-8ecf-73f1fadff3c2.sql",
65
- "20260818040808_69a5e832-57e8-4aa7-aff8-7fd7777faa7c.sql",
66
- "20260818040839_e6523163-bbf5-4a39-9233-91759059569a.sql",
67
- "20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql",
68
- "20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql",
69
- "20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql",
70
- "20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql",
71
- ];
16
+ import { MIGRATION_NAMES } from "../platform/migration-manifest";
17
+
18
+ /**
19
+ * Every canonical migration filename, oldest first.
20
+ *
21
+ * Derived from the generated catalog, which is written from the `migrations/`
22
+ * directory itself. This module and `platform/contract` used to hold separate
23
+ * copies of the same sixty names; a site that trusts one and displays the
24
+ * other must not be able to see two answers, and equality by construction is a
25
+ * stronger guarantee than a test noticing they had already diverged.
26
+ */
27
+ export const SCHEMA_MIGRATIONS: readonly string[] = MIGRATION_NAMES;
72
28
 
73
29
  /** Schema baseline version = the newest canonical migration. */
74
30
  export const SCHEMA_BASELINE = SCHEMA_MIGRATIONS[SCHEMA_MIGRATIONS.length - 1] ?? "";