@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.
@@ -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;
@@ -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
- /** Bump when the database schema changes in a way that breaks old archives. */
14
- export const SCHEMA_VERSION = 2;
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;