@bygghammar/cms-platform 0.91.1 → 0.92.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +115 -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 +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
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- The fingerprint of everything either publication reader looks at.
|
|
2
|
+
--
|
|
3
|
+
-- Read by scripts/cms-review.mjs before an upgrade and digested there, in
|
|
4
|
+
-- TypeScript, because a reviewer stands at a database that does not have
|
|
5
|
+
-- contract v3 yet and so has no cms_digest() to call. The migration recomputes
|
|
6
|
+
-- the same object with the same expression, digests it with cms_digest(), and
|
|
7
|
+
-- refuses if the two differ -- so this file and the block in
|
|
8
|
+
-- 20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24.sql must stay one query.
|
|
9
|
+
-- ops/migration-smoke.mjs is what holds them together: it builds an approval
|
|
10
|
+
-- from this text and applies the real migration against it, and a drift on
|
|
11
|
+
-- either side comes back as "the pages changed after this review was approved".
|
|
12
|
+
--
|
|
13
|
+
-- Soft-deleted pages and inactive publication rows are in here on purpose.
|
|
14
|
+
-- Deleting a page and deactivating a row both change what the site serves, and
|
|
15
|
+
-- both would otherwise slip through a fingerprint that only looked at what is
|
|
16
|
+
-- live.
|
|
17
|
+
SELECT jsonb_build_object(
|
|
18
|
+
'pageCount', count(*),
|
|
19
|
+
'pages', coalesce(jsonb_agg(entry ORDER BY page_id), '[]'::jsonb)
|
|
20
|
+
)::text
|
|
21
|
+
FROM (
|
|
22
|
+
SELECT pg.id AS page_id,
|
|
23
|
+
jsonb_build_object(
|
|
24
|
+
'id', pg.id::text,
|
|
25
|
+
'key', pg.key,
|
|
26
|
+
'status', pg.status,
|
|
27
|
+
'currentVersionId', pg.current_version_id::text,
|
|
28
|
+
'deletedAt', to_char(pg.deleted_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
|
|
29
|
+
'publications', (
|
|
30
|
+
SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
31
|
+
'id', pp.id::text,
|
|
32
|
+
'versionId', pp.version_id::text,
|
|
33
|
+
'publishAt', to_char(pp.publish_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
|
|
34
|
+
'unpublishAt', to_char(pp.unpublish_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
|
|
35
|
+
'publishedAt', to_char(pp.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
|
|
36
|
+
'active', pp.is_active
|
|
37
|
+
) ORDER BY pp.id), '[]'::jsonb)
|
|
38
|
+
FROM public.page_publications pp
|
|
39
|
+
WHERE pp.page_id = pg.id
|
|
40
|
+
)
|
|
41
|
+
) AS entry
|
|
42
|
+
FROM public.pages pg
|
|
43
|
+
) snapshot;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
-- What the classifier needs to see, and nothing else.
|
|
2
|
+
--
|
|
3
|
+
-- Active publication rows for live pages, plus the owning page of every version
|
|
4
|
+
-- either reader could reach. classifyPage() in src/platform/publication-review.ts
|
|
5
|
+
-- reads exactly this shape; the migration reads the same rows through its own
|
|
6
|
+
-- CTE chain. Restricted to referenced versions rather than every version ever
|
|
7
|
+
-- written, because the only question asked of the map is "does this id exist,
|
|
8
|
+
-- and is it this page's" -- an id that is missing from a restricted map is
|
|
9
|
+
-- missing in the same way it would be from a complete one.
|
|
10
|
+
--
|
|
11
|
+
-- Ordered by key so a scan of an unchanged database reads the same twice.
|
|
12
|
+
SELECT jsonb_build_object(
|
|
13
|
+
'pages', (
|
|
14
|
+
SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
15
|
+
'pageId', p.id::text,
|
|
16
|
+
'pageKey', p.key,
|
|
17
|
+
'status', p.status,
|
|
18
|
+
'currentVersionId', p.current_version_id::text,
|
|
19
|
+
'publications', (
|
|
20
|
+
SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
21
|
+
'id', pp.id::text,
|
|
22
|
+
'versionId', pp.version_id::text,
|
|
23
|
+
'publishAt', to_char(pp.publish_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
|
|
24
|
+
'unpublishAt', to_char(pp.unpublish_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
|
|
25
|
+
'publishedAt', to_char(pp.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')
|
|
26
|
+
) ORDER BY pp.id), '[]'::jsonb)
|
|
27
|
+
FROM public.page_publications pp
|
|
28
|
+
WHERE pp.page_id = p.id AND pp.is_active
|
|
29
|
+
)
|
|
30
|
+
) ORDER BY p.key), '[]'::jsonb)
|
|
31
|
+
FROM public.pages p
|
|
32
|
+
WHERE p.deleted_at IS NULL
|
|
33
|
+
),
|
|
34
|
+
'versionOwners', (
|
|
35
|
+
SELECT coalesce(jsonb_object_agg(v.id::text, v.page_id::text), '{}'::jsonb)
|
|
36
|
+
FROM public.page_versions v
|
|
37
|
+
WHERE v.id IN (
|
|
38
|
+
SELECT current_version_id FROM public.pages WHERE current_version_id IS NOT NULL
|
|
39
|
+
UNION
|
|
40
|
+
SELECT version_id FROM public.page_publications WHERE version_id IS NOT NULL)
|
|
41
|
+
),
|
|
42
|
+
'databaseIdentity', (
|
|
43
|
+
SELECT current_database() || '/' || d.oid::text
|
|
44
|
+
FROM pg_database d WHERE d.datname = current_database()
|
|
45
|
+
),
|
|
46
|
+
-- The cluster's own permanent name, as initdb assigned it. A second
|
|
47
|
+
-- identity rather than a replacement for the one above, because the
|
|
48
|
+
-- two answer different questions and an approval is checked against
|
|
49
|
+
-- both. This one survives the database being renamed and does not
|
|
50
|
+
-- survive the data directory being thrown away and recreated; the one
|
|
51
|
+
-- above tells two databases in one cluster apart, which is how a
|
|
52
|
+
-- staging and a production database on the same host differ. Readable
|
|
53
|
+
-- by any role that can connect: it is in the control file, not in a
|
|
54
|
+
-- catalog anybody needs rights on.
|
|
55
|
+
'clusterIdentity', (SELECT system_identifier::text FROM pg_control_system())
|
|
56
|
+
)::text;
|
package/src/backup/datasets.ts
CHANGED
|
@@ -8,10 +8,85 @@
|
|
|
8
8
|
/**
|
|
9
9
|
* Archive layout version. Format 2 adds accounts, email configuration,
|
|
10
10
|
* verified media and secret sanitising. Format 1 archives are rejected.
|
|
11
|
+
*
|
|
12
|
+
* This is the *container* version — how the zip is arranged and what may live
|
|
13
|
+
* in it. Contract v3 changes nothing about that, which is why this number did
|
|
14
|
+
* not move and the one below it did.
|
|
11
15
|
*/
|
|
12
16
|
export const ARCHIVE_FORMAT_VERSION = 2;
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The database schema an archive written today describes.
|
|
20
|
+
*
|
|
21
|
+
* Bump when the database schema changes in a way that breaks old archives.
|
|
22
|
+
* Contract v3 took it to 3: pages carry a status, publications carry a
|
|
23
|
+
* signature, and the three review tables did not exist at 2.
|
|
24
|
+
*/
|
|
25
|
+
export const SCHEMA_VERSION = 3;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What a reader does with each schema version it may meet.
|
|
29
|
+
*
|
|
30
|
+
* Not `version <= SCHEMA_VERSION`, and not a range. Every version answered
|
|
31
|
+
* here has a branch that says what reading it actually means, because the
|
|
32
|
+
* question is never "is this number small enough" but "does this build know
|
|
33
|
+
* what the rows in front of it describe". A comparison answers the first
|
|
34
|
+
* question while appearing to answer the second, and the day a schema 4 exists
|
|
35
|
+
* it would answer both wrongly.
|
|
36
|
+
*
|
|
37
|
+
* Both readers — the backoffice import in `server/backup-core.ts` and the
|
|
38
|
+
* standalone PostgreSQL tool in `restore-core.ts` — go through this one
|
|
39
|
+
* function, so there is no version either of them accepts alone.
|
|
40
|
+
*/
|
|
41
|
+
export type SchemaReading =
|
|
42
|
+
| {
|
|
43
|
+
readonly supported: true;
|
|
44
|
+
readonly version: number;
|
|
45
|
+
/** False for schema 2: it predates the review tables entirely. */
|
|
46
|
+
readonly carriesReviewHistory: boolean;
|
|
47
|
+
}
|
|
48
|
+
| { readonly supported: false; readonly version: number; readonly reason: string };
|
|
49
|
+
|
|
50
|
+
export function readSchemaVersion(version: number): SchemaReading {
|
|
51
|
+
switch (version) {
|
|
52
|
+
// Written by 0.90 and 0.91. It has no review tables, no page status and no
|
|
53
|
+
// publication signature — but everything it *does* say still means what it
|
|
54
|
+
// said, and what it leaves out is recoverable by scanning the restored
|
|
55
|
+
// content. So it is read, and a reconciliation run records the scan.
|
|
56
|
+
case 2:
|
|
57
|
+
return { supported: true, version, carriesReviewHistory: false };
|
|
58
|
+
|
|
59
|
+
// Written by 0.92 and later. Carries the review history with it.
|
|
60
|
+
case 3:
|
|
61
|
+
return { supported: true, version, carriesReviewHistory: true };
|
|
62
|
+
|
|
63
|
+
default:
|
|
64
|
+
return {
|
|
65
|
+
supported: false,
|
|
66
|
+
version,
|
|
67
|
+
reason:
|
|
68
|
+
version > SCHEMA_VERSION
|
|
69
|
+
? `it was made for a newer database (version ${version}); this site runs version ${SCHEMA_VERSION}`
|
|
70
|
+
: `version ${version} is older than any schema this build can read`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The schema versions `readSchemaVersion` accepts. Derived, never hand-listed. */
|
|
76
|
+
export const SUPPORTED_SCHEMA_VERSIONS: readonly number[] = Object.freeze(
|
|
77
|
+
[0, 1, 2, 3, 4, 5].filter((v) => readSchemaVersion(v).supported),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Prefix of every dataset name the migration approval machinery would use.
|
|
82
|
+
*
|
|
83
|
+
* Nothing emits one and nothing reads one: approval and gate records live on
|
|
84
|
+
* the VPS filesystem, root-owned and mode 0600, and are spent by the one
|
|
85
|
+
* migration they name. The constant exists so a test can state the rule
|
|
86
|
+
* positively — no archive contains a dataset under this prefix — rather than
|
|
87
|
+
* relying on nobody ever adding one.
|
|
88
|
+
*/
|
|
89
|
+
export const APPROVAL_NAMESPACE = "lovable_migration_approval";
|
|
15
90
|
|
|
16
91
|
export {
|
|
17
92
|
ALLOWED_MEDIA_EXTENSIONS,
|
|
@@ -48,6 +123,28 @@ export type TableSpec = {
|
|
|
48
123
|
* whose parent row is absent fails analysis before anything is restored.
|
|
49
124
|
*/
|
|
50
125
|
references?: { column: string; table: string; pk?: string }[];
|
|
126
|
+
/**
|
|
127
|
+
* Columns the database computes for itself. They are never written, on
|
|
128
|
+
* either pass — PostgreSQL refuses an INSERT that names a generated column,
|
|
129
|
+
* and it is right to: the whole point of `inputs_digest` being GENERATED is
|
|
130
|
+
* that it cannot disagree with the `inputs` it claims to cover, and an
|
|
131
|
+
* archive that could supply its own would hand that guarantee to whoever
|
|
132
|
+
* wrote the file.
|
|
133
|
+
*/
|
|
134
|
+
generated?: string[];
|
|
135
|
+
/**
|
|
136
|
+
* uuid columns naming a person, paired with the column that carries the same
|
|
137
|
+
* person as text.
|
|
138
|
+
*
|
|
139
|
+
* The email is the identity that survives a restore; the uuid is a pointer
|
|
140
|
+
* into whichever accounts this database happens to have. On the way in the
|
|
141
|
+
* pointer is kept only where the account exists here, and dropped where it
|
|
142
|
+
* does not — never deferred and written back, because the tables that carry
|
|
143
|
+
* these columns are append-only and would refuse the second write. The
|
|
144
|
+
* addresses that lost their pointer are reported, so relinking is something
|
|
145
|
+
* an operator does deliberately rather than something a restore guesses.
|
|
146
|
+
*/
|
|
147
|
+
actors?: { column: string; email: string }[];
|
|
51
148
|
};
|
|
52
149
|
|
|
53
150
|
/** Business keys used to detect that an archive comes from another database. */
|
|
@@ -95,6 +192,53 @@ export const DATASET_GROUPS: DatasetGroup[] = [
|
|
|
95
192
|
{ table: "block_templates", pk: "id" },
|
|
96
193
|
],
|
|
97
194
|
},
|
|
195
|
+
{
|
|
196
|
+
id: "review",
|
|
197
|
+
label: "Publication review history",
|
|
198
|
+
description:
|
|
199
|
+
"The scans that decided what each page publishes, the pages they looked at, and the decisions people took about them. Append-only: restored history is added, never overwritten or removed.",
|
|
200
|
+
// Append-only in the database, so append-only here. The three tables have
|
|
201
|
+
// BEFORE UPDATE and BEFORE DELETE triggers that raise unconditionally, and
|
|
202
|
+
// this flag is what keeps the restore on the one path they permit: insert,
|
|
203
|
+
// and do nothing where a row already exists. A restore that could rewrite
|
|
204
|
+
// this history would be a way of laundering an unreviewed publication
|
|
205
|
+
// state through a backup file, which is the one thing the history exists
|
|
206
|
+
// to make impossible.
|
|
207
|
+
insertOnly: true,
|
|
208
|
+
tables: [
|
|
209
|
+
{
|
|
210
|
+
table: "cms_publication_review_runs",
|
|
211
|
+
pk: "id",
|
|
212
|
+
actors: [
|
|
213
|
+
{ column: "classified_by", email: "classified_by_email" },
|
|
214
|
+
{ column: "approved_by", email: "approved_by_email" },
|
|
215
|
+
{ column: "applied_by", email: "applied_by_email" },
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
table: "cms_publication_review_items",
|
|
220
|
+
pk: "id",
|
|
221
|
+
// (run_id, page_id) is the business key: one item per page per run.
|
|
222
|
+
// Conflicting on the uuid instead would let the same scan's view of a
|
|
223
|
+
// page arrive twice under two ids.
|
|
224
|
+
conflict: "run_id,page_id",
|
|
225
|
+
requires: ["pages", "cms_publication_review_runs"],
|
|
226
|
+
generated: ["inputs_digest"],
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
table: "cms_publication_review_decisions",
|
|
230
|
+
pk: "id",
|
|
231
|
+
// An item is resolved at most once and for good, so item_id is the
|
|
232
|
+
// conflict target: an archived decision either inserts, because the
|
|
233
|
+
// item had none, or is refused. There is no upsert that supersedes a
|
|
234
|
+
// local decision, and `reviewConflicts` reports the collision rather
|
|
235
|
+
// than letting DO NOTHING swallow it.
|
|
236
|
+
conflict: "item_id",
|
|
237
|
+
requires: ["cms_publication_review_items"],
|
|
238
|
+
actors: [{ column: "resolved_by", email: "resolved_by_email" }],
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
},
|
|
98
242
|
{
|
|
99
243
|
id: "seo",
|
|
100
244
|
label: "SEO & routing",
|
|
@@ -209,6 +353,9 @@ export const IMPORT_ORDER: string[] = [
|
|
|
209
353
|
"pages",
|
|
210
354
|
"page_versions",
|
|
211
355
|
"page_publications",
|
|
356
|
+
"cms_publication_review_runs",
|
|
357
|
+
"cms_publication_review_items",
|
|
358
|
+
"cms_publication_review_decisions",
|
|
212
359
|
"global_sections",
|
|
213
360
|
"global_section_versions",
|
|
214
361
|
"content_sections",
|
|
@@ -232,6 +379,60 @@ export const IMPORT_ORDER: string[] = [
|
|
|
232
379
|
"email_logs",
|
|
233
380
|
];
|
|
234
381
|
|
|
382
|
+
/**
|
|
383
|
+
* The order Replace mode empties tables in: the exact reverse of IMPORT_ORDER.
|
|
384
|
+
*
|
|
385
|
+
* Written out rather than computed, and a test asserts the two are reverses of
|
|
386
|
+
* each other. `IMPORT_ORDER.slice().reverse()` would always be the reverse of
|
|
387
|
+
* whatever IMPORT_ORDER happened to say, including after somebody moved a
|
|
388
|
+
* table into the wrong place — it agrees by construction and so proves
|
|
389
|
+
* nothing. Two lists that must mirror each other disagree loudly the moment
|
|
390
|
+
* one of them is edited alone, which is the only version of this that is worth
|
|
391
|
+
* having.
|
|
392
|
+
*
|
|
393
|
+
* Order is not a preference here. `cms_publication_review_items.page_id` and
|
|
394
|
+
* `.run_id` are ON DELETE RESTRICT, so clearing pages before items raises
|
|
395
|
+
* instead of quietly cascading the review history away. That refusal is the
|
|
396
|
+
* intended behaviour: it turns an ordering mistake into a failure somebody
|
|
397
|
+
* reads, rather than into evidence nobody notices is gone.
|
|
398
|
+
*
|
|
399
|
+
* Insert-only tables are in the list and are never cleared — `buildClearPlan`
|
|
400
|
+
* leaves them out of every plan it builds. They are listed here because the
|
|
401
|
+
* list is a statement about the dependency graph, and a graph with holes in it
|
|
402
|
+
* cannot be checked.
|
|
403
|
+
*/
|
|
404
|
+
export const CLEAR_ORDER: string[] = [
|
|
405
|
+
"email_logs",
|
|
406
|
+
"audit_logs",
|
|
407
|
+
"media_trash",
|
|
408
|
+
"media_focus",
|
|
409
|
+
"media_meta",
|
|
410
|
+
"form_delivery_attempts",
|
|
411
|
+
"form_partials",
|
|
412
|
+
"form_submissions",
|
|
413
|
+
"form_settings",
|
|
414
|
+
"form_versions",
|
|
415
|
+
"forms",
|
|
416
|
+
"email_settings",
|
|
417
|
+
"email_providers",
|
|
418
|
+
"site_injections",
|
|
419
|
+
"locale_domains",
|
|
420
|
+
"redirects",
|
|
421
|
+
"page_seo",
|
|
422
|
+
"block_templates",
|
|
423
|
+
"content_sections",
|
|
424
|
+
"global_section_versions",
|
|
425
|
+
"global_sections",
|
|
426
|
+
"cms_publication_review_decisions",
|
|
427
|
+
"cms_publication_review_items",
|
|
428
|
+
"cms_publication_review_runs",
|
|
429
|
+
"page_publications",
|
|
430
|
+
"page_versions",
|
|
431
|
+
"pages",
|
|
432
|
+
"user_roles",
|
|
433
|
+
"app_users",
|
|
434
|
+
];
|
|
435
|
+
|
|
235
436
|
export type ArchiveManifest = {
|
|
236
437
|
format: number;
|
|
237
438
|
schemaVersion: number;
|