@bygghammar/cms-platform 0.91.1 → 0.92.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +125 -0
- package/README.md +20 -9
- package/docs/NEW-SITE.md +29 -14
- package/docs/PAGES.md +79 -21
- package/docs/PUBLICATION-REVIEW.md +298 -0
- package/migrations/20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24.sql +3298 -0
- package/package.json +8 -2
- package/scripts/cms-review.mjs +832 -0
- package/scripts/review-fingerprint.sql +43 -0
- package/scripts/review-scan.sql +56 -0
- package/src/backup/datasets.ts +203 -2
- package/src/backup/restore-core.ts +382 -11
- package/src/platform/contract.ts +82 -60
- package/src/platform/digest.ts +203 -0
- package/src/platform/migration-manifest.ts +310 -0
- package/src/platform/publication-review.ts +413 -0
- package/src/server/backup-core.ts +291 -18
- package/src/server/page-seo-core.ts +56 -10
- package/src/server/pages-core.ts +90 -53
- package/src/server/redirects-core.ts +51 -9
- package/src/server/schema-drift.ts +21 -65
- package/src/site/route-index.ts +641 -0
- package/src/site/routes.ts +2 -8
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
|
|
|
@@ -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
|
|
379
|
-
*
|
|
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}:
|
|
423
|
-
`${
|
|
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
|
-
|
|
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
|
|
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}).`,
|
|
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
|
-
|
|
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
|
|
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
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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",
|
|
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
|
|
6
|
-
* its database has actually applied so
|
|
7
|
-
* version screen instead of surfacing as a
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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] ?? "";
|