@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.
@@ -0,0 +1,3298 @@
1
+ -- Contract v3: telling apart the five things "it is deployed" used to mean ----
2
+ --
3
+ -- Contract v2 made publication rows the only thing the public reader consults,
4
+ -- and closed the writes that changed public visibility behind their back. It
5
+ -- left a larger question unanswered: when a release moves a site from one
6
+ -- contract version to the next, *what* has to be true before traffic may see
7
+ -- it, and who is allowed to say so.
8
+ --
9
+ -- Until now one sentence — "the deployment succeeded" — stood for five
10
+ -- separate facts that fail independently:
11
+ --
12
+ -- 1. the migration ran;
13
+ -- 2. the schema therefore offers a capability;
14
+ -- 3. somebody scanned the existing publication rows and understood what the
15
+ -- new reader would do differently to them;
16
+ -- 4. the decisions from that scan were applied;
17
+ -- 5. the routes were activated, the database was declared ready, and only
18
+ -- then was traffic released.
19
+ --
20
+ -- Collapsing them is what turns a green deploy into a site that answers 200
21
+ -- with last month's homepage. This migration gives each of the five its own
22
+ -- record, and makes the later ones refuse to be written unless the earlier
23
+ -- ones already are. Nothing here publishes, unpublishes or edits a page: it
24
+ -- adds the ledger the release process reads and writes, and the two helpers
25
+ -- everything else in contract v3 is built on.
26
+ --
27
+ -- Nothing in this file depends on the hosted PostgREST stack. The roles
28
+ -- `anon`, `authenticated` and `service_role` are granted to only where they
29
+ -- exist, exactly as the two v2 migrations do.
30
+
31
+ -- 1. Which database is this? -------------------------------------------------
32
+ --
33
+ -- A review run, a route artifact and an activation record are all statements
34
+ -- about one specific database. Carried to another — staging's scan applied to
35
+ -- production, most obviously — they are worse than useless: they describe
36
+ -- pages that may not exist and publications that certainly do not.
37
+ --
38
+ -- So every artifact carries the identity of the database it was made from, and
39
+ -- every function that consumes one refuses a mismatch. The identity is a
40
+ -- random uuid written once, on the installation itself: not a hostname, not a
41
+ -- database name, not anything an operator might reasonably copy along with the
42
+ -- data. Restoring a backup into a new database keeps the old identity, which
43
+ -- is the correct answer — it is the same installation's content, and its
44
+ -- artifacts still describe it.
45
+ CREATE TABLE IF NOT EXISTS public.cms_installation (
46
+ -- One row, forever. The CHECK is what makes that true rather than hoped for.
47
+ singleton boolean NOT NULL PRIMARY KEY DEFAULT true CHECK (singleton),
48
+ installation_id uuid NOT NULL DEFAULT gen_random_uuid(),
49
+ created_at timestamptz NOT NULL DEFAULT now()
50
+ );
51
+
52
+ INSERT INTO public.cms_installation (singleton) VALUES (true)
53
+ ON CONFLICT (singleton) DO NOTHING;
54
+
55
+ CREATE OR REPLACE FUNCTION public.cms_installation_id()
56
+ RETURNS uuid
57
+ LANGUAGE sql
58
+ STABLE
59
+ SECURITY DEFINER
60
+ SET search_path = public, pg_temp
61
+ AS $$
62
+ SELECT installation_id FROM public.cms_installation WHERE singleton;
63
+ $$;
64
+
65
+ -- 2. What can this schema do? ------------------------------------------------
66
+ --
67
+ -- "The migration ran" and "the schema offers contract v3" are different
68
+ -- claims, and only the second one is useful to a caller. A migration ledger
69
+ -- records filenames; it cannot answer "may I call the v3 route functions yet"
70
+ -- without the reader knowing which filename introduced them. This table
71
+ -- answers that question directly, and is written by the migration that makes
72
+ -- it true — so it cannot drift from the schema it describes.
73
+ --
74
+ -- It is deliberately not a readiness record. A schema can offer a capability
75
+ -- while the data it operates on has not been reviewed yet; conflating the two
76
+ -- is the third and fourth of the five facts collapsing into one again.
77
+ CREATE TABLE IF NOT EXISTS public.cms_schema_capabilities (
78
+ capability text NOT NULL PRIMARY KEY,
79
+ version integer NOT NULL,
80
+ migration text NOT NULL,
81
+ recorded_at timestamptz NOT NULL DEFAULT now()
82
+ );
83
+
84
+ INSERT INTO public.cms_schema_capabilities (capability, version, migration)
85
+ VALUES ('platform-contract', 3, '20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24.sql')
86
+ ON CONFLICT (capability) DO UPDATE
87
+ SET version = EXCLUDED.version,
88
+ migration = EXCLUDED.migration,
89
+ recorded_at = now()
90
+ WHERE public.cms_schema_capabilities.version < EXCLUDED.version;
91
+
92
+ CREATE OR REPLACE FUNCTION public.cms_schema_capability(_capability text)
93
+ RETURNS integer
94
+ LANGUAGE sql
95
+ STABLE
96
+ SECURITY DEFINER
97
+ SET search_path = public, pg_temp
98
+ AS $$
99
+ SELECT version FROM public.cms_schema_capabilities WHERE capability = _capability;
100
+ $$;
101
+
102
+ -- 3. One canonical form, two implementations, one answer ---------------------
103
+ --
104
+ -- Contract v3 asks two independent programs to agree about identity: the
105
+ -- command-line tool decides whether the plan it holds still describes the
106
+ -- database, and the database decides whether the plan being applied is the one
107
+ -- that was reviewed. If they hash the same content differently, every plan is
108
+ -- either always stale or never stale, and the check is theatre.
109
+ --
110
+ -- `jsonb` cannot be used directly for this. Its text output orders object keys
111
+ -- by length and then by bytes, which no JSON library reproduces, and its
112
+ -- numbers keep whatever scale they were parsed with. So a canonical form is
113
+ -- defined here and mirrored in TypeScript, with a parity test that hashes the
114
+ -- same corpus on both sides and compares:
115
+ --
116
+ -- null, true, false literally, lowercase
117
+ -- strings the JSON string literal, minimal escapes
118
+ -- numbers integers only, no sign on zero, no exponent
119
+ -- arrays [a,b,c] — order preserved, no spaces
120
+ -- objects {"a":1,"b":2} — keys ascending by code point
121
+ --
122
+ -- The two restrictions are the point rather than a shortcut. Fractional
123
+ -- numbers have no single spelling that a numeric and an IEEE double both
124
+ -- produce — 0.1 is not 0.1 in either direction — so a digest containing one
125
+ -- could never be proved equal. And keys are held to ASCII, because PostgreSQL
126
+ -- sorts by byte and JavaScript sorts by UTF-16 code unit, and those two orders
127
+ -- disagree above the basic plane. Every field name contract v3 digests is a
128
+ -- literal in this repository; anything else is a bug, and is refused loudly
129
+ -- here rather than hashed to something the other side cannot reproduce.
130
+ CREATE OR REPLACE FUNCTION public.cms_canonical_json(_value jsonb)
131
+ RETURNS text
132
+ LANGUAGE plpgsql
133
+ IMMUTABLE
134
+ SET search_path = public, pg_temp
135
+ AS $$
136
+ DECLARE
137
+ _kind text;
138
+ _num numeric;
139
+ _body text;
140
+ BEGIN
141
+ IF _value IS NULL THEN
142
+ RAISE EXCEPTION 'cms_canonical_json: SQL NULL is not a JSON value'
143
+ USING ERRCODE = '22023';
144
+ END IF;
145
+
146
+ _kind := jsonb_typeof(_value);
147
+
148
+ IF _kind = 'null' THEN
149
+ RETURN 'null';
150
+ END IF;
151
+
152
+ IF _kind = 'boolean' THEN
153
+ RETURN CASE WHEN _value = 'true'::jsonb THEN 'true' ELSE 'false' END;
154
+ END IF;
155
+
156
+ IF _kind = 'string' THEN
157
+ -- A scalar `jsonb` string renders as its JSON literal, escaping exactly
158
+ -- the characters JSON requires: quote, backslash, and the control range as
159
+ -- \b \f \n \r \t or \uXXXX. `JSON.stringify` makes the same choices, and
160
+ -- both leave every other code point as literal UTF-8. The one input that
161
+ -- could differ, U+0000, cannot reach here: `jsonb` refuses to store it.
162
+ RETURN _value::text;
163
+ END IF;
164
+
165
+ IF _kind = 'number' THEN
166
+ _num := _value::text::numeric;
167
+ IF _num <> trunc(_num) THEN
168
+ RAISE EXCEPTION
169
+ 'cms_canonical_json: % is fractional, and digest inputs may only '
170
+ 'contain whole numbers.', _value::text
171
+ USING ERRCODE = '22023';
172
+ END IF;
173
+ -- Beyond this range a JavaScript number stops being able to name the
174
+ -- integer it holds, so the two sides could agree on the text and disagree
175
+ -- about the value.
176
+ IF _num > 9007199254740991::numeric OR _num < -9007199254740991::numeric THEN
177
+ RAISE EXCEPTION
178
+ 'cms_canonical_json: % is outside the range both sides can represent '
179
+ 'exactly (±9007199254740991).', _value::text
180
+ USING ERRCODE = '22023';
181
+ END IF;
182
+ RETURN trunc(_num)::bigint::text;
183
+ END IF;
184
+
185
+ IF _kind = 'array' THEN
186
+ SELECT coalesce(string_agg(public.cms_canonical_json(e.value), ',' ORDER BY e.ordinality), '')
187
+ INTO _body
188
+ FROM jsonb_array_elements(_value) WITH ORDINALITY AS e(value, ordinality);
189
+ RETURN '[' || _body || ']';
190
+ END IF;
191
+
192
+ PERFORM 1 FROM jsonb_object_keys(_value) AS k
193
+ WHERE k !~ '^[A-Za-z0-9_.:-]+$'
194
+ LIMIT 1;
195
+ IF FOUND THEN
196
+ RAISE EXCEPTION
197
+ 'cms_canonical_json: object keys in a digest input must be ASCII letters, '
198
+ 'digits, underscore, dot, colon or hyphen.'
199
+ USING ERRCODE = '22023';
200
+ END IF;
201
+
202
+ -- `COLLATE "C"` is byte order, which for the ASCII keys allowed above is
203
+ -- code-point order — the order the TypeScript side gets from comparing
204
+ -- strings with `<`.
205
+ SELECT coalesce(
206
+ string_agg(
207
+ to_jsonb(k)::text || ':' || public.cms_canonical_json(_value -> k),
208
+ ',' ORDER BY k COLLATE "C"),
209
+ '')
210
+ INTO _body
211
+ FROM jsonb_object_keys(_value) AS k;
212
+ RETURN '{' || _body || '}';
213
+ END;
214
+ $$;
215
+
216
+ CREATE OR REPLACE FUNCTION public.cms_digest(_value jsonb)
217
+ RETURNS text
218
+ LANGUAGE sql
219
+ IMMUTABLE
220
+ SET search_path = public, pg_temp
221
+ AS $$
222
+ SELECT encode(sha256(convert_to(public.cms_canonical_json(_value), 'UTF8')), 'hex');
223
+ $$;
224
+
225
+
226
+ -- 4. The publication review model ---------------------------------------------
227
+ --
228
+ -- Fact 3 of the five is "somebody scanned the existing publication rows and
229
+ -- understood what the new reader would do differently to them". The thing that
230
+ -- makes it a *fact* rather than a claim is that the scan, its findings and the
231
+ -- decisions taken on them are three separate records with different lifetimes,
232
+ -- and none of them can be quietly rewritten afterwards.
233
+ --
234
+ -- The disagreement being reviewed is not between v2 and v3. It is between the
235
+ -- reader that shipped in 0.90.0-m7.12 and the reader that shipped in 0.91.1:
236
+ --
237
+ -- the 0.90 reader took the newest active publication row whose window was
238
+ -- open — ORDER BY publish_at DESC LIMIT 1 — and, when there was none,
239
+ -- fell back to COALESCE(..., pages.current_version_id);
240
+ --
241
+ -- the 0.91 reader added a published_at DESC tie-break and dropped the
242
+ -- fallback entirely.
243
+ --
244
+ -- The dropped fallback is the whole risk. A page that 0.90 served from
245
+ -- current_version_id, with no publication row at all, is a page 0.91 serves
246
+ -- nothing for. It goes dark on upgrade, silently, and the site owner finds out
247
+ -- from a visitor. Every page in this database is therefore looked at once,
248
+ -- classified, and — where the two readers disagree — resolved by a person
249
+ -- before 0.92.0 is allowed to serve traffic.
250
+ --
251
+ -- Three tables, three lifetimes:
252
+ --
253
+ -- runs immutable except for a forward-only state machine
254
+ -- items write-once; one per live page per run
255
+ -- decisions append-only; at most one per item, forever
256
+ --
257
+ -- The alternative — mutable resolved_* columns on the items themselves — was
258
+ -- rejected for two reasons. A merge restore could then write over a decision
259
+ -- somebody had already taken, and the current state of an item would depend on
260
+ -- an update ordering that nothing in the database records.
261
+
262
+ CREATE TABLE IF NOT EXISTS public.cms_publication_review_runs (
263
+ id uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
264
+
265
+ -- The nonce is what ties this row to one approval by one person at one
266
+ -- moment. It arrives from outside the database and is never reused.
267
+ approval_nonce uuid NOT NULL UNIQUE,
268
+
269
+ -- Where the scan happened. All of it is recorded so that a run found in a
270
+ -- backup can be recognised as belonging to somewhere else, rather than
271
+ -- mistaken for this installation's own history.
272
+ site_key text NOT NULL,
273
+ environment text NOT NULL,
274
+
275
+ -- The database, not the cluster: current_database() and its oid, which is
276
+ -- the identity the migration checks its approval against.
277
+ --
278
+ -- An approval carries two identities and they answer different questions.
279
+ -- 'database_identity' names the cluster -- one number for every database in
280
+ -- it -- and is the most the deployment tooling can check from outside,
281
+ -- before it has a connection to ask anything sharper with.
282
+ -- 'review_database_identity' names the database, which is the question worth
283
+ -- asking once you are inside one: two databases in a cluster share a cluster
284
+ -- identifier and are not the same thing to review, and a staging review must
285
+ -- not be able to approve a production upgrade.
286
+ --
287
+ -- Only the sharper one is kept. The cluster identifier has done its work by
288
+ -- the time this row is written, and a second copy of a settled answer is
289
+ -- something to drift rather than something to check against. The column is
290
+ -- named for the setting it comes from so that a person reading this table in
291
+ -- a backup, months later, does not have to guess which of the two it holds.
292
+ review_database_identity text NOT NULL,
293
+
294
+ release_sha text NOT NULL,
295
+ package_version text NOT NULL,
296
+ migration_name text NOT NULL,
297
+ migration_digest text NOT NULL,
298
+
299
+ -- What the scan looked at, and what it produced. The fingerprint is
300
+ -- recomputed from the live rows before anything is applied; if the pages
301
+ -- moved between the scan and the apply, the numbers stop matching and the
302
+ -- migration refuses rather than applying decisions to data nobody reviewed.
303
+ input_fingerprint text NOT NULL,
304
+ manifest_digest text NOT NULL,
305
+ evaluated_at timestamptz NOT NULL,
306
+
307
+ -- expected_page_count is settled at scan time; scanned_page_count is written
308
+ -- when the run finishes. A run may only be relied upon when the two agree —
309
+ -- see cms_publication_review_is_complete below.
310
+ expected_page_count integer NOT NULL,
311
+ scanned_page_count integer,
312
+ counts jsonb,
313
+
314
+ state text NOT NULL
315
+ CHECK (state IN ('scanning', 'applying', 'applied', 'aborted')),
316
+ scan_completed_at timestamptz,
317
+
318
+ -- What caused this scan to happen. 'migration' is the upgrade itself, 'cli'
319
+ -- is somebody running cms-review, 'reconciliation' is a restore that brought
320
+ -- in content the review history says nothing about.
321
+ --
322
+ -- Worth recording rather than inferring, because the three mean different
323
+ -- things about the *approval*. A migration run carries a person's approval
324
+ -- of a specific plan. A reconciliation run carries no approval at all: it is
325
+ -- the database noticing that content arrived unclassified and classifying it
326
+ -- with the standing default. Reading "applied run, therefore reviewed" off a
327
+ -- reconciliation would be reading somebody's consent into a machine's
328
+ -- housekeeping.
329
+ source text NOT NULL DEFAULT 'migration'
330
+ CHECK (source IN ('migration', 'cli', 'reconciliation')),
331
+
332
+ -- Three people, or the same person three times, but always recorded
333
+ -- separately: who classified, who approved, who applied. The email is kept
334
+ -- alongside the id so the record still reads correctly after the account is
335
+ -- gone. Classification and approval both precede the insert, so both are
336
+ -- mandatory; application happens afterwards, so it is not.
337
+ classified_by uuid,
338
+ classified_by_email text NOT NULL,
339
+ classified_at timestamptz NOT NULL,
340
+ approved_by uuid,
341
+ approved_by_email text NOT NULL,
342
+ approved_at timestamptz NOT NULL,
343
+ applied_by uuid,
344
+ applied_by_email text,
345
+ applied_at timestamptz,
346
+
347
+ created_at timestamptz NOT NULL DEFAULT now(),
348
+
349
+ CONSTRAINT cms_publication_review_runs_counts_object
350
+ CHECK (counts IS NULL OR jsonb_typeof(counts) = 'object'),
351
+ CONSTRAINT cms_publication_review_runs_page_counts_sane
352
+ CHECK (expected_page_count >= 0 AND (scanned_page_count IS NULL OR scanned_page_count >= 0))
353
+ );
354
+
355
+ -- "Which run governs right now" has exactly one answer: the applied run with
356
+ -- the greatest applied_at. This index is what makes that lookup cheap, and
357
+ -- restricting it to applied runs keeps abandoned scans out of the way.
358
+ CREATE INDEX IF NOT EXISTS cms_publication_review_runs_current_idx
359
+ ON public.cms_publication_review_runs (applied_at DESC)
360
+ WHERE state = 'applied';
361
+
362
+ CREATE TABLE IF NOT EXISTS public.cms_publication_review_items (
363
+ id uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
364
+ run_id uuid NOT NULL
365
+ REFERENCES public.cms_publication_review_runs (id) ON DELETE RESTRICT,
366
+
367
+ -- ON DELETE RESTRICT, not CASCADE, and deliberately so. A cascade would
368
+ -- erase the review history for a page the moment somebody hard-deleted it,
369
+ -- which is precisely when that history is worth having. It also stops a
370
+ -- forward-ordered restore from appearing to succeed while dropping the
371
+ -- evidence for what it restored.
372
+ page_id uuid NOT NULL
373
+ REFERENCES public.pages (id) ON DELETE RESTRICT,
374
+ page_key text NOT NULL,
375
+
376
+ classification text NOT NULL
377
+ CHECK (classification IN (
378
+ 'already_safe', 'preserved', 'hidden_by_expiry',
379
+ 'hidden_by_design', 'disagreement', 'needs_review',
380
+ 'structurally_invalid')),
381
+ requires_decision boolean NOT NULL,
382
+
383
+ -- What each reader would serve for this page. The two are compared, not
384
+ -- interpreted: equal means nothing changes, different means somebody looks.
385
+ legacy_version_id uuid,
386
+ v2_version_id uuid,
387
+
388
+ -- True when the 0.90 answer came from a publication row, false when it came
389
+ -- from the current_version_id fallback. This single boolean is the
390
+ -- difference between "this page was published and stays published" and
391
+ -- "this page was never published and is about to disappear".
392
+ legacy_from_publication boolean NOT NULL,
393
+
394
+ governing_publication_id uuid,
395
+ scheduled_version_id uuid,
396
+ scheduled_publish_at timestamptz,
397
+
398
+ -- Set when the historically served version was not deterministic: two active
399
+ -- rows sharing the maximum publish_at, or a tie in the (publish_at,
400
+ -- published_at) pair among the candidates. Such a page is never preserved
401
+ -- automatically, whatever else is true of it.
402
+ ambiguous_order boolean NOT NULL,
403
+
404
+ reason text,
405
+
406
+ -- Everything the classification was computed from, canonicalised. The digest
407
+ -- is generated rather than supplied, so it cannot disagree with the inputs it
408
+ -- claims to cover: a decision quoting a stale digest is refused by
409
+ -- construction rather than by a check somebody remembered to write.
410
+ inputs jsonb NOT NULL,
411
+ inputs_digest text NOT NULL
412
+ GENERATED ALWAYS AS (public.cms_digest(inputs)) STORED,
413
+
414
+ -- One item per page per run. Note what is *not* here: any uniqueness on
415
+ -- page_id alone. A later scan produces a second item for the same page, and
416
+ -- that is a new row rather than a conflict, which is what keeps old runs
417
+ -- readable after a new one lands.
418
+ CONSTRAINT cms_publication_review_items_run_page_key UNIQUE (run_id, page_id),
419
+
420
+ -- Redundant on its own — id is already the primary key — but a foreign key
421
+ -- has to name a unique constraint, and the decisions table below needs to
422
+ -- point at all three columns at once. See the note there.
423
+ CONSTRAINT cms_publication_review_items_identity_key UNIQUE (id, run_id, page_id),
424
+
425
+ CONSTRAINT cms_publication_review_items_inputs_object
426
+ CHECK (jsonb_typeof(inputs) = 'object')
427
+ );
428
+
429
+ CREATE INDEX IF NOT EXISTS cms_publication_review_items_run_idx
430
+ ON public.cms_publication_review_items (run_id);
431
+ CREATE INDEX IF NOT EXISTS cms_publication_review_items_page_idx
432
+ ON public.cms_publication_review_items (page_id);
433
+
434
+ -- The pending queue is the interesting read: items in a run that need somebody
435
+ -- and have not got them yet.
436
+ CREATE INDEX IF NOT EXISTS cms_publication_review_items_pending_idx
437
+ ON public.cms_publication_review_items (run_id, page_key)
438
+ WHERE requires_decision;
439
+
440
+ CREATE TABLE IF NOT EXISTS public.cms_publication_review_decisions (
441
+ id uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
442
+
443
+ -- UNIQUE, so an item is resolved at most once and for good. A second attempt
444
+ -- reports that it was already resolved rather than overwriting; an operator
445
+ -- who changes their mind runs a new scan, which produces new items to decide
446
+ -- on. This is also what makes a backup merge safe: an archived decision
447
+ -- either inserts, because the item had none, or collides visibly. There is no
448
+ -- path by which it silently supersedes a local one.
449
+ item_id uuid NOT NULL UNIQUE
450
+ REFERENCES public.cms_publication_review_items (id) ON DELETE RESTRICT,
451
+
452
+ run_id uuid NOT NULL,
453
+ page_id uuid NOT NULL,
454
+
455
+ decision text NOT NULL
456
+ CHECK (decision IN (
457
+ 'publish-legacy-version', 'leave-hidden', 'no-change')),
458
+
459
+ -- The digest the decider was looking at. If the item's inputs have moved
460
+ -- since, this no longer matches and the resolve refuses — a decision is
461
+ -- about a specific reading of a specific page, not about a page in general.
462
+ expected_inputs_digest text NOT NULL,
463
+
464
+ source text NOT NULL
465
+ CHECK (source IN ('migration', 'cli', 'reconciliation')),
466
+
467
+ -- The pointer is released when the account is deleted; the email stays, so
468
+ -- the history still says who decided. See the update trigger below, which
469
+ -- makes room for exactly that one write and no other.
470
+ resolved_by uuid REFERENCES public.app_users (id) ON DELETE SET NULL,
471
+ resolved_by_email text NOT NULL,
472
+
473
+ decided_at timestamptz NOT NULL DEFAULT now(),
474
+ note text,
475
+
476
+ -- The decision's own run_id and page_id must be the ones on the item it
477
+ -- names, so a row cannot be filed against one page while claiming to be
478
+ -- about another. All three columns are in the key on purpose: a foreign key
479
+ -- on (run_id, page_id) alone would only prove that *some* item has that
480
+ -- pair, which is satisfied by a second run's item for the same page and so
481
+ -- proves nothing about this decision's own item.
482
+ CONSTRAINT cms_publication_review_decisions_item_fk
483
+ FOREIGN KEY (item_id, run_id, page_id)
484
+ REFERENCES public.cms_publication_review_items (id, run_id, page_id) ON DELETE RESTRICT
485
+ );
486
+
487
+ CREATE INDEX IF NOT EXISTS cms_publication_review_decisions_run_idx
488
+ ON public.cms_publication_review_decisions (run_id, page_id);
489
+
490
+ -- 5. Making the lifetimes structural, not conventional -----------------------
491
+ --
492
+ -- Everything above describes what the three tables are *for*. What follows is
493
+ -- what makes those descriptions true even when the caller is a restore script,
494
+ -- a well-meaning operator with psql open, or a future version of this codebase
495
+ -- that has forgotten the rule. Comments do not stop an UPDATE; triggers do.
496
+
497
+ -- Runs move forward or they abort. Nothing else.
498
+ CREATE OR REPLACE FUNCTION public.cms_publication_review_runs_guard()
499
+ RETURNS trigger
500
+ LANGUAGE plpgsql
501
+ SET search_path = public, pg_temp
502
+ AS $$
503
+ DECLARE
504
+ -- The only columns a legitimate transition writes. Everything outside this
505
+ -- list is identity, provenance or an agreed count, and is frozen at insert.
506
+ _mutable constant text[] := ARRAY[
507
+ 'state', 'scanned_page_count', 'counts', 'scan_completed_at',
508
+ 'applied_by', 'applied_by_email', 'applied_at'
509
+ ];
510
+ BEGIN
511
+ IF (to_jsonb(NEW) - _mutable) IS DISTINCT FROM (to_jsonb(OLD) - _mutable) THEN
512
+ RAISE EXCEPTION
513
+ 'a review run records what was scanned and approved; that cannot be edited afterwards'
514
+ USING ERRCODE = '42501',
515
+ DETAIL = format('run %s: an update changed a column outside the state machine', OLD.id),
516
+ HINT = 'run a new scan instead of amending this one';
517
+ END IF;
518
+
519
+ IF NEW.state = OLD.state THEN
520
+ RAISE EXCEPTION 'a review run update must move its state forward'
521
+ USING ERRCODE = '42501',
522
+ DETAIL = format('run %s is already %L', OLD.id, OLD.state);
523
+ END IF;
524
+
525
+ IF NEW.state = 'aborted' THEN
526
+ IF OLD.state = 'aborted' THEN
527
+ RAISE EXCEPTION 'review run %s is already aborted', OLD.id USING ERRCODE = '42501';
528
+ END IF;
529
+ RETURN NEW;
530
+ END IF;
531
+
532
+ IF NOT ((OLD.state, NEW.state) IN (('scanning', 'applying'), ('applying', 'applied'))) THEN
533
+ RAISE EXCEPTION 'a review run cannot go from % to %', OLD.state, NEW.state
534
+ USING ERRCODE = '42501',
535
+ DETAIL = 'the permitted moves are scanning to applying, applying to applied, and anything to aborted',
536
+ HINT = 'a run that went wrong is aborted, not rewound';
537
+ END IF;
538
+
539
+ RETURN NEW;
540
+ END;
541
+ $$;
542
+
543
+ CREATE OR REPLACE FUNCTION public.cms_publication_review_runs_no_delete()
544
+ RETURNS trigger
545
+ LANGUAGE plpgsql
546
+ SET search_path = public, pg_temp
547
+ AS $$
548
+ BEGIN
549
+ RAISE EXCEPTION 'review runs are the record that a scan happened; they are not deleted'
550
+ USING ERRCODE = '42501',
551
+ DETAIL = format('run %s', OLD.id),
552
+ HINT = 'a run that should not be relied on is aborted, which leaves the evidence in place';
553
+ END;
554
+ $$;
555
+
556
+ -- Items are written once, by the scan that found them.
557
+ CREATE OR REPLACE FUNCTION public.cms_publication_review_items_write_once()
558
+ RETURNS trigger
559
+ LANGUAGE plpgsql
560
+ SET search_path = public, pg_temp
561
+ AS $$
562
+ BEGIN
563
+ RAISE EXCEPTION 'review items are what the scan saw; they are append-only'
564
+ USING ERRCODE = '42501',
565
+ DETAIL = format('item %s (%s) may not be %s',
566
+ OLD.id, OLD.page_key,
567
+ CASE TG_OP WHEN 'UPDATE' THEN 'updated' ELSE 'deleted' END),
568
+ HINT = 'record a decision against the item, or run a new scan';
569
+ END;
570
+ $$;
571
+
572
+ -- Decisions are append-only, with one deliberate exception: when an account is
573
+ -- deleted, the foreign key releases the pointer to it. That release is itself
574
+ -- an UPDATE, so an unconditional guard here would turn ON DELETE SET NULL into
575
+ -- a refusal to delete the account at all — with an error message about review
576
+ -- decisions, which is not a sentence anybody deleting a user should have to
577
+ -- read. The guard therefore permits exactly one shape of update: resolved_by
578
+ -- going from something to nothing, with every other column identical. The
579
+ -- email stays, so the record still says who decided.
580
+ CREATE OR REPLACE FUNCTION public.cms_publication_review_decisions_append_only()
581
+ RETURNS trigger
582
+ LANGUAGE plpgsql
583
+ SET search_path = public, pg_temp
584
+ AS $$
585
+ BEGIN
586
+ IF TG_OP = 'UPDATE'
587
+ AND OLD.resolved_by IS NOT NULL
588
+ AND NEW.resolved_by IS NULL
589
+ AND (to_jsonb(NEW) - 'resolved_by') = (to_jsonb(OLD) - 'resolved_by')
590
+ THEN
591
+ RETURN NEW;
592
+ END IF;
593
+
594
+ RAISE EXCEPTION 'a review decision is a record of what somebody decided; it is append-only'
595
+ USING ERRCODE = '42501',
596
+ DETAIL = format('decision %s may not be %s', OLD.id,
597
+ CASE TG_OP WHEN 'UPDATE' THEN 'updated' ELSE 'deleted' END),
598
+ HINT = 'to decide differently, run a new scan and resolve the new item';
599
+ END;
600
+ $$;
601
+
602
+ -- Row triggers never see a TRUNCATE, so append-only would be a rule you could
603
+ -- step around with one statement. These close that door. Note that TRUNCATE on
604
+ -- the items table alone is already refused by the decisions foreign key before
605
+ -- any trigger runs — it is the CASCADE form, and truncating the three tables
606
+ -- together, that these actually catch.
607
+ CREATE OR REPLACE FUNCTION public.cms_publication_review_no_truncate()
608
+ RETURNS trigger
609
+ LANGUAGE plpgsql
610
+ SET search_path = public, pg_temp
611
+ AS $$
612
+ BEGIN
613
+ RAISE EXCEPTION 'the publication review record is append-only; % cannot be truncated', TG_TABLE_NAME
614
+ USING ERRCODE = '42501',
615
+ HINT = 'a run that should not be relied on is aborted, which leaves the evidence in place';
616
+ END;
617
+ $$;
618
+
619
+ DROP TRIGGER IF EXISTS cms_publication_review_runs_guard
620
+ ON public.cms_publication_review_runs;
621
+ CREATE TRIGGER cms_publication_review_runs_guard
622
+ BEFORE UPDATE ON public.cms_publication_review_runs
623
+ FOR EACH ROW EXECUTE FUNCTION public.cms_publication_review_runs_guard();
624
+
625
+ DROP TRIGGER IF EXISTS cms_publication_review_runs_no_delete
626
+ ON public.cms_publication_review_runs;
627
+ CREATE TRIGGER cms_publication_review_runs_no_delete
628
+ BEFORE DELETE ON public.cms_publication_review_runs
629
+ FOR EACH ROW EXECUTE FUNCTION public.cms_publication_review_runs_no_delete();
630
+
631
+ DROP TRIGGER IF EXISTS cms_publication_review_items_write_once
632
+ ON public.cms_publication_review_items;
633
+ CREATE TRIGGER cms_publication_review_items_write_once
634
+ BEFORE UPDATE OR DELETE ON public.cms_publication_review_items
635
+ FOR EACH ROW EXECUTE FUNCTION public.cms_publication_review_items_write_once();
636
+
637
+ DROP TRIGGER IF EXISTS cms_publication_review_decisions_append_only
638
+ ON public.cms_publication_review_decisions;
639
+ CREATE TRIGGER cms_publication_review_decisions_append_only
640
+ BEFORE UPDATE OR DELETE ON public.cms_publication_review_decisions
641
+ FOR EACH ROW EXECUTE FUNCTION public.cms_publication_review_decisions_append_only();
642
+
643
+ DROP TRIGGER IF EXISTS cms_publication_review_runs_no_truncate
644
+ ON public.cms_publication_review_runs;
645
+ CREATE TRIGGER cms_publication_review_runs_no_truncate
646
+ BEFORE TRUNCATE ON public.cms_publication_review_runs
647
+ FOR EACH STATEMENT EXECUTE FUNCTION public.cms_publication_review_no_truncate();
648
+
649
+ DROP TRIGGER IF EXISTS cms_publication_review_items_no_truncate
650
+ ON public.cms_publication_review_items;
651
+ CREATE TRIGGER cms_publication_review_items_no_truncate
652
+ BEFORE TRUNCATE ON public.cms_publication_review_items
653
+ FOR EACH STATEMENT EXECUTE FUNCTION public.cms_publication_review_no_truncate();
654
+
655
+ DROP TRIGGER IF EXISTS cms_publication_review_decisions_no_truncate
656
+ ON public.cms_publication_review_decisions;
657
+ CREATE TRIGGER cms_publication_review_decisions_no_truncate
658
+ BEFORE TRUNCATE ON public.cms_publication_review_decisions
659
+ FOR EACH STATEMENT EXECUTE FUNCTION public.cms_publication_review_no_truncate();
660
+
661
+ -- 6. Three authorities the rest of contract v3 is built on --------------------
662
+ --
663
+ -- The review model above answers "may this upgrade proceed at all". The tables
664
+ -- here answer three questions the upgraded site asks on ordinary days, and
665
+ -- each of them is an authority rather than a cache. That is the whole reason
666
+ -- they are tables: every one of these three failures is a second opinion,
667
+ -- computed somewhere in application code, disagreeing with the first.
668
+ --
669
+ -- Nothing populates them here. Section 10 writes the first two and section 9
670
+ -- the third, and until something calls those, a database that has the tables
671
+ -- and no rows has claimed no routes and served no duplicate twice — which is
672
+ -- the correct description of one that has never done either.
673
+
674
+ -- Route authority.
675
+ --
676
+ -- Read-then-write validation cannot be made correct by ordering: two
677
+ -- concurrent saves both read a path as free, both commit, and the site now has
678
+ -- two pages at one URL. The authority has to be a constraint, so it is the
679
+ -- primary key below. cms_route_claim_apply recomputes a page's entire claim
680
+ -- set and swaps it in one transaction, taking an advisory lock per origin so
681
+ -- that writers queue in a predictable order — but the advisory lock is only a
682
+ -- fairness device. A writer that forgets it still cannot double-claim, because
683
+ -- the key is what refuses, not the queue.
684
+ --
685
+ -- The origin keeps its port. Dropping every port was the earlier mistake:
686
+ -- https://example.com and https://example.com:8443 are different sites and
687
+ -- have every right to claim the same path. Only :80 on http and :443 on https
688
+ -- come off, and they come off before the row arrives here.
689
+ --
690
+ -- Reserved paths — the API prefix, the backoffice, sitemap.xml, robots.txt,
691
+ -- the asset roots — are materialised as rows of their own, so a page that
692
+ -- tries to sit on one collides through the same primary key as a page
693
+ -- colliding with another page. One mechanism, one error, and no second list of
694
+ -- forbidden strings to keep in step with the first.
695
+ CREATE TABLE IF NOT EXISTS public.cms_route_claims (
696
+ -- Scheme and host lowercased, port preserved: the canonical origin, not the
697
+ -- Host header some particular request happened to arrive with.
698
+ origin text NOT NULL,
699
+
700
+ -- The normalized served path, always rooted. Case is deliberately not forced
701
+ -- here. Trailing-slash and case policy live in the registry snapshot below
702
+ -- because they are host decisions, and a claim is normalized under whichever
703
+ -- policy was current when it was written — which is what registry_sha
704
+ -- records.
705
+ path text NOT NULL,
706
+
707
+ kind text NOT NULL CHECK (kind IN ('page', 'redirect', 'reserved')),
708
+
709
+ -- ON DELETE CASCADE, unlike the review tables above, and for the opposite
710
+ -- reason. A review item is history and outlives its page; a claim is a
711
+ -- statement about what the site serves right now, and a deleted page serves
712
+ -- nothing. Leaving the claim behind would keep a URL reserved against a page
713
+ -- that no longer exists.
714
+ page_id uuid REFERENCES public.pages (id) ON DELETE CASCADE,
715
+ locale text,
716
+
717
+ -- Which registry snapshot this claim was normalized under, so a claim
718
+ -- written against yesterday's route map is recognisable as such instead of
719
+ -- being trusted on sight.
720
+ registry_sha text,
721
+
722
+ PRIMARY KEY (origin, path),
723
+
724
+ -- The key is only an authority if both writers spell the same URL the same
725
+ -- way, so the two normalization steps that are not policy-dependent are
726
+ -- enforced here rather than assumed of every caller.
727
+ CONSTRAINT cms_route_claims_origin_canonical
728
+ CHECK (origin = lower(origin) AND origin NOT LIKE '%/'),
729
+ CONSTRAINT cms_route_claims_path_rooted
730
+ CHECK (path LIKE '/%'),
731
+
732
+ -- A page claim with no page is unenforceable, and a reserved claim with one
733
+ -- would mean a page had been handed a path the host reserves. Redirects are
734
+ -- left open on purpose: a redirect source may or may not belong to a page.
735
+ CONSTRAINT cms_route_claims_page_id_matches_kind
736
+ CHECK ((kind = 'page' AND page_id IS NOT NULL)
737
+ OR (kind = 'reserved' AND page_id IS NULL)
738
+ OR kind = 'redirect')
739
+ );
740
+
741
+ -- "Every claim this page owns" is the read cms_route_claim_apply does before
742
+ -- each swap, and the read the cascade above does on every page delete. Without
743
+ -- this index both are a sequential scan of every route on the site.
744
+ CREATE INDEX IF NOT EXISTS cms_route_claims_page_idx
745
+ ON public.cms_route_claims (page_id)
746
+ WHERE page_id IS NOT NULL;
747
+
748
+ -- What the host knows and the database cannot infer: which paths are reserved,
749
+ -- which locales exist, which domain serves which locale, whether a trailing
750
+ -- slash is kept, whether case is folded. The package cannot read the host's
751
+ -- route table, so the host writes it down here once per release and everything
752
+ -- that normalizes a URL afterwards reads that one copy.
753
+ --
754
+ -- One row, enforced by the check, because "the current route map" is not a
755
+ -- collection. release_sha is what makes a stale one fail closed:
756
+ -- cms_route_claim_apply refuses when the sha the caller expected is not the
757
+ -- sha stored here, rather than validating tomorrow's claim against yesterday's
758
+ -- map and finding no collision.
759
+ CREATE TABLE IF NOT EXISTS public.cms_route_registry (
760
+ id int NOT NULL PRIMARY KEY CHECK (id = 1),
761
+ release_sha text NOT NULL,
762
+ snapshot jsonb NOT NULL,
763
+ updated_at timestamptz NOT NULL DEFAULT now(),
764
+
765
+ CONSTRAINT cms_route_registry_snapshot_object
766
+ CHECK (jsonb_typeof(snapshot) = 'object')
767
+ );
768
+
769
+ -- Duplication is idempotent by key rather than by hope. A committed request
770
+ -- whose response never arrived is an ordinary event on a slow link, and the
771
+ -- editor retries. Without this table the retry makes a second copy; with the
772
+ -- route authority above, that copy then tries to claim the same routes and
773
+ -- collides with the original, so a dropped packet turns into a routing failure
774
+ -- somebody has to untangle by hand. With it, the retry returns the first
775
+ -- result and says so.
776
+ --
777
+ -- No foreign keys, deliberately. The row records that a request was served,
778
+ -- which stays true after either page is deleted, and a cascade would erase the
779
+ -- evidence that a key had been used — letting a late retry duplicate after
780
+ -- all, which is precisely the case the table exists for.
781
+ CREATE TABLE IF NOT EXISTS public.cms_page_duplicate_operations (
782
+ idempotency_key uuid NOT NULL PRIMARY KEY,
783
+ source_page_id uuid NOT NULL,
784
+ created_page_id uuid NOT NULL,
785
+ created_at timestamptz NOT NULL DEFAULT now()
786
+ );
787
+
788
+ -- The lock helper, in `app` because nothing outside the database may call it.
789
+ --
790
+ -- The rejected alternative was a publicly executable _locks_held flag that
791
+ -- callers could pass to tell an inner function to skip its locking. That is a
792
+ -- caller-controlled bypass of the only thing standing between two concurrent
793
+ -- mutations and a deadlock, and it is not in this design. No flag is needed:
794
+ -- PostgreSQL row locks are reentrant within a transaction, so an inner
795
+ -- function re-locking a row its caller already holds is a no-op. Natural
796
+ -- reentrancy replaces the flag entirely.
797
+ --
798
+ -- The order is the uuid's own byte order — total, deterministic, and identical
799
+ -- in every session. That is the entire deadlock argument: a bulk resolution
800
+ -- over a batch and a single-page mutation take the rows they share in the same
801
+ -- order, so one waits rather than both dying. The tier order around it is
802
+ -- invariant too — pages, then publications, then review — and lives in the
803
+ -- callers, because this function knows only about pages.
804
+ --
805
+ -- A null array, or an array with a null in it, is refused rather than quietly
806
+ -- locking nothing: `id = ANY (ARRAY[NULL])` matches no rows, so a caller that
807
+ -- assembled its target set wrongly would sail past this believing it held a
808
+ -- lock. Ids that do not exist are not refused — the caller revalidates under
809
+ -- the lock, and "that page is gone" is its answer to give, not this
810
+ -- function's.
811
+ CREATE OR REPLACE FUNCTION app.cms_lock_pages(_page_ids uuid[])
812
+ RETURNS void
813
+ LANGUAGE plpgsql
814
+ SET search_path = public, pg_temp
815
+ AS $$
816
+ BEGIN
817
+ IF _page_ids IS NULL OR array_position(_page_ids, NULL) IS NOT NULL THEN
818
+ RAISE EXCEPTION 'app.cms_lock_pages requires an array of page ids with no nulls'
819
+ USING ERRCODE = '22004';
820
+ END IF;
821
+
822
+ PERFORM id
823
+ FROM public.pages
824
+ WHERE id = ANY (_page_ids)
825
+ ORDER BY id
826
+ FOR UPDATE;
827
+ END
828
+ $$;
829
+
830
+ -- 7. Reading the ledger, and who may read it ---------------------------------
831
+ --
832
+ -- Two questions get an answer in the database rather than in each caller,
833
+ -- because getting either of them subtly wrong is how "the scan is done" starts
834
+ -- meaning something different in two places.
835
+
836
+ -- A run is only evidence when it finished. This matters more than it looks:
837
+ -- under a run that satisfies this predicate, the *absence* of an item for a
838
+ -- live page positively means the page was scanned and needed no record. Under
839
+ -- any other run, absence means nothing at all, and a caller that cannot tell
840
+ -- the two apart will happily report an interrupted scan as a clean bill of
841
+ -- health.
842
+ CREATE OR REPLACE FUNCTION public.cms_publication_review_is_complete(_run_id uuid)
843
+ RETURNS boolean
844
+ LANGUAGE sql
845
+ STABLE
846
+ SET search_path = public, pg_temp
847
+ AS $$
848
+ SELECT EXISTS (
849
+ SELECT 1
850
+ FROM public.cms_publication_review_runs
851
+ WHERE id = _run_id
852
+ AND state = 'applied'
853
+ AND scan_completed_at IS NOT NULL
854
+ AND scanned_page_count = expected_page_count
855
+ );
856
+ $$;
857
+
858
+ -- "Which review governs this installation right now" — the applied run with
859
+ -- the greatest applied_at, and no other. Pending work is then that run's items
860
+ -- with requires_decision and no decision row, which needs no adjudication
861
+ -- because an item has at most one decision by constraint.
862
+ CREATE OR REPLACE FUNCTION public.cms_publication_review_current_run()
863
+ RETURNS uuid
864
+ LANGUAGE sql
865
+ STABLE
866
+ SET search_path = public, pg_temp
867
+ AS $$
868
+ SELECT id
869
+ FROM public.cms_publication_review_runs
870
+ WHERE state = 'applied'
871
+ ORDER BY applied_at DESC, id
872
+ LIMIT 1;
873
+ $$;
874
+
875
+ -- The scan, and the fingerprint it is taken against, as callable functions ---
876
+ --
877
+ -- Both were written inline in the DO block below, which was correct for as
878
+ -- long as the only thing that ever scanned this database was the upgrade
879
+ -- itself. That stopped being true the moment a schema-2 backup could be
880
+ -- restored into a v3 database: the archive carries content but no review
881
+ -- history, so the pages that arrive have no classification and nothing is left
882
+ -- to give them one. Reconciliation needs the same scan, and a second copy of a
883
+ -- 130-line classification would be two opinions rather than one answer.
884
+ --
885
+ -- So they are functions, and the DO block calls them. There is exactly one
886
+ -- classification in this schema, and exactly one fingerprint expression.
887
+
888
+ -- Everything either reader looks at, in one deterministic string.
889
+ --
890
+ -- Soft-deleted pages and inactive publication rows are in it deliberately:
891
+ -- deleting a page and deactivating a publication both change what the site
892
+ -- serves, and both would slip through a fingerprint that only looked at what
893
+ -- is live. scripts/review-fingerprint.sql is this same expression written for
894
+ -- a reviewer standing at a database that has no cms_digest() yet; the two are
895
+ -- held together by ops/migration-smoke.mjs.
896
+ CREATE OR REPLACE FUNCTION public.cms_publication_review_fingerprint()
897
+ RETURNS text
898
+ LANGUAGE sql
899
+ STABLE
900
+ SET search_path = public, pg_temp
901
+ AS $fingerprint$
902
+ SELECT public.cms_digest(jsonb_build_object(
903
+ 'pageCount', count(*),
904
+ 'pages', coalesce(jsonb_agg(entry ORDER BY page_id), '[]'::jsonb)
905
+ ))
906
+ FROM (
907
+ SELECT pg.id AS page_id,
908
+ jsonb_build_object(
909
+ 'id', pg.id::text,
910
+ 'key', pg.key,
911
+ 'status', pg.status,
912
+ 'currentVersionId', pg.current_version_id::text,
913
+ 'deletedAt', to_char(pg.deleted_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
914
+ 'publications', (
915
+ SELECT coalesce(jsonb_agg(jsonb_build_object(
916
+ 'id', pp.id::text,
917
+ 'versionId', pp.version_id::text,
918
+ 'publishAt', to_char(pp.publish_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
919
+ 'unpublishAt', to_char(pp.unpublish_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
920
+ 'publishedAt', to_char(pp.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
921
+ 'active', pp.is_active
922
+ ) ORDER BY pp.id), '[]'::jsonb)
923
+ FROM public.page_publications pp
924
+ WHERE pp.page_id = pg.id
925
+ )
926
+ ) AS entry
927
+ FROM public.pages pg
928
+ ) snapshot;
929
+ $fingerprint$;
930
+
931
+ -- One row per live page: what each reader would serve for it, and the shape of
932
+ -- their disagreement.
933
+ --
934
+ -- This is the same calculation as src/platform/publication-review.ts, written
935
+ -- a second time because a migration cannot call TypeScript;
936
+ -- tests/fixtures/publication-review-corpus.json is seeded into a database and
937
+ -- run through both, so the two stay one answer rather than two opinions.
938
+ -- `_at` is the moment the scan is taken as at, and it is a parameter rather
939
+ -- than a `now()` inside the body for one reason: a scan compares publication
940
+ -- windows against a clock, and every row in one scan must be compared against
941
+ -- the *same* reading of it. A body calling now() would be stable within a
942
+ -- statement and so would happen to be correct today, but it would also let a
943
+ -- caller with its own idea of "now" -- the migration, which checks whether
944
+ -- windows crossed between the approval and this moment -- silently disagree
945
+ -- with the scan it is checking.
946
+ CREATE OR REPLACE FUNCTION public.cms_publication_review_scan(_at timestamptz DEFAULT now())
947
+ RETURNS TABLE (
948
+ page_id uuid,
949
+ page_key text,
950
+ classification text,
951
+ requires_decision boolean,
952
+ legacy_version_id uuid,
953
+ v2_version_id uuid,
954
+ legacy_from_publication boolean,
955
+ governing_publication_id uuid,
956
+ scheduled_version_id uuid,
957
+ scheduled_publish_at timestamptz,
958
+ ambiguous_order boolean,
959
+ reason text,
960
+ inputs jsonb
961
+ )
962
+ LANGUAGE sql
963
+ STABLE
964
+ SET search_path = public, pg_temp
965
+ AS $scan$
966
+ WITH live AS (
967
+ SELECT p.id AS page_id, p.key AS page_key, p.status, p.current_version_id
968
+ FROM public.pages p
969
+ WHERE p.deleted_at IS NULL
970
+ ),
971
+ act AS (
972
+ SELECT pp.id, pp.page_id, pp.version_id, pp.publish_at, pp.unpublish_at, pp.published_at
973
+ FROM public.page_publications pp
974
+ JOIN live l ON l.page_id = pp.page_id
975
+ WHERE pp.is_active
976
+ ),
977
+ started AS (SELECT * FROM act WHERE publish_at <= _at),
978
+ opened AS (SELECT * FROM started WHERE unpublish_at IS NULL OR unpublish_at > _at),
979
+ -- 0.90.0-m7.12: closed rows are gone before the newest is chosen.
980
+ legacy AS (
981
+ SELECT DISTINCT ON (page_id) page_id, id, version_id
982
+ FROM opened ORDER BY page_id, publish_at DESC, id
983
+ ),
984
+ -- 0.91.1: the newest started row governs whether or not it is still open,
985
+ -- and only then is it asked. An expired row shadows an open one here.
986
+ gov AS (
987
+ SELECT DISTINCT ON (page_id) page_id, id, version_id, unpublish_at
988
+ FROM started ORDER BY page_id, publish_at DESC, published_at DESC NULLS LAST, id
989
+ ),
990
+ sched AS (
991
+ SELECT DISTINCT ON (page_id) page_id, version_id, publish_at
992
+ FROM act WHERE publish_at > _at ORDER BY page_id, publish_at, id
993
+ ),
994
+ -- Two independent ways to lose determinism, one per reader. Ties are still
995
+ -- broken above, by row id, so that this record can be reproduced; these two
996
+ -- are what says the record is not authoritative.
997
+ amb_open AS (
998
+ SELECT page_id FROM (
999
+ SELECT page_id, publish_at, max(publish_at) OVER (PARTITION BY page_id) AS mx FROM opened
1000
+ ) t WHERE publish_at = mx GROUP BY page_id HAVING count(*) > 1
1001
+ ),
1002
+ amb_started AS (
1003
+ SELECT page_id FROM (
1004
+ SELECT page_id, publish_at, published_at,
1005
+ max(publish_at) OVER (PARTITION BY page_id) AS mx FROM started
1006
+ ) t WHERE publish_at = mx GROUP BY page_id
1007
+ HAVING count(*) > 1
1008
+ AND (count(*) FILTER (WHERE published_at IS NULL) > 0
1009
+ OR count(DISTINCT published_at) < count(*))
1010
+ ),
1011
+ faults AS (
1012
+ SELECT page_id, string_agg(fault, '; ' ORDER BY ord, fault) AS reason FROM (
1013
+ SELECT l.page_id, 0 AS ord,
1014
+ format('page status %L is not one this schema defines', l.status) AS fault
1015
+ FROM live l WHERE l.status NOT IN ('draft', 'published')
1016
+ UNION ALL
1017
+ SELECT l.page_id, 1,
1018
+ CASE WHEN v.id IS NULL
1019
+ THEN 'current_version_id points at a version that does not exist'
1020
+ ELSE 'current_version_id points at another page''s version' END
1021
+ FROM live l
1022
+ LEFT JOIN public.page_versions v ON v.id = l.current_version_id
1023
+ WHERE l.current_version_id IS NOT NULL AND (v.id IS NULL OR v.page_id <> l.page_id)
1024
+ UNION ALL
1025
+ SELECT a.page_id, 2,
1026
+ format('publication %s points at %s', a.id,
1027
+ CASE WHEN v.id IS NULL THEN 'a version that does not exist'
1028
+ ELSE 'another page''s version' END)
1029
+ FROM act a
1030
+ LEFT JOIN public.page_versions v ON v.id = a.version_id
1031
+ WHERE v.id IS NULL OR v.page_id <> a.page_id
1032
+ UNION ALL
1033
+ SELECT a.page_id, 3, format('publication %s is scheduled to end before it starts', a.id)
1034
+ FROM act a
1035
+ WHERE a.unpublish_at IS NOT NULL AND a.unpublish_at <= a.publish_at
1036
+ ) f GROUP BY page_id
1037
+ ),
1038
+ computed AS (
1039
+ SELECT l.page_id, l.page_key, l.status,
1040
+ (lg.version_id IS NOT NULL) AS legacy_from_publication,
1041
+ coalesce(lg.version_id, l.current_version_id) AS legacy_version_id,
1042
+ CASE WHEN g.unpublish_at IS NULL OR g.unpublish_at > _at
1043
+ THEN g.version_id END AS v2_version_id,
1044
+ lg.id AS governing_publication_id,
1045
+ s.version_id AS scheduled_version_id,
1046
+ s.publish_at AS scheduled_publish_at,
1047
+ (ao.page_id IS NOT NULL OR ast.page_id IS NOT NULL) AS ambiguous_order,
1048
+ EXISTS (SELECT 1 FROM started st WHERE st.page_id = l.page_id) AS has_started,
1049
+ f.reason AS fault_reason
1050
+ FROM live l
1051
+ LEFT JOIN legacy lg ON lg.page_id = l.page_id
1052
+ LEFT JOIN gov g ON g.page_id = l.page_id
1053
+ LEFT JOIN sched s ON s.page_id = l.page_id
1054
+ LEFT JOIN amb_open ao ON ao.page_id = l.page_id
1055
+ LEFT JOIN amb_started ast ON ast.page_id = l.page_id
1056
+ LEFT JOIN faults f ON f.page_id = l.page_id
1057
+ ),
1058
+ classified AS (
1059
+ SELECT c.*,
1060
+ CASE
1061
+ WHEN c.fault_reason IS NOT NULL THEN 'structurally_invalid'
1062
+ WHEN c.legacy_version_id IS NOT DISTINCT FROM c.v2_version_id THEN 'already_safe'
1063
+ WHEN c.legacy_version_id IS NOT NULL AND c.v2_version_id IS NOT NULL THEN 'disagreement'
1064
+ WHEN c.status <> 'published' THEN 'hidden_by_design'
1065
+ WHEN c.ambiguous_order THEN 'needs_review'
1066
+ WHEN c.has_started AND NOT c.legacy_from_publication THEN 'hidden_by_expiry'
1067
+ WHEN c.legacy_from_publication THEN 'preserved'
1068
+ ELSE 'needs_review'
1069
+ END AS classification
1070
+ FROM computed c
1071
+ ),
1072
+ final AS (
1073
+ SELECT c.*,
1074
+ (c.classification IN ('structurally_invalid', 'disagreement', 'needs_review'))
1075
+ AS requires_decision,
1076
+ CASE c.classification
1077
+ WHEN 'structurally_invalid' THEN c.fault_reason
1078
+ WHEN 'disagreement' THEN 'the two readers serve different versions of this page'
1079
+ WHEN 'hidden_by_design' THEN
1080
+ format('the page is %s, so it was not public either way', c.status)
1081
+ WHEN 'hidden_by_expiry' THEN
1082
+ 'every publication window for this page had already closed'
1083
+ WHEN 'preserved' THEN
1084
+ 'an expired publication row shadows the open one, so the new reader serves nothing'
1085
+ WHEN 'needs_review' THEN
1086
+ CASE WHEN c.ambiguous_order THEN 'ambiguous_order'
1087
+ WHEN c.scheduled_version_id IS NOT NULL THEN
1088
+ 'this page''s only publication window has not opened yet, and the data cannot '
1089
+ || 'say whether it is meant to stay live until then or go dark now'
1090
+ ELSE 'the page has no publication row at all, so what it used to serve cannot '
1091
+ || 'be established from the data' END
1092
+ END AS reason
1093
+ FROM classified c
1094
+ )
1095
+ SELECT f.page_id, f.page_key, f.classification, f.requires_decision,
1096
+ f.legacy_version_id, f.v2_version_id, f.legacy_from_publication,
1097
+ f.governing_publication_id, f.scheduled_version_id, f.scheduled_publish_at,
1098
+ f.ambiguous_order, f.reason,
1099
+ -- Field names are the SQL column names on both sides, and the digest
1100
+ -- is taken over the canonical form, so key order here does not matter
1101
+ -- and a rename on either side is a parity failure rather than a
1102
+ -- silent divergence.
1103
+ jsonb_build_object(
1104
+ 'ambiguous_order', f.ambiguous_order,
1105
+ 'classification', f.classification,
1106
+ 'governing_publication_id', f.governing_publication_id::text,
1107
+ 'legacy_from_publication', f.legacy_from_publication,
1108
+ 'legacy_version_id', f.legacy_version_id::text,
1109
+ 'page_id', f.page_id::text,
1110
+ 'page_key', f.page_key,
1111
+ 'requires_decision', f.requires_decision,
1112
+ 'scheduled_publish_at', to_char(f.scheduled_publish_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
1113
+ 'scheduled_version_id', f.scheduled_version_id::text,
1114
+ 'v2_version_id', f.v2_version_id::text
1115
+ )
1116
+ FROM final f;
1117
+ $scan$;
1118
+
1119
+ -- Reconciliation: a scan that no person asked for --------------------------
1120
+ --
1121
+ -- A schema-2 archive predates these tables entirely, so restoring one leaves a
1122
+ -- database full of pages that no run has ever looked at. Nothing is wrong with
1123
+ -- the content; what is missing is the record of what it publishes and why. The
1124
+ -- backoffice queue would be empty, cms_publication_review_is_complete would be
1125
+ -- false for every candidate run, and the site would be in the one state this
1126
+ -- schema exists to make impossible: serving pages whose classification nobody
1127
+ -- can state.
1128
+ --
1129
+ -- So the restore scans, and records the scan honestly:
1130
+ --
1131
+ -- source='reconciliation' nobody approved this; the database did it
1132
+ -- classified_by_email the operator who ran the restore
1133
+ -- approved_by_email the same, and it is not a second signature --
1134
+ -- the columns are NOT NULL and the honest answer
1135
+ -- to "who approved this" is "the person who chose
1136
+ -- to restore an archive that carried no history"
1137
+ --
1138
+ -- What it does NOT do is decide anything. It writes items, never decisions:
1139
+ -- every page is classified with the standing default (keep showing what the
1140
+ -- site shows today), and any page the scan cannot speak for is left with
1141
+ -- requires_decision set, waiting for a person exactly as it would after an
1142
+ -- upgrade. A reconciliation that silently resolved its own ambiguities would
1143
+ -- be a way to launder an unreviewed publication state through a backup file.
1144
+ --
1145
+ -- Called only where the restore actually finished. A partial restore must not
1146
+ -- produce a run, because a run's expected_page_count is a claim about a
1147
+ -- database that has stopped changing, and half a restore is not that.
1148
+ --
1149
+ -- Definer's rights, and granted to service_role alone.
1150
+ --
1151
+ -- The two tables it writes grant every client role SELECT and nothing else --
1152
+ -- runs and items are written by this schema, never by a session -- so an
1153
+ -- invoker-rights version of this function would reach its first INSERT and
1154
+ -- stop, whoever called it. The grant is also the whole of the authorization:
1155
+ -- there is no signed-in user behind a restore to ask is_staff about, and the
1156
+ -- newest applied run is the one that governs the installation, so an editor
1157
+ -- session able to call this could displace a person's reviewed run with a
1158
+ -- machine's housekeeping and empty the pending-decision queue by doing it.
1159
+ -- Same shape, and the same reason, as cms_route_registry_set below.
1160
+ CREATE OR REPLACE FUNCTION public.cms_publication_review_reconcile(
1161
+ _operator_email text,
1162
+ _site_key text DEFAULT NULL,
1163
+ _environment text DEFAULT NULL
1164
+ )
1165
+ RETURNS uuid
1166
+ LANGUAGE plpgsql
1167
+ SECURITY DEFINER
1168
+ SET search_path = public, pg_temp
1169
+ AS $reconcile$
1170
+ DECLARE
1171
+ _at timestamptz := now();
1172
+ _run_id uuid := gen_random_uuid();
1173
+ _live bigint;
1174
+ _scanned bigint;
1175
+ _identity text;
1176
+ BEGIN
1177
+ IF coalesce(btrim(_operator_email), '') = '' THEN
1178
+ RAISE EXCEPTION 'reconciliation needs the address of whoever ran the restore'
1179
+ USING ERRCODE = '22023';
1180
+ END IF;
1181
+
1182
+ SELECT current_database() || '/' || d.oid::text INTO _identity
1183
+ FROM pg_database d WHERE d.datname = current_database();
1184
+
1185
+ SELECT count(*) INTO _live FROM public.pages WHERE deleted_at IS NULL;
1186
+
1187
+ -- 'applying' first, then 'applied' once the items are in and counted. The
1188
+ -- run's own guard trigger permits exactly that transition, and a failure
1189
+ -- between the two leaves a run that cms_publication_review_is_complete
1190
+ -- refuses -- which is the correct reading of an interrupted scan.
1191
+ INSERT INTO public.cms_publication_review_runs (
1192
+ id, approval_nonce, site_key, environment, review_database_identity,
1193
+ release_sha,
1194
+ package_version, migration_name, migration_digest, input_fingerprint,
1195
+ manifest_digest, evaluated_at, expected_page_count, state, source,
1196
+ classified_by_email, classified_at, approved_by_email, approved_at
1197
+ ) VALUES (
1198
+ _run_id, gen_random_uuid(),
1199
+ coalesce(nullif(btrim(_site_key), ''), 'restore'),
1200
+ coalesce(nullif(btrim(_environment), ''), 'restore'),
1201
+ _identity,
1202
+ 'restore', 'restore', 'restore', 'restore',
1203
+ public.cms_publication_review_fingerprint(),
1204
+ 'restore',
1205
+ _at, _live, 'applying', 'reconciliation',
1206
+ _operator_email, _at, _operator_email, _at
1207
+ );
1208
+
1209
+ INSERT INTO public.cms_publication_review_items (
1210
+ run_id, page_id, page_key, classification, requires_decision,
1211
+ legacy_version_id, v2_version_id, legacy_from_publication,
1212
+ governing_publication_id, scheduled_version_id, scheduled_publish_at,
1213
+ ambiguous_order, reason, inputs
1214
+ )
1215
+ SELECT _run_id, page_id, page_key, classification, requires_decision,
1216
+ legacy_version_id, v2_version_id, legacy_from_publication,
1217
+ governing_publication_id, scheduled_version_id, scheduled_publish_at,
1218
+ ambiguous_order, reason, inputs
1219
+ FROM public.cms_publication_review_scan(_at);
1220
+
1221
+ GET DIAGNOSTICS _scanned = ROW_COUNT;
1222
+
1223
+ -- expected and scanned are compared, not assumed equal: a page created or
1224
+ -- soft-deleted between the count above and the scan would make them differ,
1225
+ -- and cms_publication_review_is_complete is what notices.
1226
+ UPDATE public.cms_publication_review_runs
1227
+ SET state = 'applied',
1228
+ scanned_page_count = _scanned,
1229
+ scan_completed_at = now(),
1230
+ applied_by_email = _operator_email,
1231
+ applied_at = now(),
1232
+ counts = (
1233
+ SELECT jsonb_object_agg(classification, n)
1234
+ FROM (SELECT classification, count(*) AS n
1235
+ FROM public.cms_publication_review_items
1236
+ WHERE run_id = _run_id
1237
+ GROUP BY classification) c
1238
+ )
1239
+ WHERE id = _run_id;
1240
+
1241
+ RETURN _run_id;
1242
+ END;
1243
+ $reconcile$;
1244
+
1245
+ -- The runtime capability answer. A filename list cannot tell a caller whether
1246
+ -- it may call the v3 functions yet; this can, because it is assembled from the
1247
+ -- rows the migrations themselves wrote as they made each capability true.
1248
+ -- healthResponse calls it, and treats a missing function, an error, or a
1249
+ -- contractVersion below what the package expects as a failed health check.
1250
+ --
1251
+ -- Every row here is written by the file that makes its capability true, in the
1252
+ -- same transaction, which is what keeps the answer honest: there is no point
1253
+ -- at which the schema claims route-claims and has no cms_route_claims to
1254
+ -- claim into. The four named capabilities are the four contract v3 adds; the
1255
+ -- two versioned ones are reported as versions instead.
1256
+ INSERT INTO public.cms_schema_capabilities (capability, version, migration)
1257
+ VALUES
1258
+ ('archive-schema', 3, '20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24.sql'),
1259
+ ('publication-review', 1, '20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24.sql'),
1260
+ ('route-claims', 1, '20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24.sql'),
1261
+ ('duplicate-idempotency', 1, '20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24.sql'),
1262
+ ('global-lock-order', 1, '20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24.sql')
1263
+ ON CONFLICT (capability) DO UPDATE
1264
+ SET version = EXCLUDED.version,
1265
+ migration = EXCLUDED.migration,
1266
+ recorded_at = now()
1267
+ WHERE public.cms_schema_capabilities.version < EXCLUDED.version;
1268
+
1269
+ CREATE OR REPLACE FUNCTION public.cms_platform_capability()
1270
+ RETURNS jsonb
1271
+ LANGUAGE sql
1272
+ STABLE
1273
+ SECURITY DEFINER
1274
+ SET search_path = public, pg_temp
1275
+ AS $$
1276
+ SELECT jsonb_build_object(
1277
+ 'contractVersion', public.cms_schema_capability('platform-contract'),
1278
+ 'schemaVersion', public.cms_schema_capability('archive-schema'),
1279
+ 'reviewModel', 'runs-items-decisions',
1280
+ -- Named capabilities only: the two version rows above are reported as
1281
+ -- versions, not repeated here as feature names. Sorted, so two
1282
+ -- installations at the same schema give byte-identical answers.
1283
+ 'capabilities', COALESCE((
1284
+ SELECT jsonb_agg(capability ORDER BY capability)
1285
+ FROM public.cms_schema_capabilities
1286
+ WHERE capability NOT IN ('platform-contract', 'archive-schema')
1287
+ ), '[]'::jsonb)
1288
+ );
1289
+ $$;
1290
+
1291
+ ALTER TABLE public.cms_publication_review_runs ENABLE ROW LEVEL SECURITY;
1292
+ ALTER TABLE public.cms_publication_review_items ENABLE ROW LEVEL SECURITY;
1293
+ ALTER TABLE public.cms_publication_review_decisions ENABLE ROW LEVEL SECURITY;
1294
+ ALTER TABLE public.cms_route_claims ENABLE ROW LEVEL SECURITY;
1295
+ ALTER TABLE public.cms_route_registry ENABLE ROW LEVEL SECURITY;
1296
+ ALTER TABLE public.cms_page_duplicate_operations ENABLE ROW LEVEL SECURITY;
1297
+
1298
+ -- The two route tables are the one exception to "anon gets nothing", and the
1299
+ -- exception is the public reader itself. Every render resolves the requested
1300
+ -- URL against the claims and against the registry's normalization policy, and
1301
+ -- it does so as whichever role serves public traffic — anon, on a hosted
1302
+ -- stack. Withholding them would also protect nothing: this schema has let
1303
+ -- anyone read public.pages since its first migration ("Anyone can read
1304
+ -- pages"), so every page a claim can name is already legible. What the claims
1305
+ -- add is which URL serves it, which is the one thing a site tells the world on
1306
+ -- purpose.
1307
+ --
1308
+ -- No TO clause on either policy, deliberately. A self-host with no hosted
1309
+ -- roles at all still has to be able to read its own routes, and a policy named
1310
+ -- for roles that do not exist cannot be created there.
1311
+ DROP POLICY IF EXISTS cms_route_claims_public_read ON public.cms_route_claims;
1312
+ CREATE POLICY cms_route_claims_public_read
1313
+ ON public.cms_route_claims FOR SELECT USING (true);
1314
+
1315
+ DROP POLICY IF EXISTS cms_route_registry_public_read ON public.cms_route_registry;
1316
+ CREATE POLICY cms_route_registry_public_read
1317
+ ON public.cms_route_registry FOR SELECT USING (true);
1318
+
1319
+ -- Read-only, to the roles that have any business reading each table, and only
1320
+ -- where those roles exist — this file has to apply to a plain PostgreSQL
1321
+ -- database with no PostgREST stack behind it, exactly as the two v2
1322
+ -- migrations do.
1323
+ --
1324
+ -- Nothing here grants INSERT, UPDATE or DELETE to anybody, on any of the six.
1325
+ -- Runs and items are written by the migration and by
1326
+ -- cms_publication_review_reconcile; decisions, route claims, the registry
1327
+ -- snapshot and the duplicate ledger are written only through SECURITY DEFINER
1328
+ -- functions, which is what makes "who decided this", "who claimed this URL"
1329
+ -- and "was this duplication already served" questions with one answer each. The duplicate ledger joins the review tables on the private
1330
+ -- side: it is editor bookkeeping, and it names pages before anyone has decided
1331
+ -- to publish them.
1332
+ DO $$
1333
+ DECLARE
1334
+ _role text;
1335
+ _table text;
1336
+ BEGIN
1337
+ FOREACH _role IN ARRAY ARRAY['anon', 'authenticated', 'service_role'] LOOP
1338
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = _role) THEN
1339
+ CONTINUE;
1340
+ END IF;
1341
+
1342
+ FOREACH _table IN ARRAY ARRAY['cms_installation',
1343
+ 'cms_schema_capabilities',
1344
+ 'cms_publication_review_runs',
1345
+ 'cms_publication_review_items',
1346
+ 'cms_publication_review_decisions',
1347
+ 'cms_page_duplicate_operations'] LOOP
1348
+ EXECUTE format('REVOKE ALL ON TABLE public.%I FROM %I', _table, _role);
1349
+ IF _role <> 'anon' THEN
1350
+ EXECUTE format('GRANT SELECT ON TABLE public.%I TO %I', _table, _role);
1351
+ END IF;
1352
+ END LOOP;
1353
+
1354
+ FOREACH _table IN ARRAY ARRAY['cms_route_claims',
1355
+ 'cms_route_registry'] LOOP
1356
+ EXECUTE format('REVOKE ALL ON TABLE public.%I FROM %I', _table, _role);
1357
+ EXECUTE format('GRANT SELECT ON TABLE public.%I TO %I', _table, _role);
1358
+ END LOOP;
1359
+
1360
+ IF _role = 'authenticated' THEN
1361
+ FOREACH _table IN ARRAY ARRAY['cms_publication_review_runs',
1362
+ 'cms_publication_review_items',
1363
+ 'cms_publication_review_decisions',
1364
+ 'cms_page_duplicate_operations'] LOOP
1365
+ EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', _table || '_admin_read', _table);
1366
+ EXECUTE format(
1367
+ 'CREATE POLICY %I ON public.%I FOR SELECT TO authenticated
1368
+ USING (public.has_role(auth.uid(), ''admin''::public.app_role))',
1369
+ _table || '_admin_read', _table);
1370
+ END LOOP;
1371
+ ELSIF _role = 'service_role' THEN
1372
+ FOREACH _table IN ARRAY ARRAY['cms_publication_review_runs',
1373
+ 'cms_publication_review_items',
1374
+ 'cms_publication_review_decisions',
1375
+ 'cms_page_duplicate_operations'] LOOP
1376
+ EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', _table || '_service_read', _table);
1377
+ EXECUTE format(
1378
+ 'CREATE POLICY %I ON public.%I FOR SELECT TO service_role USING (true)',
1379
+ _table || '_service_read', _table);
1380
+ END LOOP;
1381
+ END IF;
1382
+ END LOOP;
1383
+ END
1384
+ $$;
1385
+
1386
+ -- 8. The approval this migration refuses to run without ----------------------
1387
+ --
1388
+ -- Everything above is schema. What follows changes what the site serves, and
1389
+ -- it does so on the strength of a scan somebody looked at and signed. The
1390
+ -- signature arrives as custom GUCs, set by the deployment helper immediately
1391
+ -- before this file is piped in:
1392
+ --
1393
+ -- PGOPTIONS="-c lovable.approval_nonce=... -c lovable.input_fingerprint=..."
1394
+ --
1395
+ -- which leaves stdin byte-identical to the file that was digested, so the
1396
+ -- approval covers exactly the SQL that runs. No psql meta-commands, no
1397
+ -- interpolation, nothing that makes this file unreadable by another client.
1398
+ --
1399
+ -- The helper has already checked the approval record against this deployment
1400
+ -- before exporting anything. Some of that is deliberately checked again here,
1401
+ -- because the helper and the database can disagree, and the database is the
1402
+ -- one that knows what is actually in it.
1403
+ --
1404
+ -- One exception, and it is not a loophole. The review exists to carry a
1405
+ -- person's answers from the moment they looked at the site to the moment the
1406
+ -- upgrade happens. Where the scan finds nothing to answer, there is nothing to
1407
+ -- carry, and nobody to wait for: every page either looks the same to both
1408
+ -- readers, was not public either way, or is covered by the standing default.
1409
+ -- So a database the scan can speak for entirely records an unattended run and
1410
+ -- says so in the record. This is what lets a site be installed from scratch --
1411
+ -- the earlier migrations seed a site's own starter pages, so a fresh database
1412
+ -- is never empty by the time this runs -- and it is not a way around the
1413
+ -- review, because the scan happens in this transaction, against this database:
1414
+ -- "there was nothing to decide" is not a claim an approval can make.
1415
+ --
1416
+ -- An approval that is present is honoured and checked in full either way.
1417
+ DO $$
1418
+ DECLARE
1419
+ _migration constant text := '20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24';
1420
+ -- Not a person, and shaped so it can never become one: .invalid is reserved
1421
+ -- precisely so that it never resolves.
1422
+ _unattended constant text := 'migration@cms-platform.invalid';
1423
+ -- Every setting an approval must arrive with. The setting
1424
+ -- 'database_identity' is not here and that is deliberate: it names the
1425
+ -- cluster, which is the deployment tooling's question, and this file asks a
1426
+ -- narrower one. See the identity check below.
1427
+ _required constant text[] := ARRAY[
1428
+ 'approval_nonce', 'site_key', 'environment', 'review_database_identity',
1429
+ 'release_sha', 'package_version', 'migration_name', 'migration_digest',
1430
+ 'run_id', 'input_fingerprint', 'manifest_digest', 'approval_expires_at',
1431
+ 'evaluated_at', 'decision_count'
1432
+ ];
1433
+ _at timestamptz := now();
1434
+ _live bigint;
1435
+ _needs bigint;
1436
+ _approved boolean;
1437
+ _identity text;
1438
+ _run_id uuid;
1439
+ _evaluated timestamptz;
1440
+ _expires timestamptz;
1441
+ _decisions jsonb := '[]'::jsonb;
1442
+ _live_fp text;
1443
+ _crossed bigint;
1444
+ _undecided text;
1445
+ _mismatched text;
1446
+ _unrestorable text;
1447
+ _guc text;
1448
+ _raw text;
1449
+ _missing text[] := ARRAY[]::text[];
1450
+ BEGIN
1451
+ -- Already done. A migration runner that loses its connection between the
1452
+ -- commit and its own bookkeeping will run this file again, and the second
1453
+ -- attempt must be a quiet no-op rather than a refusal: the review is a
1454
+ -- one-time crossing from the old reader to the new one, and once a run has
1455
+ -- reached 'applied' there is no longer an old reader to preserve anything
1456
+ -- from. Without this the retry reaches the fingerprint check and fails with
1457
+ -- "the pages changed after this review was approved" -- true, because the
1458
+ -- restorations changed them, but a frightening sentence to meet when in fact
1459
+ -- nothing is wrong.
1460
+ IF EXISTS (SELECT 1 FROM public.cms_publication_review_runs
1461
+ WHERE migration_name = _migration AND state = 'applied') THEN
1462
+ RAISE NOTICE 'contract v3: the publication review already ran here, so there is nothing to do';
1463
+ RETURN;
1464
+ END IF;
1465
+
1466
+ SELECT count(*) INTO _live FROM public.pages WHERE deleted_at IS NULL;
1467
+
1468
+ -- The identity an approval can name. cms_installation_id() is created by
1469
+ -- this very file, so on a first upgrade it did not exist when the scan ran
1470
+ -- and cannot be what the scan bound itself to. This is what a read-only role
1471
+ -- could see beforehand, and it changes when content is restored into a
1472
+ -- different database — which is the case worth catching.
1473
+ SELECT current_database() || '/' || d.oid::text INTO _identity
1474
+ FROM pg_database d WHERE d.datname = current_database();
1475
+
1476
+ -- The fingerprint of what is here. Both paths record it, and an approval is
1477
+ -- checked against it. Not "has anything changed" in the abstract, but
1478
+ -- everything either reader looks at, in one deterministic string, so that a
1479
+ -- page edited between the review and the upgrade is refused on arrival
1480
+ -- rather than settled from stale evidence. See the function for what is in
1481
+ -- it and why.
1482
+ _live_fp := public.cms_publication_review_fingerprint();
1483
+
1484
+ -- The scan itself, and it runs before anything has been decided, because
1485
+ -- whether this upgrade needs a person is a question only the scan can
1486
+ -- answer.
1487
+ --
1488
+ -- Into a temporary table rather than straight into the review items, because
1489
+ -- an item belongs to a run and the run cannot be written until the count
1490
+ -- below has chosen a path. ON COMMIT DROP, so a failure anywhere after this
1491
+ -- takes the scan with it and leaves nothing behind to be mistaken for a
1492
+ -- record.
1493
+ CREATE TEMP TABLE cms_v3_scan (
1494
+ page_id uuid,
1495
+ page_key text,
1496
+ classification text,
1497
+ requires_decision boolean,
1498
+ legacy_version_id uuid,
1499
+ v2_version_id uuid,
1500
+ legacy_from_publication boolean,
1501
+ governing_publication_id uuid,
1502
+ scheduled_version_id uuid,
1503
+ scheduled_publish_at timestamptz,
1504
+ ambiguous_order boolean,
1505
+ reason text,
1506
+ inputs jsonb
1507
+ ) ON COMMIT DROP;
1508
+
1509
+ INSERT INTO cms_v3_scan
1510
+ SELECT * FROM public.cms_publication_review_scan(_at);
1511
+
1512
+ -- Who has to be involved. A page that classified cleanly needs nobody; only
1513
+ -- the pages the data cannot speak for do. Where there are none, this is an
1514
+ -- ordinary schema change and is applied as one.
1515
+ SELECT count(*) FILTER (WHERE requires_decision) INTO _needs FROM cms_v3_scan;
1516
+
1517
+ -- An approval that was supplied is used even when the scan would have been
1518
+ -- content to proceed alone. Somebody who looked at this database and recorded
1519
+ -- an answer gets that answer applied and every check below run against it --
1520
+ -- including the one case the count above cannot see on its own, an operator
1521
+ -- who decided that a page the default would have kept alive should stay dark.
1522
+ _approved := _needs > 0
1523
+ OR nullif(current_setting('lovable.approval_nonce', true), '') IS NOT NULL;
1524
+
1525
+ IF _approved THEN
1526
+ -- Absent and empty are the same thing here. PGOPTIONS will happily carry a
1527
+ -- variable that was set to nothing, and a blank fingerprint must not read as
1528
+ -- a fingerprint that matched.
1529
+ FOREACH _guc IN ARRAY _required LOOP
1530
+ IF nullif(current_setting('lovable.' || _guc, true), '') IS NULL THEN
1531
+ _missing := _missing || _guc;
1532
+ END IF;
1533
+ END LOOP;
1534
+
1535
+ IF cardinality(_missing) > 0 THEN
1536
+ RAISE EXCEPTION
1537
+ 'this database has % live pages and no approved publication review; missing %',
1538
+ _live, array_to_string(_missing, ', ')
1539
+ USING ERRCODE = '42501',
1540
+ HINT = 'run cms-review scan, review what it found, then install-lovable-migration-approval';
1541
+ END IF;
1542
+
1543
+ -- Malformed is its own failure, separate from absent, because "the helper
1544
+ -- exported something unusable" and "the helper exported nothing" are
1545
+ -- different faults and get looked for in different places.
1546
+ _raw := current_setting('lovable.run_id', true);
1547
+ BEGIN
1548
+ _run_id := _raw::uuid;
1549
+ _evaluated := current_setting('lovable.evaluated_at', true)::timestamptz;
1550
+ _expires := current_setting('lovable.approval_expires_at', true)::timestamptz;
1551
+ PERFORM current_setting('lovable.approval_nonce', true)::uuid;
1552
+ EXCEPTION WHEN others THEN
1553
+ RAISE EXCEPTION 'this approval is not readable: %', SQLERRM USING ERRCODE = '22023';
1554
+ END;
1555
+
1556
+ IF _expires <= _at THEN
1557
+ RAISE EXCEPTION 'this approval expired at %', _expires
1558
+ USING ERRCODE = '42501',
1559
+ HINT = 'the database may have moved on since the scan; run cms-review scan again';
1560
+ END IF;
1561
+
1562
+ -- A review cannot have happened later than now. Worth refusing on its own
1563
+ -- terms, but the reason it matters here is the window-crossing check below:
1564
+ -- that check asks which windows moved between the review and now, and if the
1565
+ -- review claims to be in the future there is no such interval, so the check
1566
+ -- passes by looking at nothing. A skewed clock or an edited timestamp would
1567
+ -- switch off the one protection a fingerprint cannot provide.
1568
+ IF _evaluated > _at THEN
1569
+ RAISE EXCEPTION 'this approval says it was evaluated at %, which is in the future', _evaluated
1570
+ USING ERRCODE = '22023',
1571
+ HINT = 'check the clock on the machine that ran cms-review scan';
1572
+ END IF;
1573
+
1574
+ -- The name arrives as the file, because the other reader of an approval is
1575
+ -- a deployment script holding a path and comparing basenames. One trailing
1576
+ -- '.sql' is removed here rather than adding a second spelling of the name to
1577
+ -- the approval, since two spellings of one thing is how the two readers came
1578
+ -- to disagree in the first place. A name that never had the suffix still
1579
+ -- matches, so an approval made by an older helper is not orphaned.
1580
+ IF regexp_replace(coalesce(current_setting('lovable.migration_name', true), ''),
1581
+ '[.]sql$', '') <> _migration THEN
1582
+ RAISE EXCEPTION 'this approval is for migration %, not %',
1583
+ current_setting('lovable.migration_name', true), _migration
1584
+ USING ERRCODE = '42501';
1585
+ END IF;
1586
+
1587
+ -- The database, not the cluster. An approval also carries the cluster's
1588
+ -- identifier, under the setting 'database_identity', and the deployment
1589
+ -- tooling checks that one before it connects, because that is the most it
1590
+ -- can know from outside. From in here the sharper question is answerable
1591
+ -- and is the one worth asking: two databases in one cluster share an
1592
+ -- identifier and are not the same database to review. It is this answer
1593
+ -- the run keeps, in cms_publication_review_runs.review_database_identity.
1594
+ IF current_setting('lovable.review_database_identity', true) <> _identity THEN
1595
+ RAISE EXCEPTION 'this approval was made against database %, but this is %',
1596
+ current_setting('lovable.review_database_identity', true), _identity
1597
+ USING ERRCODE = '42501',
1598
+ HINT = 'a staging review cannot approve a production upgrade';
1599
+ END IF;
1600
+
1601
+ -- Recorded, not verified: nothing in this database states which release or
1602
+ -- package version is being deployed, so there is nothing here to check them
1603
+ -- against. The helper checked them against the deployment before it exported
1604
+ -- anything; this file writes them down so the run says which deployment it
1605
+ -- belonged to.
1606
+
1607
+ IF _live_fp <> current_setting('lovable.input_fingerprint', true) THEN
1608
+ RAISE EXCEPTION 'the pages changed after this review was approved'
1609
+ USING ERRCODE = '42501',
1610
+ DETAIL = format('approved %s, found %s',
1611
+ current_setting('lovable.input_fingerprint', true), _live_fp),
1612
+ HINT = 'run cms-review scan again against the current database';
1613
+ END IF;
1614
+
1615
+
1616
+ -- A fingerprint says the rows have not moved. It cannot say the clock has
1617
+ -- not: a window that opened or closed between the scan and now changes what
1618
+ -- a page serves without changing a single row, and the classification the
1619
+ -- operator approved would be describing a moment that has passed.
1620
+ SELECT count(*) INTO _crossed
1621
+ FROM public.page_publications pp
1622
+ WHERE pp.is_active
1623
+ AND ((pp.publish_at > _evaluated AND pp.publish_at <= _at)
1624
+ OR (pp.unpublish_at IS NOT NULL AND pp.unpublish_at > _evaluated AND pp.unpublish_at <= _at));
1625
+
1626
+ IF _crossed > 0 THEN
1627
+ RAISE EXCEPTION
1628
+ '% publication window(s) opened or closed between the review at % and now',
1629
+ _crossed, _evaluated
1630
+ USING ERRCODE = '42501',
1631
+ HINT = 'run cms-review scan again; the site is not showing what the review described';
1632
+ END IF;
1633
+
1634
+ INSERT INTO public.cms_publication_review_runs (
1635
+ id, approval_nonce, site_key, environment, review_database_identity,
1636
+ release_sha,
1637
+ package_version, migration_name, migration_digest, input_fingerprint,
1638
+ manifest_digest, evaluated_at, expected_page_count, state, source,
1639
+ classified_by_email, classified_at, approved_by_email, approved_at
1640
+ ) VALUES (
1641
+ _run_id,
1642
+ current_setting('lovable.approval_nonce', true)::uuid,
1643
+ current_setting('lovable.site_key', true),
1644
+ current_setting('lovable.environment', true),
1645
+ _identity,
1646
+ current_setting('lovable.release_sha', true),
1647
+ current_setting('lovable.package_version', true),
1648
+ _migration,
1649
+ current_setting('lovable.migration_digest', true),
1650
+ _live_fp,
1651
+ current_setting('lovable.manifest_digest', true),
1652
+ _evaluated, _live, 'applying', 'migration',
1653
+ coalesce(nullif(current_setting('lovable.classified_by_email', true), ''), _unattended), _evaluated,
1654
+ coalesce(nullif(current_setting('lovable.approved_by_email', true), ''), _unattended), _evaluated
1655
+ );
1656
+ ELSE
1657
+ -- Nothing here was ambiguous, so there is no approval to record and no
1658
+ -- person to name. The run is written anyway, with the real page count and
1659
+ -- the real fingerprint, because "this database was scanned and found
1660
+ -- clear" is exactly as much a fact about the upgrade as a signed review is,
1661
+ -- and the readiness check asks the same question of both.
1662
+ _run_id := gen_random_uuid();
1663
+ INSERT INTO public.cms_publication_review_runs (
1664
+ id, approval_nonce, site_key, environment, review_database_identity,
1665
+ release_sha,
1666
+ package_version, migration_name, migration_digest, input_fingerprint,
1667
+ manifest_digest, evaluated_at, expected_page_count, state, source,
1668
+ classified_by_email, classified_at, approved_by_email, approved_at
1669
+ ) VALUES (
1670
+ _run_id, gen_random_uuid(),
1671
+ coalesce(nullif(current_setting('lovable.site_key', true), ''), 'unattended'),
1672
+ coalesce(nullif(current_setting('lovable.environment', true), ''), 'unattended'),
1673
+ _identity,
1674
+ coalesce(nullif(current_setting('lovable.release_sha', true), ''), 'unattended'),
1675
+ coalesce(nullif(current_setting('lovable.package_version', true), ''), 'unattended'),
1676
+ _migration,
1677
+ coalesce(nullif(current_setting('lovable.migration_digest', true), ''), 'unattended'),
1678
+ _live_fp,
1679
+ coalesce(nullif(current_setting('lovable.manifest_digest', true), ''), 'unattended'),
1680
+ _at, _live, 'applying', 'migration',
1681
+ _unattended, _at, _unattended, _at
1682
+ );
1683
+ RAISE NOTICE
1684
+ 'contract v3: % live page(s) scanned, none of which needed a person', _live;
1685
+ END IF;
1686
+
1687
+ INSERT INTO public.cms_publication_review_items (
1688
+ run_id, page_id, page_key, classification, requires_decision,
1689
+ legacy_version_id, v2_version_id, legacy_from_publication,
1690
+ governing_publication_id, scheduled_version_id, scheduled_publish_at,
1691
+ ambiguous_order, reason, inputs
1692
+ )
1693
+ SELECT _run_id, page_id, page_key, classification, requires_decision,
1694
+ legacy_version_id, v2_version_id, legacy_from_publication,
1695
+ governing_publication_id, scheduled_version_id, scheduled_publish_at,
1696
+ ambiguous_order, reason, inputs
1697
+ FROM cms_v3_scan;
1698
+
1699
+ -- The decisions a person made about what they saw. They travel as canonical
1700
+ -- JSON in a GUC alongside their digest, so a payload edited between the
1701
+ -- approval and the upgrade is a refusal rather than a surprise.
1702
+ --
1703
+ -- Only departures from the standing default travel this way. The default is
1704
+ -- "keep showing what the site shows today", which the migration can apply on
1705
+ -- its own; what it cannot know is when somebody decided otherwise.
1706
+ -- Only where an approval was checked. On the unattended path nothing has
1707
+ -- been bound to this database or to this scan, so a decision list arriving
1708
+ -- in a startup option would be an unsigned instruction, and is not read.
1709
+ _raw := CASE WHEN _approved
1710
+ THEN nullif(current_setting('lovable.decisions', true), '') END;
1711
+ IF _raw IS NOT NULL THEN
1712
+ BEGIN
1713
+ _decisions := _raw::jsonb;
1714
+ EXCEPTION WHEN others THEN
1715
+ RAISE EXCEPTION 'the approved decisions are not readable json' USING ERRCODE = '22023';
1716
+ END;
1717
+
1718
+ IF jsonb_typeof(_decisions) <> 'array' THEN
1719
+ RAISE EXCEPTION 'the approved decisions must be a json array, not %',
1720
+ jsonb_typeof(_decisions) USING ERRCODE = '22023';
1721
+ END IF;
1722
+
1723
+ IF public.cms_digest(_decisions)
1724
+ IS DISTINCT FROM nullif(current_setting('lovable.decisions_digest', true), '') THEN
1725
+ RAISE EXCEPTION 'the approved decisions do not match the digest that was signed'
1726
+ USING ERRCODE = '42501',
1727
+ HINT = 'the decision list was edited after the approval was installed';
1728
+ END IF;
1729
+ END IF;
1730
+
1731
+ -- The count exists for the reader that has no json parser: a shell script on
1732
+ -- a server can say how many answers it is about to install, but only if
1733
+ -- somebody counted for it. Checked because a number nobody verifies is a
1734
+ -- number that will eventually be wrong, and it would be wrong in the one
1735
+ -- place an operator was relying on it to be right. Outside the block above on
1736
+ -- purpose: an approval claiming three decisions and carrying no decision
1737
+ -- setting at all is exactly the case worth catching, and _decisions is the
1738
+ -- empty list until something fills it.
1739
+ IF _approved AND current_setting('lovable.decision_count', true)
1740
+ IS DISTINCT FROM jsonb_array_length(_decisions)::text THEN
1741
+ RAISE EXCEPTION 'this approval says it carries % decision(s) but carries %',
1742
+ current_setting('lovable.decision_count', true), jsonb_array_length(_decisions)
1743
+ USING ERRCODE = '22023';
1744
+ END IF;
1745
+
1746
+ -- A decision is an answer to a specific classification, not to a page. If
1747
+ -- the page classified differently this time, the answer is about a question
1748
+ -- nobody asked, and applying it would be guesswork wearing a signature.
1749
+ SELECT string_agg(d.page_id::text, ', ' ORDER BY d.page_id::text) INTO _mismatched
1750
+ FROM jsonb_to_recordset(_decisions)
1751
+ AS d(page_id uuid, decision text, expected_inputs_digest text,
1752
+ resolved_by_email text, note text)
1753
+ LEFT JOIN public.cms_publication_review_items i
1754
+ ON i.run_id = _run_id AND i.page_id = d.page_id
1755
+ WHERE i.id IS NULL OR i.inputs_digest IS DISTINCT FROM d.expected_inputs_digest;
1756
+
1757
+ IF _mismatched IS NOT NULL THEN
1758
+ RAISE EXCEPTION 'these pages classified differently than when they were reviewed: %',
1759
+ _mismatched
1760
+ USING ERRCODE = '42501',
1761
+ HINT = 'run cms-review scan again and review the pages that changed';
1762
+ END IF;
1763
+
1764
+ INSERT INTO public.cms_publication_review_decisions (
1765
+ item_id, run_id, page_id, decision, expected_inputs_digest, source,
1766
+ resolved_by_email, note
1767
+ )
1768
+ SELECT i.id, i.run_id, i.page_id, d.decision, d.expected_inputs_digest, 'migration',
1769
+ coalesce(nullif(d.resolved_by_email, ''), _unattended), d.note
1770
+ FROM jsonb_to_recordset(_decisions)
1771
+ AS d(page_id uuid, decision text, expected_inputs_digest text,
1772
+ resolved_by_email text, note text)
1773
+ JOIN public.cms_publication_review_items i
1774
+ ON i.run_id = _run_id AND i.page_id = d.page_id;
1775
+
1776
+ -- Fail closed. A page whose classification says "nobody can tell what this
1777
+ -- was showing" does not get an answer invented for it at three in the
1778
+ -- morning; the upgrade stops and waits for a person.
1779
+ SELECT string_agg(i.page_key, ', ' ORDER BY i.page_key) INTO _undecided
1780
+ FROM public.cms_publication_review_items i
1781
+ LEFT JOIN public.cms_publication_review_decisions d ON d.item_id = i.id
1782
+ WHERE i.run_id = _run_id AND i.requires_decision AND d.id IS NULL;
1783
+
1784
+ IF _undecided IS NOT NULL THEN
1785
+ RAISE EXCEPTION 'these pages still need a decision before the upgrade can proceed: %',
1786
+ _undecided
1787
+ USING ERRCODE = '42501',
1788
+ HINT = 'cms-review resolve records what to do with each of them';
1789
+ END IF;
1790
+
1791
+ -- A decision to republish is only honourable if the version it names is
1792
+ -- actually this page's. On a structurally invalid page the legacy version is
1793
+ -- itself the corrupt datum -- it may belong to another page or have been
1794
+ -- deleted -- and writing it into a fresh publication row would carry the
1795
+ -- fault forward under a note claiming the page was preserved. Refuse instead,
1796
+ -- so the reviewer is told their answer cannot be carried out rather than
1797
+ -- being shown a restoration that quietly did the wrong thing.
1798
+ SELECT string_agg(i.page_key, ', ' ORDER BY i.page_key) INTO _unrestorable
1799
+ FROM public.cms_publication_review_items i
1800
+ LEFT JOIN public.cms_publication_review_decisions d ON d.item_id = i.id
1801
+ LEFT JOIN public.page_versions v ON v.id = i.legacy_version_id
1802
+ WHERE i.run_id = _run_id
1803
+ AND i.legacy_version_id IS NOT NULL
1804
+ AND (d.decision = 'publish-legacy-version'
1805
+ OR (d.id IS NULL AND i.classification = 'preserved'))
1806
+ AND (v.id IS NULL OR v.page_id <> i.page_id);
1807
+
1808
+ IF _unrestorable IS NOT NULL THEN
1809
+ RAISE EXCEPTION 'these pages cannot be republished, because the version recorded for them does not belong to them: %',
1810
+ _unrestorable
1811
+ USING ERRCODE = '22023',
1812
+ HINT = 'leave-hidden records the outage honestly; repairing the version pointer is a separate, deliberate change';
1813
+ END IF;
1814
+
1815
+ -- Making it true. A restoration writes a NEW publication row rather than
1816
+ -- reopening the expired one: the old row is the record of a window that
1817
+ -- genuinely closed, and editing it would destroy the only evidence of what
1818
+ -- the site did before the upgrade. The new row is dated now, so it governs
1819
+ -- under the new reader, and it says in its own note which run put it there.
1820
+ INSERT INTO public.page_publications (
1821
+ page_id, version_id, publish_at, unpublish_at, is_active, published_at, note
1822
+ )
1823
+ SELECT i.page_id, i.legacy_version_id, _at, NULL, true, _at,
1824
+ format('contract v3 review %s: kept serving the version this page was showing', _run_id)
1825
+ FROM public.cms_publication_review_items i
1826
+ LEFT JOIN public.cms_publication_review_decisions d ON d.item_id = i.id
1827
+ WHERE i.run_id = _run_id
1828
+ AND i.legacy_version_id IS NOT NULL
1829
+ AND (d.decision = 'publish-legacy-version'
1830
+ OR (d.id IS NULL AND i.classification = 'preserved'));
1831
+ END
1832
+ $$;
1833
+
1834
+ -- 9. Every mutation takes the same locks in the same order -------------------
1835
+ --
1836
+ -- Two editors, two pages, one deadlock. `cms_save_page_version` locks the page
1837
+ -- it is saving and then — still holding that lock — calls
1838
+ -- `cms_schedule_page_reveal`, which locks the page the countdown names. An
1839
+ -- editor publishing A with a countdown revealing B takes A and then B. An
1840
+ -- editor publishing B with a countdown revealing A takes B and then A. Run the
1841
+ -- two in the same second and PostgreSQL kills one of them with a deadlock
1842
+ -- error, which is not something the person on the other end can act on.
1843
+ --
1844
+ -- None of this is hypothetical. The two nested reveal call sites are in the
1845
+ -- published migration 20260904160000, at lines 268 and 675; and
1846
+ -- `cms_cancel_page_publication` compounds the problem at lines 541 and 546 by
1847
+ -- locking a publication row before the page that owns it, which is the reverse
1848
+ -- of the order every one of its siblings uses.
1849
+ --
1850
+ -- The fix is one rule, written out the same way in every function that writes:
1851
+ --
1852
+ -- 1. Work out the complete set of pages the call can touch, before taking a
1853
+ -- single lock.
1854
+ -- 2. Lock that whole set through `app.cms_lock_pages`, which orders it by
1855
+ -- id — the UUID's own byte order: total, deterministic, and the same
1856
+ -- order in every session of every installation.
1857
+ -- 3. Read the pages again under the lock, and decide everything from what is
1858
+ -- there now. A key that named a different page a moment ago aborts the
1859
+ -- call with SQLSTATE 40001, the one code client libraries already know to
1860
+ -- retry.
1861
+ -- 4. Only then publication rows, and only in id order.
1862
+ --
1863
+ -- Two sessions that want the same pages queue at the same first row instead of
1864
+ -- meeting head-on, so the second one waits rather than dies.
1865
+ --
1866
+ -- There is no `_locks_held` flag and no "internal" twin of any function: a flag
1867
+ -- the caller can set is a caller-controlled bypass of the ordering, which is
1868
+ -- the whole of what is being protected. It is not needed, either. PostgreSQL
1869
+ -- row locks are reentrant within a transaction, so when `cms_save_page_version`
1870
+ -- has already locked A and B, the `cms_schedule_page_reveal` it calls locks A
1871
+ -- and B again and waits for nothing. The nested call still runs the whole
1872
+ -- collect-lock-revalidate; under its caller's locks that revalidation cannot
1873
+ -- fail, and that is exactly the property which makes nesting safe.
1874
+ --
1875
+ -- Every signature below is unchanged, so every body is a replacement rather
1876
+ -- than a new function. `CREATE OR REPLACE` keeps the EXECUTE grants migrations
1877
+ -- 20260904140000 and 20260904160000 wrote, and the rollback rows the review
1878
+ -- records can name the published bodies precisely because nothing was dropped.
1879
+
1880
+ CREATE OR REPLACE FUNCTION public.cms_save_page_version(
1881
+ _page_id uuid,
1882
+ _blocks jsonb,
1883
+ _note text DEFAULT NULL,
1884
+ _publish boolean DEFAULT false,
1885
+ _actor_email text DEFAULT NULL,
1886
+ -- `[{ "key": "...", "publishAt": "...", "description": "..." }]` — the pages
1887
+ -- a countdown on this page reveals when its timer reaches zero.
1888
+ _reveals jsonb DEFAULT '[]'::jsonb,
1889
+ _audit jsonb DEFAULT '{}'::jsonb
1890
+ )
1891
+ RETURNS jsonb
1892
+ LANGUAGE plpgsql
1893
+ SECURITY DEFINER
1894
+ SET search_path = public, pg_temp
1895
+ AS $$
1896
+ DECLARE
1897
+ _uid uuid := app.current_user_id();
1898
+ _email text;
1899
+ _page public.pages;
1900
+ _next_no integer;
1901
+ _version_id uuid;
1902
+ _reveal jsonb;
1903
+ _scheduled jsonb := '[]'::jsonb;
1904
+ _keys text[];
1905
+ _targets jsonb;
1906
+ _relocked jsonb;
1907
+ BEGIN
1908
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
1909
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
1910
+ END IF;
1911
+ IF _page_id IS NULL THEN
1912
+ RAISE EXCEPTION 'Page not found' USING ERRCODE = 'P0002';
1913
+ END IF;
1914
+
1915
+ -- Step 1. Every page this call can reach, worked out before anything is
1916
+ -- locked. A reveal is only ever acted on by a publish, so only a publish
1917
+ -- widens the set past the page being saved.
1918
+ _keys := ARRAY(
1919
+ SELECT DISTINCT btrim(r.value->>'key')
1920
+ FROM jsonb_array_elements(
1921
+ CASE WHEN _publish THEN COALESCE(_reveals, '[]'::jsonb)
1922
+ ELSE '[]'::jsonb END) AS r(value)
1923
+ WHERE btrim(COALESCE(r.value->>'key', '')) <> '');
1924
+
1925
+ SELECT COALESCE(jsonb_object_agg(p.key, p.id), '{}'::jsonb) INTO _targets
1926
+ FROM public.pages p
1927
+ WHERE p.key = ANY (_keys) AND p.deleted_at IS NULL;
1928
+
1929
+ -- Step 2. One ordered lock over the whole set, taken once.
1930
+ PERFORM app.cms_lock_pages(
1931
+ ARRAY[_page_id]
1932
+ || ARRAY(SELECT t.v::uuid FROM jsonb_each_text(_targets) AS t(k, v)));
1933
+
1934
+ -- Step 3. Ask the same question again, now that the answer cannot change.
1935
+ -- A key that has moved means somebody else's rename landed between the two
1936
+ -- statements: there is no honest answer to give, so this says "try again" in
1937
+ -- the one SQLSTATE a client already retries, and writes nothing.
1938
+ SELECT COALESCE(jsonb_object_agg(p.key, p.id), '{}'::jsonb) INTO _relocked
1939
+ FROM public.pages p
1940
+ WHERE p.key = ANY (_keys) AND p.deleted_at IS NULL;
1941
+ IF _relocked IS DISTINCT FROM _targets THEN
1942
+ RAISE EXCEPTION
1943
+ 'A page this save reveals was renamed, deleted or created while the save '
1944
+ 'was waiting for its locks. Nothing was written; try again.'
1945
+ USING ERRCODE = '40001';
1946
+ END IF;
1947
+
1948
+ SELECT * INTO _page FROM public.pages
1949
+ WHERE id = _page_id AND deleted_at IS NULL;
1950
+ IF _page.id IS NULL THEN
1951
+ RAISE EXCEPTION 'Page not found' USING ERRCODE = 'P0002';
1952
+ END IF;
1953
+
1954
+ _email := public.cms_actor_email(_uid, _actor_email);
1955
+
1956
+ SELECT COALESCE(MAX(version_no), 0) + 1 INTO _next_no
1957
+ FROM public.page_versions WHERE page_id = _page_id;
1958
+
1959
+ INSERT INTO public.page_versions
1960
+ (page_id, version_no, blocks, note, created_by, created_by_email)
1961
+ VALUES
1962
+ (_page_id, _next_no, COALESCE(_blocks, '[]'::jsonb), _note, _uid, _email)
1963
+ RETURNING id INTO _version_id;
1964
+
1965
+ UPDATE public.pages
1966
+ SET current_version_id = _version_id,
1967
+ updated_at = now(),
1968
+ updated_by = _uid
1969
+ WHERE id = _page_id;
1970
+
1971
+ IF _publish THEN
1972
+ -- Step 4. Publication rows, and only now. The page tier above has already
1973
+ -- decided which session gets here first; ordering inside this tier is what
1974
+ -- keeps two sessions holding the same pages from crossing each other over
1975
+ -- the rows beneath them.
1976
+ PERFORM id FROM public.page_publications
1977
+ WHERE page_id = _page_id AND is_active
1978
+ ORDER BY id
1979
+ FOR UPDATE;
1980
+
1981
+ UPDATE public.page_publications
1982
+ SET is_active = false
1983
+ WHERE page_id = _page_id AND is_active;
1984
+
1985
+ INSERT INTO public.page_publications (page_id, version_id, is_active, published_by)
1986
+ VALUES (_page_id, _version_id, true, _uid);
1987
+
1988
+ UPDATE public.pages SET status = 'published' WHERE id = _page_id;
1989
+
1990
+ -- The audit row belongs to the same transaction as the publication it
1991
+ -- describes. Publishing from the builder is the route most editors take,
1992
+ -- and until now it was the one that left no trace.
1993
+ INSERT INTO public.audit_logs (actor_id, actor_email, action, entity, entity_id, meta)
1994
+ VALUES (
1995
+ _uid, _email, 'page.publish', 'pages', _page_id::text,
1996
+ COALESCE(_audit, '{}'::jsonb)
1997
+ || jsonb_build_object('key', _page.key, 'status', 'published',
1998
+ 'versionNo', _next_no, 'versionId', _version_id)
1999
+ );
2000
+
2001
+ /*
2002
+ * Reveals are part of the publish, not a follow-up to it. Scheduling them
2003
+ * here — through the very function the countdown path already used, so
2004
+ * there is one implementation of a reveal and not two — means a target
2005
+ * that cannot be scheduled takes the whole publish down with it instead of
2006
+ * leaving a page live and reporting failure. A countdown naming a page
2007
+ * that was renamed or never written to is an editing mistake rather than a
2008
+ * failure: that function reports it scheduled nothing, and the save
2009
+ * carries on. Every page it can reach is already locked, so the call below
2010
+ * re-locks rows this transaction holds and waits for nothing.
2011
+ */
2012
+ FOR _reveal IN SELECT value FROM jsonb_array_elements(COALESCE(_reveals, '[]'::jsonb))
2013
+ LOOP
2014
+ _scheduled := _scheduled || jsonb_build_array(
2015
+ jsonb_build_object('key', _reveal->>'key')
2016
+ || public.cms_schedule_page_reveal(
2017
+ _reveal->>'key',
2018
+ (_reveal->>'publishAt')::timestamptz,
2019
+ COALESCE(_audit, '{}'::jsonb)
2020
+ || jsonb_build_object('description', _reveal->>'description'),
2021
+ _actor_email,
2022
+ -- Named, so a countdown pointing at the page being published is
2023
+ -- refused instead of hiding it until its own deadline.
2024
+ _page_id,
2025
+ -- Publishing now means the countdown starts now, so a deadline
2026
+ -- already past is refused here for the same reason it is on the
2027
+ -- scheduled path: it would reveal the target before anyone
2028
+ -- could see the countdown that was supposed to reveal it.
2029
+ now()
2030
+ )
2031
+ );
2032
+ END LOOP;
2033
+ END IF;
2034
+
2035
+ RETURN jsonb_build_object(
2036
+ 'versionId', _version_id,
2037
+ 'versionNo', _next_no,
2038
+ 'published', _publish,
2039
+ 'reveals', _scheduled
2040
+ );
2041
+ END;
2042
+ $$;
2043
+
2044
+ -- One page is still a set, and it goes through the same door. Writing the
2045
+ -- single-page case by hand would leave two lock orders in the schema — the one
2046
+ -- described above, and whatever this function happened to do — and the second
2047
+ -- one is the sort of thing that stays right until somebody adds a second page
2048
+ -- to it.
2049
+ CREATE OR REPLACE FUNCTION public.cms_set_page_status(
2050
+ _page_id uuid,
2051
+ _status text,
2052
+ _audit jsonb DEFAULT '{}'::jsonb,
2053
+ _actor_email text DEFAULT NULL
2054
+ )
2055
+ RETURNS jsonb
2056
+ LANGUAGE plpgsql
2057
+ SECURITY DEFINER
2058
+ SET search_path = public, pg_temp
2059
+ AS $$
2060
+ DECLARE
2061
+ _uid uuid := app.current_user_id();
2062
+ _page public.pages;
2063
+ _next text := lower(btrim(COALESCE(_status, '')));
2064
+ BEGIN
2065
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
2066
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
2067
+ END IF;
2068
+ IF _next NOT IN ('draft', 'published') THEN
2069
+ RAISE EXCEPTION 'Unknown page status %', _status USING ERRCODE = '22023';
2070
+ END IF;
2071
+ IF _page_id IS NULL THEN
2072
+ RAISE EXCEPTION 'Page not found' USING ERRCODE = 'P0002';
2073
+ END IF;
2074
+
2075
+ PERFORM app.cms_lock_pages(ARRAY[_page_id]);
2076
+
2077
+ SELECT * INTO _page FROM public.pages
2078
+ WHERE id = _page_id AND deleted_at IS NULL;
2079
+ IF _page.id IS NULL THEN
2080
+ RAISE EXCEPTION 'Page not found' USING ERRCODE = 'P0002';
2081
+ END IF;
2082
+ IF _next = 'published' AND _page.current_version_id IS NULL THEN
2083
+ RAISE EXCEPTION 'This page has no saved content yet, so there is nothing to publish.'
2084
+ USING ERRCODE = '22023';
2085
+ END IF;
2086
+
2087
+ PERFORM id FROM public.page_publications
2088
+ WHERE page_id = _page_id AND is_active
2089
+ ORDER BY id
2090
+ FOR UPDATE;
2091
+
2092
+ UPDATE public.page_publications
2093
+ SET is_active = false
2094
+ WHERE page_id = _page_id AND is_active;
2095
+
2096
+ IF _next = 'published' THEN
2097
+ INSERT INTO public.page_publications (page_id, version_id, is_active, published_by)
2098
+ VALUES (_page_id, _page.current_version_id, true, _uid);
2099
+ END IF;
2100
+
2101
+ UPDATE public.pages SET status = _next, updated_at = now(), updated_by = _uid
2102
+ WHERE id = _page_id;
2103
+
2104
+ -- The audit row belongs to the same transaction: a publish nobody can account
2105
+ -- for afterwards is its own kind of half-applied write.
2106
+ INSERT INTO public.audit_logs (actor_id, actor_email, action, entity, entity_id, meta)
2107
+ VALUES (
2108
+ _uid,
2109
+ public.cms_actor_email(_uid, _actor_email),
2110
+ CASE WHEN _next = 'published' THEN 'page.publish' ELSE 'page.unpublish' END,
2111
+ 'pages',
2112
+ _page_id::text,
2113
+ COALESCE(_audit, '{}'::jsonb) || jsonb_build_object('key', _page.key, 'status', _next)
2114
+ );
2115
+
2116
+ RETURN jsonb_build_object('status', _next, 'key', _page.key);
2117
+ END;
2118
+ $$;
2119
+
2120
+ -- The reveal is where the cycle used to close: it locked its target and nothing
2121
+ -- else, so whichever page its caller was already holding became the second edge
2122
+ -- of the graph. Locking source and target together, in id order, is what turns
2123
+ -- that edge into a queue. Called on its own the source is NULL and the set is
2124
+ -- one page; called from a save or a schedule both are already held, and this
2125
+ -- costs a lock lookup and nothing else.
2126
+ CREATE OR REPLACE FUNCTION public.cms_schedule_page_reveal(
2127
+ _page_key text,
2128
+ _publish_at timestamptz,
2129
+ _audit jsonb DEFAULT '{}'::jsonb,
2130
+ _actor_email text DEFAULT NULL,
2131
+ -- The page whose countdown asks for this, so a countdown pointed at its own
2132
+ -- page can be recognised before anything is written.
2133
+ _source_page_id uuid DEFAULT NULL,
2134
+ -- When that page itself becomes public, for a reveal being scheduled
2135
+ -- alongside a future publication. NULL when the source is going live now.
2136
+ _source_publish_at timestamptz DEFAULT NULL
2137
+ )
2138
+ RETURNS jsonb
2139
+ LANGUAGE plpgsql
2140
+ SECURITY DEFINER
2141
+ SET search_path = public, pg_temp
2142
+ AS $$
2143
+ DECLARE
2144
+ _uid uuid := app.current_user_id();
2145
+ _page public.pages;
2146
+ _governing public.page_publications;
2147
+ _wanted text := btrim(COALESCE(_page_key, ''));
2148
+ _target uuid;
2149
+ _relocked uuid;
2150
+ BEGIN
2151
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
2152
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
2153
+ END IF;
2154
+ IF _publish_at IS NULL THEN
2155
+ RAISE EXCEPTION 'A reveal needs an instant to happen at' USING ERRCODE = '22023';
2156
+ END IF;
2157
+
2158
+ SELECT p.id INTO _target FROM public.pages p
2159
+ WHERE p.key = _wanted AND p.deleted_at IS NULL;
2160
+
2161
+ PERFORM app.cms_lock_pages(
2162
+ ARRAY(SELECT x FROM unnest(ARRAY[_source_page_id, _target]) AS u(x)
2163
+ WHERE x IS NOT NULL));
2164
+
2165
+ SELECT p.id INTO _relocked FROM public.pages p
2166
+ WHERE p.key = _wanted AND p.deleted_at IS NULL;
2167
+ IF _relocked IS DISTINCT FROM _target THEN
2168
+ RAISE EXCEPTION
2169
+ 'The page "%" was renamed, deleted or created while this reveal was '
2170
+ 'waiting for its locks. Nothing was written; try again.', _wanted
2171
+ USING ERRCODE = '40001';
2172
+ END IF;
2173
+
2174
+ SELECT * INTO _page FROM public.pages WHERE id = _target;
2175
+
2176
+ -- A countdown may name a page that was renamed, deleted or never written to.
2177
+ -- That is an editing mistake, not a database error: say nothing happened.
2178
+ IF _page.id IS NULL OR _page.current_version_id IS NULL THEN
2179
+ RETURN jsonb_build_object('scheduled', false, 'reason', 'no-such-page');
2180
+ END IF;
2181
+
2182
+ -- Checked before any write, because the write this one would do is the one
2183
+ -- that hides the page the editor is publishing.
2184
+ IF _source_page_id IS NOT NULL AND _page.id = _source_page_id THEN
2185
+ RAISE EXCEPTION
2186
+ 'The countdown on "%" is set to reveal the page it is on. A page cannot '
2187
+ 'reveal itself: point the countdown at another page, or change what it '
2188
+ 'does when the timer reaches zero.', _page.key
2189
+ USING ERRCODE = '22023';
2190
+ END IF;
2191
+
2192
+ -- A reveal that fires before the page carrying its countdown appears would
2193
+ -- publish the target while the countdown is still invisible.
2194
+ IF _source_publish_at IS NOT NULL AND _publish_at <= _source_publish_at THEN
2195
+ RAISE EXCEPTION
2196
+ 'The countdown revealing "%" reaches zero at %, at or before the page '
2197
+ 'carrying it goes live at %. Move the countdown deadline after the '
2198
+ 'publication time.', _page.key, _publish_at, _source_publish_at
2199
+ USING ERRCODE = '22023';
2200
+ END IF;
2201
+
2202
+ -- Publication rows, after the pages and in id order.
2203
+ PERFORM id FROM public.page_publications
2204
+ WHERE page_id = _page.id AND is_active
2205
+ ORDER BY id
2206
+ FOR UPDATE;
2207
+
2208
+ -- The same rule the public reader uses: the newest active row whose start
2209
+ -- has arrived governs, and the page is public while that row is open.
2210
+ SELECT * INTO _governing FROM public.page_publications
2211
+ WHERE page_id = _page.id AND is_active AND publish_at <= now()
2212
+ ORDER BY publish_at DESC, published_at DESC
2213
+ LIMIT 1;
2214
+ IF _governing.id IS NOT NULL
2215
+ AND (_governing.unpublish_at IS NULL OR _governing.unpublish_at > now()) THEN
2216
+ RAISE EXCEPTION
2217
+ 'The page "%" is already on the public website, so a countdown cannot '
2218
+ 'reveal it. Unpublish it first, or point the countdown at a page that is '
2219
+ 'still hidden.', _page.key
2220
+ USING ERRCODE = '22023';
2221
+ END IF;
2222
+
2223
+ -- Saving the same page twice must not pile up identical reveals.
2224
+ IF EXISTS (
2225
+ SELECT 1 FROM public.page_publications pp
2226
+ WHERE pp.page_id = _page.id
2227
+ AND pp.version_id = _page.current_version_id
2228
+ AND pp.publish_at = _publish_at
2229
+ AND pp.is_active
2230
+ ) THEN
2231
+ RETURN jsonb_build_object('scheduled', false, 'reason', 'already-scheduled',
2232
+ 'pageId', _page.id);
2233
+ END IF;
2234
+
2235
+ -- Whatever is retired here is either waiting or finished: a governing row
2236
+ -- that is still open was refused above, so this cannot take a page offline.
2237
+ UPDATE public.page_publications
2238
+ SET is_active = false
2239
+ WHERE page_id = _page.id AND is_active;
2240
+
2241
+ INSERT INTO public.page_publications
2242
+ (page_id, version_id, publish_at, is_active, published_by)
2243
+ VALUES
2244
+ (_page.id, _page.current_version_id, _publish_at, true, _uid);
2245
+
2246
+ INSERT INTO public.audit_logs (actor_id, actor_email, action, entity, entity_id, meta)
2247
+ VALUES (
2248
+ _uid, public.cms_actor_email(_uid, _actor_email), 'page.schedule', 'pages', _page.id::text,
2249
+ COALESCE(_audit, '{}'::jsonb) || jsonb_build_object('publish_at', _publish_at)
2250
+ );
2251
+
2252
+ RETURN jsonb_build_object('scheduled', true, 'pageId', _page.id);
2253
+ END;
2254
+ $$;
2255
+
2256
+ -- The inversion. This function read its publication row `FOR UPDATE` and only
2257
+ -- afterwards locked the page that owns it — publications before pages, the
2258
+ -- reverse of everything else in the schema, and enough on its own to deadlock
2259
+ -- against any sibling that was doing it the right way round. The publication is
2260
+ -- read once without a lock, purely to learn which page to lock; the page goes
2261
+ -- first; and only then is the row read again, locked, and judged.
2262
+ CREATE OR REPLACE FUNCTION public.cms_cancel_page_publication(
2263
+ _publication_id uuid,
2264
+ _audit jsonb DEFAULT '{}'::jsonb,
2265
+ _actor_email text DEFAULT NULL
2266
+ )
2267
+ RETURNS jsonb
2268
+ LANGUAGE plpgsql
2269
+ SECURITY DEFINER
2270
+ SET search_path = public, pg_temp
2271
+ AS $$
2272
+ DECLARE
2273
+ _uid uuid := app.current_user_id();
2274
+ _pub public.page_publications;
2275
+ _page public.pages;
2276
+ _owner uuid;
2277
+ BEGIN
2278
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
2279
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
2280
+ END IF;
2281
+
2282
+ SELECT pp.page_id INTO _owner FROM public.page_publications pp
2283
+ WHERE pp.id = _publication_id;
2284
+ IF _owner IS NULL THEN
2285
+ RAISE EXCEPTION 'Publication not found' USING ERRCODE = 'P0002';
2286
+ END IF;
2287
+
2288
+ PERFORM app.cms_lock_pages(ARRAY[_owner]);
2289
+
2290
+ SELECT * INTO _pub FROM public.page_publications
2291
+ WHERE id = _publication_id
2292
+ FOR UPDATE;
2293
+ IF _pub.id IS NULL THEN
2294
+ RAISE EXCEPTION 'Publication not found' USING ERRCODE = 'P0002';
2295
+ END IF;
2296
+ -- Nothing in this schema moves a publication between pages, so this can only
2297
+ -- be something nothing here wrote. Refusing is cheaper than reasoning about
2298
+ -- it, and 40001 says the one useful thing: read it again.
2299
+ IF _pub.page_id IS DISTINCT FROM _owner THEN
2300
+ RAISE EXCEPTION
2301
+ 'This publication changed pages while the cancellation was waiting for '
2302
+ 'its locks. Nothing was written; try again.'
2303
+ USING ERRCODE = '40001';
2304
+ END IF;
2305
+
2306
+ SELECT * INTO _page FROM public.pages WHERE id = _pub.page_id;
2307
+
2308
+ -- Cancelling twice is the same wish twice, not an error worth failing a
2309
+ -- screen over. Nothing is written and nothing is logged.
2310
+ IF NOT _pub.is_active THEN
2311
+ RETURN jsonb_build_object('cancelled', false, 'reason', 'already-cancelled',
2312
+ 'pageId', _pub.page_id);
2313
+ END IF;
2314
+
2315
+ IF _pub.publish_at <= now() THEN
2316
+ RAISE EXCEPTION
2317
+ 'This publication has already started, so cancelling it would take "%" '
2318
+ 'off the public website — possibly putting an older version back. Use '
2319
+ 'Unpublish for that instead.', _page.key
2320
+ USING ERRCODE = '22023';
2321
+ END IF;
2322
+
2323
+ UPDATE public.page_publications
2324
+ SET is_active = false
2325
+ WHERE id = _publication_id;
2326
+
2327
+ INSERT INTO public.audit_logs (actor_id, actor_email, action, entity, entity_id, meta)
2328
+ VALUES (
2329
+ _uid,
2330
+ public.cms_actor_email(_uid, _actor_email),
2331
+ 'page.schedule.cancel',
2332
+ 'pages',
2333
+ _pub.page_id::text,
2334
+ COALESCE(_audit, '{}'::jsonb)
2335
+ || jsonb_build_object('key', _page.key, 'publicationId', _publication_id,
2336
+ 'publish_at', _pub.publish_at,
2337
+ 'unpublish_at', _pub.unpublish_at)
2338
+ );
2339
+
2340
+ RETURN jsonb_build_object('cancelled', true, 'pageId', _pub.page_id, 'key', _page.key);
2341
+ END;
2342
+ $$;
2343
+
2344
+ -- The scheduled path reaches the same second page the immediate one does, and
2345
+ -- reached it the same way: page first, reveal target second. The version is
2346
+ -- read without a lock to learn which page it belongs to, that page and every
2347
+ -- reveal target are locked together, and the version is then read again — a
2348
+ -- version that changed pages under us is not something this schema does, so it
2349
+ -- is refused rather than reasoned about.
2350
+ CREATE OR REPLACE FUNCTION public.cms_schedule_page_version(
2351
+ _version_id uuid,
2352
+ _publish_at timestamptz,
2353
+ _unpublish_at timestamptz DEFAULT NULL,
2354
+ _note text DEFAULT NULL,
2355
+ -- `[{ "key": "...", "publishAt": "...", "description": "..." }]`, read off
2356
+ -- the blocks of the version being scheduled.
2357
+ _reveals jsonb DEFAULT '[]'::jsonb,
2358
+ _audit jsonb DEFAULT '{}'::jsonb,
2359
+ _actor_email text DEFAULT NULL
2360
+ )
2361
+ RETURNS jsonb
2362
+ LANGUAGE plpgsql
2363
+ SECURITY DEFINER
2364
+ SET search_path = public, pg_temp
2365
+ AS $$
2366
+ DECLARE
2367
+ _uid uuid := app.current_user_id();
2368
+ _version public.page_versions;
2369
+ _page public.pages;
2370
+ _publication_id uuid;
2371
+ _reveal jsonb;
2372
+ _scheduled jsonb := '[]'::jsonb;
2373
+ _owner uuid;
2374
+ _keys text[];
2375
+ _targets jsonb;
2376
+ _relocked jsonb;
2377
+ BEGIN
2378
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
2379
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
2380
+ END IF;
2381
+
2382
+ -- The browser control offers a wall clock and a calendar; nothing stops a
2383
+ -- caller sending something else, and a window that ends before it begins is
2384
+ -- a page that is never public and a schedule nobody can read.
2385
+ IF _publish_at IS NULL THEN
2386
+ RAISE EXCEPTION 'A scheduled publication needs a time to start at.'
2387
+ USING ERRCODE = '22023';
2388
+ END IF;
2389
+ IF _unpublish_at IS NOT NULL AND _unpublish_at <= _publish_at THEN
2390
+ RAISE EXCEPTION
2391
+ 'A publication window has to end after it starts: % is not after %.',
2392
+ _unpublish_at, _publish_at
2393
+ USING ERRCODE = '22023';
2394
+ END IF;
2395
+
2396
+ SELECT pv.page_id INTO _owner FROM public.page_versions pv
2397
+ WHERE pv.id = _version_id;
2398
+ IF _owner IS NULL THEN
2399
+ RAISE EXCEPTION 'Version not found' USING ERRCODE = 'P0002';
2400
+ END IF;
2401
+
2402
+ _keys := ARRAY(
2403
+ SELECT DISTINCT btrim(r.value->>'key')
2404
+ FROM jsonb_array_elements(COALESCE(_reveals, '[]'::jsonb)) AS r(value)
2405
+ WHERE btrim(COALESCE(r.value->>'key', '')) <> '');
2406
+
2407
+ SELECT COALESCE(jsonb_object_agg(p.key, p.id), '{}'::jsonb) INTO _targets
2408
+ FROM public.pages p
2409
+ WHERE p.key = ANY (_keys) AND p.deleted_at IS NULL;
2410
+
2411
+ PERFORM app.cms_lock_pages(
2412
+ ARRAY[_owner]
2413
+ || ARRAY(SELECT t.v::uuid FROM jsonb_each_text(_targets) AS t(k, v)));
2414
+
2415
+ SELECT COALESCE(jsonb_object_agg(p.key, p.id), '{}'::jsonb) INTO _relocked
2416
+ FROM public.pages p
2417
+ WHERE p.key = ANY (_keys) AND p.deleted_at IS NULL;
2418
+ IF _relocked IS DISTINCT FROM _targets THEN
2419
+ RAISE EXCEPTION
2420
+ 'A page this schedule reveals was renamed, deleted or created while the '
2421
+ 'schedule was waiting for its locks. Nothing was written; try again.'
2422
+ USING ERRCODE = '40001';
2423
+ END IF;
2424
+
2425
+ SELECT * INTO _version FROM public.page_versions WHERE id = _version_id;
2426
+ IF _version.id IS NULL THEN
2427
+ RAISE EXCEPTION 'Version not found' USING ERRCODE = 'P0002';
2428
+ END IF;
2429
+ IF _version.page_id IS DISTINCT FROM _owner THEN
2430
+ RAISE EXCEPTION
2431
+ 'This version changed pages while the schedule was waiting for its '
2432
+ 'locks. Nothing was written; try again.'
2433
+ USING ERRCODE = '40001';
2434
+ END IF;
2435
+
2436
+ -- The page is derived from the version and locked, so a schedule can only
2437
+ -- ever be written against the page the version belongs to.
2438
+ SELECT * INTO _page FROM public.pages
2439
+ WHERE id = _version.page_id AND deleted_at IS NULL;
2440
+ IF _page.id IS NULL THEN
2441
+ RAISE EXCEPTION 'Page not found' USING ERRCODE = 'P0002';
2442
+ END IF;
2443
+
2444
+ PERFORM id FROM public.page_publications
2445
+ WHERE page_id = _page.id AND is_active
2446
+ ORDER BY id
2447
+ FOR UPDATE;
2448
+
2449
+ INSERT INTO public.page_publications
2450
+ (page_id, version_id, publish_at, unpublish_at, is_active, published_by, note)
2451
+ VALUES
2452
+ (_page.id, _version.id, _publish_at, _unpublish_at, true, _uid, _note)
2453
+ RETURNING id INTO _publication_id;
2454
+
2455
+ INSERT INTO public.audit_logs (actor_id, actor_email, action, entity, entity_id, meta)
2456
+ VALUES (
2457
+ _uid,
2458
+ public.cms_actor_email(_uid, _actor_email),
2459
+ 'page.schedule',
2460
+ 'pages',
2461
+ _page.id::text,
2462
+ COALESCE(_audit, '{}'::jsonb)
2463
+ || jsonb_build_object('key', _page.key, 'versionId', _version.id,
2464
+ 'publicationId', _publication_id,
2465
+ 'publish_at', _publish_at, 'unpublish_at', _unpublish_at)
2466
+ );
2467
+
2468
+ FOR _reveal IN SELECT value FROM jsonb_array_elements(COALESCE(_reveals, '[]'::jsonb))
2469
+ LOOP
2470
+ _scheduled := _scheduled || jsonb_build_array(
2471
+ jsonb_build_object('key', _reveal->>'key')
2472
+ || public.cms_schedule_page_reveal(
2473
+ _reveal->>'key',
2474
+ (_reveal->>'publishAt')::timestamptz,
2475
+ COALESCE(_audit, '{}'::jsonb)
2476
+ || jsonb_build_object('description', _reveal->>'description'),
2477
+ _actor_email,
2478
+ _page.id,
2479
+ _publish_at
2480
+ )
2481
+ );
2482
+ END LOOP;
2483
+
2484
+ RETURN jsonb_build_object(
2485
+ 'publicationId', _publication_id,
2486
+ 'pageId', _page.id,
2487
+ 'versionId', _version.id,
2488
+ 'reveals', _scheduled
2489
+ );
2490
+ END;
2491
+ $$;
2492
+
2493
+ -- Duplicating a page was five round trips from the browser: read the source,
2494
+ -- read every key in the installation, read the source's blocks, insert a page,
2495
+ -- insert a version, update the pointer, write an audit row. Anything that went
2496
+ -- wrong after the third of those left a page with no content, or content with
2497
+ -- no pointer, or a copy nobody could account for — and nothing rolled any of it
2498
+ -- back. Here it is one transaction, and what it asks of the caller is what
2499
+ -- makes the copy honest:
2500
+ --
2501
+ -- * `_expected_version_id` is which version the editor was looking at;
2502
+ -- * `_expected_blocks_digest` is what that version *contained* when they
2503
+ -- looked. A version id cannot bind content on its own, because content
2504
+ -- under a stable version id is not immutable in this schema: the published
2505
+ -- migrations 20260730095256 and 20260811130318 both rewrite
2506
+ -- `page_versions.blocks` in place. So the digest is recomputed here, from
2507
+ -- the row, under the row's own lock, and a mismatch refuses the copy;
2508
+ -- * `_idempotency_key` is which duplication this is. A committed request
2509
+ -- whose response was lost on a slow link is ordinary, and without a key the
2510
+ -- retry silently makes a second copy — which, once §9 route claims exist,
2511
+ -- stops being a cosmetic annoyance and starts colliding for the same
2512
+ -- addresses as the first.
2513
+ --
2514
+ -- `_blocks` is what to write into the copy, which is not the same thing as what
2515
+ -- the source holds: the caller re-identifies every block so the two pages do
2516
+ -- not share block ids. The digest binds what it was derived *from*.
2517
+ CREATE OR REPLACE FUNCTION public.cms_duplicate_page(
2518
+ _source_page_id uuid,
2519
+ _expected_version_id uuid,
2520
+ _expected_blocks_digest text,
2521
+ _idempotency_key uuid,
2522
+ _key text,
2523
+ _name text,
2524
+ _blocks jsonb,
2525
+ _audit jsonb DEFAULT '{}'::jsonb,
2526
+ _actor_email text DEFAULT NULL
2527
+ )
2528
+ RETURNS jsonb
2529
+ LANGUAGE plpgsql
2530
+ SECURITY DEFINER
2531
+ SET search_path = public, pg_temp
2532
+ AS $$
2533
+ DECLARE
2534
+ _uid uuid := app.current_user_id();
2535
+ _email text;
2536
+ _new_id uuid := gen_random_uuid();
2537
+ _prior public.cms_page_duplicate_operations;
2538
+ _copy public.pages;
2539
+ _source public.pages;
2540
+ _version public.page_versions;
2541
+ _version_id uuid;
2542
+ _wanted_key text := btrim(COALESCE(_key, ''));
2543
+ _wanted_nm text := btrim(COALESCE(_name, ''));
2544
+ _actual text;
2545
+ BEGIN
2546
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
2547
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
2548
+ END IF;
2549
+ IF _idempotency_key IS NULL THEN
2550
+ RAISE EXCEPTION
2551
+ 'A duplication needs an idempotency key, so that a retry can be told '
2552
+ 'apart from a second copy.'
2553
+ USING ERRCODE = '22023';
2554
+ END IF;
2555
+ IF _wanted_key = '' OR _wanted_nm = '' THEN
2556
+ RAISE EXCEPTION 'A copy needs both an address and a name.'
2557
+ USING ERRCODE = '22023';
2558
+ END IF;
2559
+ IF _source_page_id IS NULL THEN
2560
+ RAISE EXCEPTION 'Page not found' USING ERRCODE = 'P0002';
2561
+ END IF;
2562
+
2563
+ -- The source and the page that is about to exist, in id order. Locking an id
2564
+ -- no row carries yet costs nothing and blocks nobody; it is written down so
2565
+ -- that this function's lock set is stated in the same shape as every other
2566
+ -- one, rather than remembered as an exception.
2567
+ PERFORM app.cms_lock_pages(ARRAY[_source_page_id, _new_id]);
2568
+
2569
+ -- A retry is a retry, not a second copy. Two calls carrying the same key
2570
+ -- cannot both be here at once: they are duplicating the same source, so the
2571
+ -- lock above has already put one of them behind the other.
2572
+ SELECT * INTO _prior FROM public.cms_page_duplicate_operations
2573
+ WHERE idempotency_key = _idempotency_key;
2574
+ IF _prior.idempotency_key IS NOT NULL THEN
2575
+ SELECT * INTO _copy FROM public.pages WHERE id = _prior.created_page_id;
2576
+ RETURN jsonb_build_object(
2577
+ 'pageId', _prior.created_page_id,
2578
+ 'sourceId', _prior.source_page_id,
2579
+ 'key', _copy.key,
2580
+ 'name', _copy.name,
2581
+ 'status', _copy.status,
2582
+ 'versionId', _copy.current_version_id,
2583
+ 'idempotent', true
2584
+ );
2585
+ END IF;
2586
+
2587
+ SELECT * INTO _source FROM public.pages
2588
+ WHERE id = _source_page_id AND deleted_at IS NULL;
2589
+ IF _source.id IS NULL THEN
2590
+ RAISE EXCEPTION 'Page not found' USING ERRCODE = 'P0002';
2591
+ END IF;
2592
+
2593
+ -- Trashed pages keep their address, so a copy that took one would collide the
2594
+ -- moment the original came back out of the trash.
2595
+ IF EXISTS (SELECT 1 FROM public.pages WHERE key = _wanted_key) THEN
2596
+ RAISE EXCEPTION
2597
+ 'A page already has the address "%", including in the trash.', _wanted_key
2598
+ USING ERRCODE = '23505';
2599
+ END IF;
2600
+
2601
+ IF _source.current_version_id IS DISTINCT FROM _expected_version_id THEN
2602
+ RAISE EXCEPTION
2603
+ 'The page "%" was saved again while this copy was being prepared, so the '
2604
+ 'copy would not be of what was on screen. Reopen the page and copy it '
2605
+ 'again.', _source.key
2606
+ USING ERRCODE = '22023';
2607
+ END IF;
2608
+
2609
+ IF _source.current_version_id IS NULL THEN
2610
+ -- Nothing saved means nothing to bind, and a digest for content that does
2611
+ -- not exist is a caller that thinks it is copying something else.
2612
+ IF _expected_blocks_digest IS NOT NULL THEN
2613
+ RAISE EXCEPTION
2614
+ 'The page "%" has no saved content, so there is no block digest for it '
2615
+ 'to match.', _source.key
2616
+ USING ERRCODE = '22023';
2617
+ END IF;
2618
+ ELSE
2619
+ -- Read `FOR UPDATE` and never written: the copy must not be able to change
2620
+ -- what it was copied from, and the lock is what makes the digest below an
2621
+ -- answer about the row rather than about a moment that has passed.
2622
+ SELECT * INTO _version FROM public.page_versions
2623
+ WHERE id = _source.current_version_id
2624
+ FOR UPDATE;
2625
+ IF _version.id IS NULL THEN
2626
+ RAISE EXCEPTION 'Version not found' USING ERRCODE = 'P0002';
2627
+ END IF;
2628
+
2629
+ _actual := public.cms_digest(_version.blocks);
2630
+ IF _expected_blocks_digest IS NULL OR _actual <> _expected_blocks_digest THEN
2631
+ RAISE EXCEPTION
2632
+ 'The content of "%" is not what this copy was built from (% rather '
2633
+ 'than %). Reopen the page and copy it again.',
2634
+ _source.key, _actual, COALESCE(_expected_blocks_digest, 'nothing')
2635
+ USING ERRCODE = '22023';
2636
+ END IF;
2637
+ END IF;
2638
+
2639
+ _email := public.cms_actor_email(_uid, _actor_email);
2640
+
2641
+ -- The copy starts as a draft whatever the source's status is: it has no
2642
+ -- publication rows, so there is nothing for it to be published *as*.
2643
+ INSERT INTO public.pages (id, key, name, is_system, status, updated_by)
2644
+ VALUES (_new_id, _wanted_key, _wanted_nm, false, 'draft', _uid);
2645
+
2646
+ INSERT INTO public.page_versions
2647
+ (page_id, version_no, blocks, note, created_by, created_by_email)
2648
+ VALUES
2649
+ (_new_id, 1, COALESCE(_blocks, '[]'::jsonb),
2650
+ format('Copied from "%s"', _source.name), _uid, _email)
2651
+ RETURNING id INTO _version_id;
2652
+
2653
+ UPDATE public.pages
2654
+ SET current_version_id = _version_id,
2655
+ updated_at = now(),
2656
+ updated_by = _uid
2657
+ WHERE id = _new_id;
2658
+
2659
+ -- The ledger entry is written in the same transaction as the page it accounts
2660
+ -- for, which is the whole of what makes it trustworthy: if anything after
2661
+ -- this rolls back, the key is unspent and the retry does the entire thing
2662
+ -- again, exactly as it should.
2663
+ INSERT INTO public.cms_page_duplicate_operations
2664
+ (idempotency_key, source_page_id, created_page_id)
2665
+ VALUES (_idempotency_key, _source.id, _new_id);
2666
+
2667
+ INSERT INTO public.audit_logs (actor_id, actor_email, action, entity, entity_id, meta)
2668
+ VALUES (
2669
+ _uid, _email, 'page.duplicate', 'pages', _new_id::text,
2670
+ COALESCE(_audit, '{}'::jsonb)
2671
+ || jsonb_build_object(
2672
+ 'key', _wanted_key, 'source_key', _source.key,
2673
+ 'sourceId', _source.id, 'versionId', _version_id,
2674
+ 'idempotencyKey', _idempotency_key,
2675
+ 'description', format('Duplicated the page "%s" as the draft "%s" (%s).',
2676
+ _source.name, _wanted_nm, _wanted_key))
2677
+ );
2678
+
2679
+ RETURN jsonb_build_object(
2680
+ 'pageId', _new_id,
2681
+ 'sourceId', _source.id,
2682
+ 'key', _wanted_key,
2683
+ 'name', _wanted_nm,
2684
+ 'status', 'draft',
2685
+ 'versionId', _version_id,
2686
+ 'idempotent', false
2687
+ );
2688
+ END;
2689
+ $$;
2690
+
2691
+ -- 10. The route authority, and the host knowledge it answers against ---------
2692
+ --
2693
+ -- Section 6 created the two tables and said nothing writes them yet. These are
2694
+ -- the two functions that do, and the order between them is not interchangeable.
2695
+ -- The registry has to exist before a claim can be checked against it, because
2696
+ -- the check is what a claim is validated by; and the reserved rows the registry
2697
+ -- materialises have to be present before a page can be refused for sitting on
2698
+ -- one, because that refusal is a primary-key violation and not a list lookup.
2699
+
2700
+ -- The host writes down what the package cannot infer.
2701
+ --
2702
+ -- The database has never been able to see the host's route table: which paths
2703
+ -- are reserved for the API, the backoffice, the sitemap and the asset roots,
2704
+ -- which locales exist, which domain serves which locale, whether a trailing
2705
+ -- slash is kept, whether case is folded. Every one of those is a decision the
2706
+ -- application makes at build time, and every one of them changes what two URLs
2707
+ -- have to agree about before they count as the same URL. So the deploy step
2708
+ -- writes them here once per release, and everything that normalizes a URL
2709
+ -- afterwards reads that one copy rather than forming its own opinion.
2710
+ --
2711
+ -- Reserved paths become claims of their own — one row per origin per path,
2712
+ -- kind = 'reserved', owned by no page. That is the whole reason they are
2713
+ -- materialised rather than checked: a page that tries to sit on /api is
2714
+ -- refused by the same primary key that refuses a page sitting on another
2715
+ -- page's path, with the same error, through the same code. A second list of
2716
+ -- forbidden strings, consulted separately, is a second opinion waiting to
2717
+ -- disagree with the first.
2718
+ --
2719
+ -- This one is not for editors. It is called by a deploy step, which has no
2720
+ -- signed-in user, so the grant below is the authority rather than is_staff --
2721
+ -- and the check here refuses a signed-in caller who is not staff, so that a
2722
+ -- grant widened by mistake still does not hand the route map to a visitor.
2723
+ CREATE OR REPLACE FUNCTION public.cms_route_registry_set(
2724
+ _release_sha text,
2725
+ _snapshot jsonb,
2726
+ _audit jsonb DEFAULT '{}'::jsonb,
2727
+ _actor_email text DEFAULT NULL
2728
+ )
2729
+ RETURNS jsonb
2730
+ LANGUAGE plpgsql
2731
+ SECURITY DEFINER
2732
+ SET search_path = public, pg_temp
2733
+ AS $$
2734
+ DECLARE
2735
+ _uid uuid := app.current_user_id();
2736
+ _email text;
2737
+ _sha text := btrim(COALESCE(_release_sha, ''));
2738
+ _origins text[];
2739
+ _paths text[];
2740
+ _origin text;
2741
+ _path text;
2742
+ _policy text;
2743
+ _reserved int := 0;
2744
+ _holder record;
2745
+ BEGIN
2746
+ IF _uid IS NOT NULL AND NOT public.is_staff(_uid) THEN
2747
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
2748
+ END IF;
2749
+ _email := public.cms_actor_email(_uid, _actor_email);
2750
+
2751
+ IF _sha = '' THEN
2752
+ RAISE EXCEPTION
2753
+ 'A route registry needs the release it describes, so that a claim '
2754
+ 'normalized under it can be told apart from one normalized under the '
2755
+ 'release before.'
2756
+ USING ERRCODE = '22023';
2757
+ END IF;
2758
+ IF _snapshot IS NULL OR jsonb_typeof(_snapshot) <> 'object' THEN
2759
+ RAISE EXCEPTION 'The route registry snapshot has to be an object.'
2760
+ USING ERRCODE = '22023';
2761
+ END IF;
2762
+
2763
+ -- Fail closed on the mapping, and name the entry that failed. A deployment
2764
+ -- whose locale-to-domain map has one bad entry has to stop, because the
2765
+ -- alternative -- quietly serving that locale from the default origin -- is
2766
+ -- a site that claims one set of URLs and serves another, and nobody finds
2767
+ -- out until a canonical tag points somewhere that was never deployed.
2768
+ IF jsonb_typeof(_snapshot->'origins') <> 'array'
2769
+ OR jsonb_array_length(_snapshot->'origins') = 0 THEN
2770
+ RAISE EXCEPTION
2771
+ 'The route registry has to name at least one origin. There is no '
2772
+ 'single-origin fallback: a mapping that cannot be read is a mapping '
2773
+ 'nobody has checked.'
2774
+ USING ERRCODE = '22023';
2775
+ END IF;
2776
+
2777
+ SELECT array_agg(value ORDER BY value) INTO _origins
2778
+ FROM jsonb_array_elements_text(_snapshot->'origins') AS t(value);
2779
+
2780
+ FOREACH _origin IN ARRAY _origins LOOP
2781
+ IF _origin IS NULL OR btrim(_origin) = '' THEN
2782
+ RAISE EXCEPTION 'The route registry names an empty origin.'
2783
+ USING ERRCODE = '22023';
2784
+ END IF;
2785
+ IF _origin <> lower(_origin) OR _origin LIKE '%/' THEN
2786
+ RAISE EXCEPTION
2787
+ 'The origin % is not canonical. Scheme and host are lowercased and '
2788
+ 'the origin carries no trailing slash; the port stays, because '
2789
+ 'https://x and https://x:8443 are different sites.', _origin
2790
+ USING ERRCODE = '22023';
2791
+ END IF;
2792
+ IF _origin NOT LIKE 'http://%' AND _origin NOT LIKE 'https://%' THEN
2793
+ RAISE EXCEPTION
2794
+ 'The origin % names no scheme. An origin without one cannot be '
2795
+ 'compared with the origin a request arrived on.', _origin
2796
+ USING ERRCODE = '22023';
2797
+ END IF;
2798
+ END LOOP;
2799
+
2800
+ IF _snapshot ? 'trailingSlash' THEN
2801
+ _policy := _snapshot->>'trailingSlash';
2802
+ IF _policy IS NULL OR _policy NOT IN ('never', 'always') THEN
2803
+ RAISE EXCEPTION
2804
+ 'The trailing-slash policy is % and has to be never or always. A '
2805
+ 'policy nobody can read is a policy two writers will read '
2806
+ 'differently.', COALESCE(_policy, 'null')
2807
+ USING ERRCODE = '22023';
2808
+ END IF;
2809
+ END IF;
2810
+
2811
+ IF _snapshot ? 'reservedPaths' THEN
2812
+ IF jsonb_typeof(_snapshot->'reservedPaths') <> 'array' THEN
2813
+ RAISE EXCEPTION 'The registry''s reserved paths have to be an array.'
2814
+ USING ERRCODE = '22023';
2815
+ END IF;
2816
+ SELECT array_agg(DISTINCT value ORDER BY value) INTO _paths
2817
+ FROM jsonb_array_elements_text(_snapshot->'reservedPaths') AS t(value);
2818
+ END IF;
2819
+ _paths := COALESCE(_paths, ARRAY[]::text[]);
2820
+
2821
+ FOREACH _path IN ARRAY _paths LOOP
2822
+ IF _path IS NULL OR _path NOT LIKE '/%' THEN
2823
+ RAISE EXCEPTION
2824
+ 'The reserved path % is not rooted. A reserved path is compared '
2825
+ 'against a served path, and those always begin at the root.',
2826
+ COALESCE(_path, 'null')
2827
+ USING ERRCODE = '22023';
2828
+ END IF;
2829
+ END LOOP;
2830
+
2831
+ INSERT INTO public.cms_route_registry (id, release_sha, snapshot, updated_at)
2832
+ VALUES (1, _sha, _snapshot, now())
2833
+ ON CONFLICT (id) DO UPDATE
2834
+ SET release_sha = EXCLUDED.release_sha,
2835
+ snapshot = EXCLUDED.snapshot,
2836
+ updated_at = EXCLUDED.updated_at;
2837
+
2838
+ -- Rewritten rather than merged, for the same reason a page's claims are:
2839
+ -- the snapshot is the whole truth about what this release reserves, so a
2840
+ -- path dropped from it stops being reserved in the same statement that stops
2841
+ -- describing it. Merging would leave yesterday's reservations standing with
2842
+ -- nothing left that says why.
2843
+ DELETE FROM public.cms_route_claims WHERE kind = 'reserved';
2844
+
2845
+ BEGIN
2846
+ INSERT INTO public.cms_route_claims (origin, path, kind, page_id, locale, registry_sha)
2847
+ SELECT o.origin, p.path, 'reserved', NULL, NULL, _sha
2848
+ FROM unnest(_origins) AS o(origin)
2849
+ CROSS JOIN unnest(_paths) AS p(path);
2850
+ GET DIAGNOSTICS _reserved = ROW_COUNT;
2851
+ EXCEPTION WHEN unique_violation THEN
2852
+ -- A page is already sitting where this release wants to reserve. The
2853
+ -- deploy step has to stop: the alternative is a release that serves its
2854
+ -- own API from one origin and somebody's page from another.
2855
+ SELECT c.origin, c.path, c.kind, pg.key INTO _holder
2856
+ FROM public.cms_route_claims c
2857
+ LEFT JOIN public.pages pg ON pg.id = c.page_id
2858
+ WHERE c.kind <> 'reserved'
2859
+ AND c.origin = ANY (_origins)
2860
+ AND c.path = ANY (_paths)
2861
+ ORDER BY c.origin, c.path
2862
+ LIMIT 1;
2863
+ RAISE EXCEPTION
2864
+ 'The path % on % is reserved by this release and already claimed by %. '
2865
+ 'Move or delete that claim and deploy again; the registry is not '
2866
+ 'written.',
2867
+ COALESCE(_holder.path, '?'), COALESCE(_holder.origin, '?'),
2868
+ COALESCE('the ' || _holder.kind || ' "' || _holder.key || '"', 'another claim')
2869
+ USING ERRCODE = '23505';
2870
+ END;
2871
+
2872
+ INSERT INTO public.audit_logs (actor_id, actor_email, action, entity, entity_id, meta)
2873
+ VALUES (
2874
+ _uid, _email, 'route.registry', 'cms_route_registry', '1',
2875
+ COALESCE(_audit, '{}'::jsonb)
2876
+ || jsonb_build_object(
2877
+ 'releaseSha', _sha,
2878
+ 'origins', to_jsonb(_origins),
2879
+ 'reserved', _reserved,
2880
+ 'description', format(
2881
+ 'Wrote the route registry for release %s: %s origin(s), %s reserved claim(s).',
2882
+ _sha, array_length(_origins, 1), _reserved))
2883
+ );
2884
+
2885
+ RETURN jsonb_build_object(
2886
+ 'ok', true,
2887
+ 'releaseSha', _sha,
2888
+ 'origins', to_jsonb(_origins),
2889
+ 'reserved', _reserved
2890
+ );
2891
+ END;
2892
+ $$;
2893
+
2894
+ -- One page's routes, swapped in one transaction.
2895
+ --
2896
+ -- Read-then-write cannot be made correct by ordering. Two editors renaming two
2897
+ -- pages to the same slug both read the path as free, both write, and the site
2898
+ -- now serves two pages at one URL with nothing in the database that disagrees.
2899
+ -- So nothing here reads to decide. The page's whole claim set is deleted and
2900
+ -- re-inserted, and the primary key on (origin, path) is what refuses the
2901
+ -- second writer -- which means a writer that skips every precaution below
2902
+ -- still cannot double-claim.
2903
+ --
2904
+ -- The advisory lock is a fairness device and nothing more. It makes concurrent
2905
+ -- writers queue in a predictable order per origin instead of interleaving into
2906
+ -- an avoidable failure, and it is taken after the page lock, because the tier
2907
+ -- order in section 9 is pages first and a route claim does not get to invert
2908
+ -- it. One lock per distinct origin, in sorted order, so two sessions claiming
2909
+ -- across the same two origins in opposite directions queue rather than
2910
+ -- deadlock. Two origins that hash alike share a queue; that costs a little
2911
+ -- waiting and no correctness, because the queue was never the authority.
2912
+ --
2913
+ -- A collision comes back as a value rather than an exception. It is an
2914
+ -- ordinary editing outcome -- somebody else got there first -- and raising
2915
+ -- would take down whatever transaction the caller had already built around
2916
+ -- this call. The insert still rolls back, so the page keeps the claims it had.
2917
+ CREATE OR REPLACE FUNCTION public.cms_route_claim_apply(
2918
+ _page_id uuid,
2919
+ _claims jsonb,
2920
+ _expected_registry_sha text,
2921
+ _audit jsonb DEFAULT '{}'::jsonb,
2922
+ _actor_email text DEFAULT NULL
2923
+ )
2924
+ RETURNS jsonb
2925
+ LANGUAGE plpgsql
2926
+ SECURITY DEFINER
2927
+ SET search_path = public, pg_temp
2928
+ AS $$
2929
+ DECLARE
2930
+ _uid uuid := app.current_user_id();
2931
+ _email text;
2932
+ _page public.pages;
2933
+ _registry public.cms_route_registry;
2934
+ _origins text[];
2935
+ _origin text;
2936
+ _claim jsonb;
2937
+ _repeat record;
2938
+ _released int := 0;
2939
+ _claimed int := 0;
2940
+ _collisions jsonb;
2941
+ BEGIN
2942
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
2943
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
2944
+ END IF;
2945
+ _email := public.cms_actor_email(_uid, _actor_email);
2946
+
2947
+ IF _page_id IS NULL THEN
2948
+ RAISE EXCEPTION 'Page not found' USING ERRCODE = 'P0002';
2949
+ END IF;
2950
+ IF _claims IS NULL OR jsonb_typeof(_claims) <> 'array' THEN
2951
+ RAISE EXCEPTION
2952
+ 'A claim set is an array, and an empty one is how a page says it serves '
2953
+ 'nothing. Null is not the same statement.'
2954
+ USING ERRCODE = '22023';
2955
+ END IF;
2956
+
2957
+ SELECT * INTO _registry FROM public.cms_route_registry WHERE id = 1;
2958
+ IF _registry.id IS NULL THEN
2959
+ RAISE EXCEPTION
2960
+ 'No route registry has been written, so there is nothing to normalize '
2961
+ 'this claim against. The deploy step calls cms_route_registry_set once '
2962
+ 'the application is up.'
2963
+ USING ERRCODE = '22023';
2964
+ END IF;
2965
+ -- A stale registry fails closed. Validating tomorrow's claim against
2966
+ -- yesterday's route map finds no collision precisely when the map is the
2967
+ -- thing that changed, which is the one case where finding none is wrong.
2968
+ IF _registry.release_sha IS DISTINCT FROM _expected_registry_sha THEN
2969
+ RAISE EXCEPTION
2970
+ 'This claim was normalized against release %, and the site is now on '
2971
+ '%. Reload the route registry and normalize again.',
2972
+ COALESCE(_expected_registry_sha, 'nothing'), _registry.release_sha
2973
+ USING ERRCODE = '22023';
2974
+ END IF;
2975
+
2976
+ SELECT array_agg(value ORDER BY value) INTO _origins
2977
+ FROM jsonb_array_elements_text(_registry.snapshot->'origins') AS t(value);
2978
+ _origins := COALESCE(_origins, ARRAY[]::text[]);
2979
+
2980
+ FOR _claim IN SELECT value FROM jsonb_array_elements(_claims) LOOP
2981
+ IF COALESCE(_claim->>'origin', '') = ''
2982
+ OR COALESCE(_claim->>'path', '') = '' THEN
2983
+ RAISE EXCEPTION 'Every claim needs an origin and a path.'
2984
+ USING ERRCODE = '22023';
2985
+ END IF;
2986
+ IF _claim->>'origin' <> lower(_claim->>'origin')
2987
+ OR _claim->>'origin' LIKE '%/' THEN
2988
+ RAISE EXCEPTION
2989
+ 'The origin % is not canonical: lowercase it and drop the trailing '
2990
+ 'slash, but keep the port.', _claim->>'origin'
2991
+ USING ERRCODE = '22023';
2992
+ END IF;
2993
+ IF _claim->>'path' NOT LIKE '/%' THEN
2994
+ RAISE EXCEPTION 'The path % is not rooted.', _claim->>'path'
2995
+ USING ERRCODE = '22023';
2996
+ END IF;
2997
+ IF COALESCE(_claim->>'kind', '') NOT IN ('page', 'redirect') THEN
2998
+ RAISE EXCEPTION
2999
+ 'A page claims paths as a page or as a redirect. Reserved paths '
3000
+ 'belong to the release and are written by cms_route_registry_set.'
3001
+ USING ERRCODE = '22023';
3002
+ END IF;
3003
+ -- An origin the registry does not serve has no reserved rows on it, so a
3004
+ -- claim there would find /api free. That is the invalid-mapping case, and
3005
+ -- it fails closed here rather than being served from somewhere else.
3006
+ IF NOT (_claim->>'origin' = ANY (_origins)) THEN
3007
+ RAISE EXCEPTION
3008
+ 'This release serves %, and the claim names %. A locale mapped to a '
3009
+ 'domain the release does not serve is refused rather than served '
3010
+ 'from the default origin.',
3011
+ array_to_string(_origins, ', '), _claim->>'origin'
3012
+ USING ERRCODE = '22023';
3013
+ END IF;
3014
+ END LOOP;
3015
+
3016
+ -- Two claims at one address inside one set would raise the same 23505 as a
3017
+ -- real collision and find no other holder, which would answer "somebody
3018
+ -- else has it" about nobody. It is a caller mistake and says so.
3019
+ SELECT e.value->>'origin' AS origin, e.value->>'path' AS path INTO _repeat
3020
+ FROM jsonb_array_elements(_claims) AS e(value)
3021
+ GROUP BY 1, 2
3022
+ HAVING count(*) > 1
3023
+ LIMIT 1;
3024
+ IF _repeat.path IS NOT NULL THEN
3025
+ RAISE EXCEPTION
3026
+ 'This claim set names % on % twice. One page cannot serve one URL two '
3027
+ 'ways.', _repeat.path, _repeat.origin
3028
+ USING ERRCODE = '22023';
3029
+ END IF;
3030
+
3031
+ -- Pages first, as everywhere else in this file.
3032
+ PERFORM app.cms_lock_pages(ARRAY[_page_id]);
3033
+
3034
+ SELECT * INTO _page FROM public.pages
3035
+ WHERE id = _page_id AND deleted_at IS NULL;
3036
+ IF _page.id IS NULL THEN
3037
+ RAISE EXCEPTION 'Page not found' USING ERRCODE = 'P0002';
3038
+ END IF;
3039
+
3040
+ SELECT array_agg(DISTINCT e.value->>'origin' ORDER BY e.value->>'origin')
3041
+ INTO _origins
3042
+ FROM jsonb_array_elements(_claims) AS e(value);
3043
+ FOREACH _origin IN ARRAY COALESCE(_origins, ARRAY[]::text[]) LOOP
3044
+ PERFORM pg_advisory_xact_lock(hashtext('cms_route_claims'), hashtext(_origin));
3045
+ END LOOP;
3046
+
3047
+ BEGIN
3048
+ DELETE FROM public.cms_route_claims WHERE page_id = _page_id;
3049
+ GET DIAGNOSTICS _released = ROW_COUNT;
3050
+
3051
+ INSERT INTO public.cms_route_claims (origin, path, kind, page_id, locale, registry_sha)
3052
+ SELECT e.value->>'origin',
3053
+ e.value->>'path',
3054
+ e.value->>'kind',
3055
+ _page_id,
3056
+ NULLIF(e.value->>'locale', ''),
3057
+ _registry.release_sha
3058
+ FROM jsonb_array_elements(_claims) AS e(value);
3059
+ GET DIAGNOSTICS _claimed = ROW_COUNT;
3060
+ EXCEPTION WHEN unique_violation THEN
3061
+ -- The delete above is rolled back with the insert, so the page still holds
3062
+ -- what it held. What follows is a diagnostic, not the decision: the key
3063
+ -- already made that, and this only reads back who won.
3064
+ SELECT jsonb_agg(jsonb_build_object(
3065
+ 'origin', held.origin,
3066
+ 'path', held.path,
3067
+ 'kind', held.kind,
3068
+ 'locale', held.locale,
3069
+ 'heldBy', CASE WHEN held.page_id IS NULL THEN NULL
3070
+ ELSE jsonb_build_object('pageId', held.page_id,
3071
+ 'key', holder.key,
3072
+ 'name', holder.name)
3073
+ END)
3074
+ ORDER BY held.origin, held.path)
3075
+ INTO _collisions
3076
+ FROM public.cms_route_claims held
3077
+ LEFT JOIN public.pages holder ON holder.id = held.page_id
3078
+ WHERE held.page_id IS DISTINCT FROM _page_id
3079
+ AND (held.origin, held.path) IN (
3080
+ SELECT e.value->>'origin', e.value->>'path'
3081
+ FROM jsonb_array_elements(_claims) AS e(value));
3082
+
3083
+ RETURN jsonb_build_object(
3084
+ 'ok', false,
3085
+ 'pageId', _page_id,
3086
+ 'registrySha', _registry.release_sha,
3087
+ 'collisions', COALESCE(_collisions, '[]'::jsonb)
3088
+ );
3089
+ END;
3090
+
3091
+ INSERT INTO public.audit_logs (actor_id, actor_email, action, entity, entity_id, meta)
3092
+ VALUES (
3093
+ _uid, _email, 'route.claim', 'cms_route_claims', _page_id::text,
3094
+ COALESCE(_audit, '{}'::jsonb)
3095
+ || jsonb_build_object(
3096
+ 'key', _page.key,
3097
+ 'registrySha', _registry.release_sha,
3098
+ 'claimed', _claimed,
3099
+ 'released', _released,
3100
+ 'claims', _claims,
3101
+ 'description', format(
3102
+ 'The page "%s" now claims %s route(s); %s released.',
3103
+ _page.name, _claimed, _released))
3104
+ );
3105
+
3106
+ RETURN jsonb_build_object(
3107
+ 'ok', true,
3108
+ 'pageId', _page_id,
3109
+ 'registrySha', _registry.release_sha,
3110
+ 'claimed', _claimed,
3111
+ 'released', _released,
3112
+ 'collisions', '[]'::jsonb
3113
+ );
3114
+ END;
3115
+ $$;
3116
+
3117
+ -- 11. Closing the run ---------------------------------------------------------
3118
+ --
3119
+ -- Separate from the block above on purpose. Until this runs, the run says
3120
+ -- 'applying', which is not evidence of anything: cms_publication_review_is_-
3121
+ -- complete refuses it, and a caller asking "was this page scanned?" gets no
3122
+ -- for every page. The transition to 'applied' is the single moment the answer
3123
+ -- changes, and it happens once, at the end, when there is nothing left to do.
3124
+ --
3125
+ -- If anything above raised, this never runs and the whole transaction is gone
3126
+ -- — the tables included. There is no state in which the schema exists and the
3127
+ -- review does not.
3128
+ DO $$
3129
+ DECLARE
3130
+ _run_id uuid;
3131
+ _scanned bigint;
3132
+ _counts jsonb;
3133
+ BEGIN
3134
+ -- Nothing to close, because the block above short-circuited on a database
3135
+ -- where the review already ran. Checked before the STRICT select, which
3136
+ -- would otherwise turn a harmless second run of this file into an error.
3137
+ IF NOT EXISTS (SELECT 1 FROM public.cms_publication_review_runs
3138
+ WHERE state = 'applying') THEN
3139
+ RETURN;
3140
+ END IF;
3141
+
3142
+ -- STRICT rather than a plain SELECT: "exactly one run is being applied" is
3143
+ -- an invariant of this file, and if it is ever false the right outcome is a
3144
+ -- refusal, not finalising whichever row came back first.
3145
+ SELECT id INTO STRICT _run_id
3146
+ FROM public.cms_publication_review_runs
3147
+ WHERE state = 'applying';
3148
+
3149
+ SELECT coalesce(jsonb_object_agg(classification, n), '{}'::jsonb), coalesce(sum(n), 0)
3150
+ INTO _counts, _scanned
3151
+ FROM (
3152
+ SELECT classification, count(*) AS n
3153
+ FROM public.cms_publication_review_items
3154
+ WHERE run_id = _run_id
3155
+ GROUP BY classification
3156
+ ) tally;
3157
+
3158
+ UPDATE public.cms_publication_review_runs
3159
+ SET state = 'applied',
3160
+ scanned_page_count = _scanned,
3161
+ counts = _counts,
3162
+ scan_completed_at = now(),
3163
+ applied_by_email = coalesce(applied_by_email, 'migration@cms-platform.invalid'),
3164
+ applied_at = now()
3165
+ WHERE id = _run_id;
3166
+
3167
+ -- The predicate the rest of the system trusts, asserted here rather than
3168
+ -- hoped for. A run that does not satisfy it is not evidence, so shipping one
3169
+ -- would mean shipping a schema whose central question has no answer.
3170
+ IF NOT public.cms_publication_review_is_complete(_run_id) THEN
3171
+ RAISE EXCEPTION 'the review run did not finalise cleanly'
3172
+ USING ERRCODE = 'P0002',
3173
+ DETAIL = format('run %s scanned %s of %s pages', _run_id, _scanned,
3174
+ (SELECT expected_page_count
3175
+ FROM public.cms_publication_review_runs WHERE id = _run_id));
3176
+ END IF;
3177
+ END
3178
+ $$;
3179
+
3180
+ -- 12. Who may reach any of this ----------------------------------------------
3181
+ --
3182
+ -- Last, and deliberately so: nothing above can be reached by anybody until the
3183
+ -- review has been written and closed, because a half-applied transaction is
3184
+ -- never visible to another session at all.
3185
+
3186
+ -- The two capability helpers are the one thing here an unauthenticated caller
3187
+ -- has to be able to reach: the health endpoint asks what contract version the
3188
+ -- schema offers before anybody has signed in, and a health check that cannot
3189
+ -- ask fails closed on every request. They are SECURITY DEFINER for exactly
3190
+ -- that reason — the answer is public, the table it is assembled from is not,
3191
+ -- and opening the function is a much smaller thing to open than the table.
3192
+ --
3193
+ -- cms_installation_id is SECURITY DEFINER for the same mechanical reason but
3194
+ -- is not public: nothing unauthenticated needs to know which installation it
3195
+ -- is talking to, and an identifier that is only interesting to an attacker
3196
+ -- correlating backups is not worth handing out for free.
3197
+ -- PostgreSQL grants EXECUTE on a new function to PUBLIC by default, and
3198
+ -- revoking it from a named role does not take that away — so on a SECURITY
3199
+ -- DEFINER function, revoking from `anon` alone leaves it callable by `anon`.
3200
+ -- The three below are restricted, so they say so to PUBLIC explicitly, in the
3201
+ -- same shape the rest of this repository's migrations use. The two capability
3202
+ -- helpers keep the default on purpose: an unauthenticated health check has to
3203
+ -- reach them, including on a self-host that has no hosted roles at all.
3204
+ REVOKE ALL ON FUNCTION public.cms_installation_id() FROM PUBLIC;
3205
+ REVOKE ALL ON FUNCTION public.cms_publication_review_is_complete(uuid) FROM PUBLIC;
3206
+ REVOKE ALL ON FUNCTION public.cms_publication_review_current_run() FROM PUBLIC;
3207
+
3208
+ -- The lock helper is not on any of those lists, because it is not for callers
3209
+ -- at all. It lives in `app`, which PostgREST does not expose, and this takes
3210
+ -- away the EXECUTE that PostgreSQL hands PUBLIC on every new function — after
3211
+ -- which the only things that can call it are the SECURITY DEFINER functions in
3212
+ -- this schema, which run as the owner. There is no grant to anon,
3213
+ -- authenticated or service_role anywhere in this file, and the loop below
3214
+ -- revokes from each of them by name as well, so that adding one later has to
3215
+ -- be a deliberate act rather than an oversight.
3216
+ REVOKE ALL ON FUNCTION app.cms_lock_pages(uuid[]) FROM PUBLIC;
3217
+
3218
+ -- The one new function in this migration a person calls. It follows the rule
3219
+ -- migration 20260904160000 set for every other write: away from PUBLIC here,
3220
+ -- away from `anon` by name below, and to the two roles that already hold the
3221
+ -- rest of the page lifecycle. A self-hosted installation grants it to the role
3222
+ -- its application connects as, alongside the statements `docs/PAGES.md` lists:
3223
+ --
3224
+ -- GRANT EXECUTE ON FUNCTION public.cms_duplicate_page(
3225
+ -- uuid, uuid, text, uuid, text, text, jsonb, jsonb, text) TO cms_app;
3226
+ REVOKE ALL ON FUNCTION public.cms_duplicate_page(
3227
+ uuid, uuid, text, uuid, text, text, jsonb, jsonb, text) FROM PUBLIC;
3228
+
3229
+ -- The claim writer follows the same rule as the duplicate above: staff, in the
3230
+ -- application's own role. The registry writer does not, and is the one
3231
+ -- function in this file granted to service_role alone. It is called by a
3232
+ -- deploy step that has no signed-in user, so the grant is what stands in for
3233
+ -- is_staff — and the route map is precisely the thing an editor session must
3234
+ -- not be able to rewrite, because rewriting it is how every claim already
3235
+ -- stored stops meaning what it meant.
3236
+ REVOKE ALL ON FUNCTION public.cms_route_claim_apply(
3237
+ uuid, jsonb, text, jsonb, text) FROM PUBLIC;
3238
+ REVOKE ALL ON FUNCTION public.cms_route_registry_set(
3239
+ text, jsonb, jsonb, text) FROM PUBLIC;
3240
+ REVOKE ALL ON FUNCTION public.cms_publication_review_reconcile(
3241
+ text, text, text) FROM PUBLIC;
3242
+
3243
+ DO $$
3244
+ DECLARE
3245
+ _role text;
3246
+ _signature text;
3247
+ BEGIN
3248
+ FOREACH _role IN ARRAY ARRAY['anon', 'authenticated', 'service_role'] LOOP
3249
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = _role) THEN
3250
+ CONTINUE;
3251
+ END IF;
3252
+
3253
+ FOREACH _signature IN ARRAY ARRAY[
3254
+ 'public.cms_platform_capability()',
3255
+ 'public.cms_schema_capability(text)',
3256
+ 'public.cms_canonical_json(jsonb)',
3257
+ 'public.cms_digest(jsonb)'
3258
+ ] LOOP
3259
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM %I', _signature, _role);
3260
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO %I', _signature, _role);
3261
+ END LOOP;
3262
+
3263
+ FOREACH _signature IN ARRAY ARRAY[
3264
+ 'public.cms_installation_id()',
3265
+ 'public.cms_publication_review_is_complete(uuid)',
3266
+ 'public.cms_publication_review_current_run()'
3267
+ ] LOOP
3268
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM %I', _signature, _role);
3269
+ IF _role <> 'anon' THEN
3270
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO %I', _signature, _role);
3271
+ END IF;
3272
+ END LOOP;
3273
+
3274
+ FOREACH _signature IN ARRAY ARRAY[
3275
+ 'public.cms_duplicate_page(uuid, uuid, text, uuid, text, text, jsonb, jsonb, text)',
3276
+ 'public.cms_route_claim_apply(uuid, jsonb, text, jsonb, text)'
3277
+ ] LOOP
3278
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM %I', _signature, _role);
3279
+ IF _role <> 'anon' THEN
3280
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO %I', _signature, _role);
3281
+ END IF;
3282
+ END LOOP;
3283
+
3284
+ FOREACH _signature IN ARRAY ARRAY[
3285
+ 'public.cms_route_registry_set(text, jsonb, jsonb, text)',
3286
+ 'public.cms_publication_review_reconcile(text, text, text)'
3287
+ ] LOOP
3288
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM %I', _signature, _role);
3289
+ IF _role = 'service_role' THEN
3290
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO %I', _signature, _role);
3291
+ END IF;
3292
+ END LOOP;
3293
+
3294
+ -- Revoked from every client role and granted to none of them.
3295
+ EXECUTE format('REVOKE ALL ON FUNCTION app.cms_lock_pages(uuid[]) FROM %I', _role);
3296
+ END LOOP;
3297
+ END
3298
+ $$;