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