@bygghammar/cms-platform 0.91.1 → 0.92.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,298 @@
1
+ # Publication review: what the contract v3 upgrade would change
2
+
3
+ Upgrading a site from 0.90.0-m7.12 to contract v3 changes which publication row
4
+ a page is served from. For most pages that changes nothing. For a few it is the
5
+ difference between a live page and a 404, and the database does not hold enough
6
+ to say which was intended. This is the procedure for asking somebody.
7
+
8
+ Run it against the site you are about to upgrade, before you apply the
9
+ migration. It reads; it does not write anything to the database.
10
+
11
+ ## Why there is a review at all
12
+
13
+ Both readers answer the same question — *which version of this page is public
14
+ right now?* — and they disagree about the order of two steps.
15
+
16
+ | Reader | How it picks |
17
+ | --- | --- |
18
+ | 0.90.0-m7.12 | Drops closed publication rows, then takes the newest of what is left |
19
+ | Contract v3 | Takes the newest row that has started, then asks whether it is still open |
20
+
21
+ Where a page's newest row has expired and an older one is still open, the old
22
+ reader skipped the expired row and served the open one. The new reader picks the
23
+ expired row first, finds it closed, and serves nothing. The page goes dark on
24
+ deploy, with no error anywhere: the row it should have used is still sitting
25
+ there, one place further down the list.
26
+
27
+ The new order is the correct one — it is what makes a scheduled window mean what
28
+ it says — so the fix is not to keep the old reader. It is to find the pages this
29
+ reordering moves, repair the ones that can be repaired from the data, and get an
30
+ answer for the rest.
31
+
32
+ ## The shape of a run
33
+
34
+ ```
35
+ export CMS_REVIEW_DSN=postgres://… # the site you are upgrading
36
+ cms-review scan # what is here, and what it would become
37
+ cms-review resolve <page-key> <answer> --by you@example.com
38
+ cms-review approve --site-key voyara --environment production \
39
+ --release-sha "$GITHUB_SHA" --by you@example.com \
40
+ --confirm-environment production
41
+ PGOPTIONS="$(cms-review options)" psql -f 20260905120000_….sql
42
+ # — or, where the database is on a VPS and nobody types psql:
43
+ cms-review record > migration.approval
44
+ ```
45
+
46
+ `scan` writes `.cms-review.json` in the working directory — `--file` puts it
47
+ somewhere else, and every later subcommand takes the same flag. `show` reprints
48
+ the last scan without touching the database, which is the one to use when
49
+ somebody asks what is still outstanding.
50
+
51
+ The file is the unit of work. It is worth committing to the deployment's own
52
+ repository, because it is the record of who said what about which page.
53
+
54
+ ## What the scan calls things
55
+
56
+ Seven classifications, evaluated in order and mutually exclusive. Three of them
57
+ stop the upgrade until a person answers.
58
+
59
+ | Classification | What it means | Needs an answer |
60
+ | --- | --- | --- |
61
+ | `already_safe` | Both readers serve the same version | no |
62
+ | `hidden_by_design` | The page is not published, so it was never public | no |
63
+ | `hidden_by_expiry` | Every window had already closed; it was already dark | no |
64
+ | `preserved` | An expired row shadows an open one — the migration repairs this | no |
65
+ | `structurally_invalid` | The publication rows contradict themselves | **yes** |
66
+ | `disagreement` | The two readers serve *different versions* of this page | **yes** |
67
+ | `needs_review` | One database state, two opposite intentions | **yes** |
68
+
69
+ `preserved` is the case the upgrade was written for, and it is settled without
70
+ asking: today's public appearance is kept. The scan lists those pages anyway, so
71
+ that "the migration is about to touch these" is never a surprise.
72
+
73
+ `needs_review` covers the two shapes nobody can decide from the outside. A page
74
+ whose only window has not opened yet is either meant to stay live until it does
75
+ or meant to go dark now, and the rows are identical in both cases. A page whose
76
+ windows are in an ambiguous order has the same problem one level down.
77
+
78
+ ## The three answers
79
+
80
+ | Answer | What it does |
81
+ | --- | --- |
82
+ | `publish-legacy-version` | Write a fresh open publication row for the version the old reader served |
83
+ | `leave-hidden` | Accept that the page goes dark |
84
+ | `no-change` | Touch nothing; the new reader's answer is the right one |
85
+
86
+ `publish-legacy-version` is offered only where it is honest. If the version the
87
+ old reader served belongs to a different page, the scan does not list it as an
88
+ available answer and the migration refuses it — writing another page's content
89
+ into this page's publication row under a note claiming preservation is not
90
+ preservation.
91
+
92
+ Record one with `--note` when the reason will not be obvious in six months. The
93
+ note travels into the approval and into the audit row the migration writes.
94
+
95
+ ## The connection string is never an argument
96
+
97
+ The database comes from `CMS_REVIEW_DSN`, or from `DATABASE_URL`. There is no
98
+ `--dsn` flag and there will not be one: an argument is visible in every `ps` on
99
+ the machine for as long as the process runs, and a connection string usually
100
+ carries a password. The URL is taken apart into `PGHOST`, `PGUSER`, `PGPASSWORD`
101
+ and the rest, which are passed to `psql` in its environment.
102
+
103
+ A role with `SELECT` on `pages`, `page_versions` and `page_publications` is
104
+ enough for the whole procedure. Nothing here writes to the database.
105
+
106
+ ## What an approval binds itself to
107
+
108
+ `approve` seals the answers. It refuses while any page still needs one, so the
109
+ migration's own refusal is never the first time you hear about it.
110
+
111
+ | Flag | |
112
+ | --- | --- |
113
+ | `--by` | Required. Who approved; recorded in the audit row |
114
+ | `--site-key`, `--environment`, `--release-sha` | Required. Which deployment this speaks for |
115
+ | `--confirm-environment` | Required, and must repeat `--environment` exactly |
116
+ | `--expires-in` | Seconds, 60–86400, default 3600 |
117
+ | `--manifest-digest` | Optional; overrides the catalog digest it would compute |
118
+
119
+ Before it seals anything, `approve` prints the site, the environment, the
120
+ database identity, the host it would connect to and how many pages were
121
+ answered, and then refuses unless `--confirm-environment` repeats the
122
+ environment back. That is deliberate friction. It is the last point at which
123
+ `production` typed in a staging terminal is still recoverable. If
124
+ `CMS_ENVIRONMENT` is set and disagrees with `--environment`, it stops there
125
+ instead — one of the two is wrong and neither the CLI nor the migration can tell
126
+ which.
127
+
128
+ The sealed approval carries a digest of the migration file's bytes, a digest of
129
+ the whole catalog of migrations this package ships, a fingerprint of the rows
130
+ the scan read, and the moment it was evaluated. The migration recomputes all of
131
+ it. An approval is therefore good for one database, one release, one package,
132
+ one `.sql` file and one hour — and for the rows as they stood when they were
133
+ read. Edit the migration after approving it and the approval stops matching.
134
+
135
+ `approve` also checks this package's migration manifest against its own
136
+ `migrations/` directory, because it is holding both. In a checkout a mismatch
137
+ means somebody edited a `.sql` without running `npm run manifest`. In an
138
+ installed package it means something worse, since publishing checks the same
139
+ thing.
140
+
141
+ ## Handing it to the migration
142
+
143
+ There are two roads an approval can travel, and both are rendered from the same
144
+ builder inside `cms-review`, so they cannot say different things about the same
145
+ review.
146
+
147
+ ### The road with a psql prompt
148
+
149
+ `options` prints the sealed approval as PostgreSQL startup options:
150
+
151
+ ```
152
+ PGOPTIONS="$(cms-review options)" psql -f 20260905120000_….sql
153
+ ```
154
+
155
+ Fourteen of those settings are required — `approval_nonce`, `site_key`,
156
+ `environment`, `review_database_identity`, `release_sha`, `package_version`,
157
+ `migration_name`, `migration_digest`, `run_id`, `input_fingerprint`,
158
+ `manifest_digest`, `decision_count`, `approval_expires_at`, `evaluated_at` —
159
+ each read as `lovable.<name>`, with absent and empty treated the same way. The
160
+ answers themselves travel alongside them, as `lovable.decisions`, and so do the
161
+ two people, as `lovable.classified_by_email` and `lovable.approved_by_email`.
162
+
163
+ ### The road with no psql prompt
164
+
165
+ On a self-hosted VPS nobody types `psql`. The approval is carried there as a
166
+ file, installed by the deployment tooling, and read back by a shell function
167
+ that never runs any of this JavaScript. `cms-review record` prints exactly that
168
+ file:
169
+
170
+ ```
171
+ cms-review record > migration.approval
172
+ scp migration.approval you@vps:
173
+ ```
174
+
175
+ The record is nineteen `key=value` lines in a fixed order, one line each,
176
+ newline-terminated:
177
+
178
+ | # | Key | |
179
+ | --- | --- | --- |
180
+ | 1 | `approval_nonce` | 32 lowercase hex characters; this approval, spent once |
181
+ | 2 | `site_key` | Which site |
182
+ | 3 | `environment` | `staging` or `production` |
183
+ | 4 | `database_identity` | The **cluster**: `system_identifier` from `pg_control_system()` |
184
+ | 5 | `review_database_identity` | The **database**: `current_database()` and its oid |
185
+ | 6 | `release_sha` | The commit this speaks for |
186
+ | 7 | `package_version` | The package that produced it |
187
+ | 8 | `migration_name` | With its `.sql` suffix |
188
+ | 9 | `migration_digest` | sha256 of the migration's bytes |
189
+ | 10 | `run_id` | The scan this approval descends from |
190
+ | 11 | `input_fingerprint` | The rows as they stood when read |
191
+ | 12 | `manifest_digest` | The whole catalog of migrations |
192
+ | 13 | `decision_count` | How many answers, as a decimal |
193
+ | 14 | `decisions` | Canonical JSON — sorted keys, no spaces |
194
+ | 15 | `decisions_digest` | sha256 of exactly the text on line 14 |
195
+ | 16 | `evaluated_at` | UTC, whole seconds, ending `Z` |
196
+ | 17 | `approval_expires_at` | UTC, whole seconds, ending `Z` |
197
+ | 18 | `classified_by_email` | Who answered the pages |
198
+ | 19 | `approved_by_email` | Who approved the answers |
199
+
200
+ `record` refuses harder than `options` does, on purpose. It requires
201
+ `staging` or `production` rather than any environment string, and it checks
202
+ every field's shape — the nonce is hex, the digests are forty hex characters,
203
+ the timestamps are whole seconds, an address is at most 254 characters counted
204
+ whole, `@` and domain included, nothing is outside printable ASCII — before it
205
+ prints anything. Each of those is the reader's own bound, counted the way the
206
+ reader counts it, so that what this command prints is what a server accepts. The connection road would let some of those through and let the
207
+ migration decide. The record road cannot: by the time the file is read, you are
208
+ not at the keyboard, so the refusal has to happen while you still are.
209
+
210
+ ### Two identities, and why the record carries both
211
+
212
+ `database_identity` names the **cluster**. It is what the deployment tooling
213
+ asks about, because that side is deciding whether this approval belongs to the
214
+ machine it is standing on. `review_database_identity` names the **database**,
215
+ which is what the review actually read and what the migration checks, because a
216
+ cluster can hold more than one and only one of them was scanned. Neither is a
217
+ substitute for the other, and the record carries both rather than making one
218
+ side reinterpret a field that means something else to whoever wrote it.
219
+
220
+ The run the migration writes keeps the sharper of the two, in
221
+ `cms_publication_review_runs.review_database_identity`. The cluster identifier
222
+ has already done its work by then — the deployment tooling checked it before it
223
+ opened a connection — and a second copy of a settled answer is something to
224
+ drift rather than something to check against. The column is named for the
225
+ setting it came from, so a run found in a backup months later does not have to
226
+ be guessed at.
227
+
228
+ ### Two people, and why the record carries both
229
+
230
+ An approval is two acts. Somebody classified the pages, and somebody approved
231
+ the classification. Today `cms-review` records the same address for both,
232
+ because one person did both; the record still carries two fields, so a
233
+ four-eyes procedure does not need a new record format, and so the migration's
234
+ audit row can name a person rather than recording the change as unattended.
235
+ Where the two agree, `install-lovable-migration-approval` prints one line; where
236
+ they differ, it prints both.
237
+
238
+ ### Zero answers, written down
239
+
240
+ An approval with nothing to answer is expressible, and it is not the same thing
241
+ as no approval. `decision_count` is `0`, `decisions` is `[]`, and
242
+ `decisions_digest` is the sha256 of those two bytes — the same value the shell
243
+ computes with `sha256sum` and the same one PostgreSQL computes over the `jsonb`,
244
+ because the CLI emits canonical JSON rather than whatever key order it happened
245
+ to build the object in. That is what records a deliberate approval of a run the
246
+ scan would have been content to make on its own.
247
+
248
+ ### Two limits worth knowing before you plan a large review
249
+
250
+ No value may contain a space. The server splits `PGOPTIONS` on whitespace, so a
251
+ note with a space in it arrives as a separate unparseable argument and the
252
+ connection fails saying nothing about notes. `cms-review` rejects the space
253
+ where you typed it instead.
254
+
255
+ The whole string must fit in the startup packet, which is a little under 10000
256
+ bytes. `options` refuses above 9000 rather than let the connection fail on the
257
+ far side with no cause named, and `record` refuses above 8192 for the same
258
+ reason one step earlier, since the deployment tooling composes the same options
259
+ from the file. A site with more answered pages than that fits should be answered
260
+ in smaller groups, or have some of the pages resolved in the backoffice before
261
+ the upgrade.
262
+
263
+ ### Proving the two ends still agree
264
+
265
+ The record is consumed by a shell function in another repository, which is the
266
+ kind of seam that drifts without anyone noticing. `ops/approval-record-roundtrip.mjs`
267
+ runs the real CLI against a real PostgreSQL, hands the file it produced to that
268
+ repository's real reader, and applies the real migration with the options that
269
+ reader composed:
270
+
271
+ ```
272
+ CMS_ROUNDTRIP_TEMPLATE=../lovable-selfhost-deployment-template bun run test:roundtrip
273
+ ```
274
+
275
+ It needs Docker and a checkout of the deployment template, so it is a release
276
+ gate rather than a unit test: it is named in `package.json` as
277
+ `test:roundtrip`, it is step 3 of the release process in
278
+ [CONTRIBUTING.md](../CONTRIBUTING.md), and it is deliberately outside
279
+ `bun run check`, which has to run anywhere. The checkout is an argument or
280
+ `CMS_ROUNDTRIP_TEMPLATE`, never a fixed path. Run it whenever either side of
281
+ the record changes — a change made on one side only is invisible to both
282
+ repositories' CI and fatal on a server.
283
+
284
+ ## Running it without an approval
285
+
286
+ Most sites never need any of the above. The migration counts the pages that
287
+ require an answer before it does anything else, and if there are none this is an
288
+ ordinary schema change applied as one: the `preserved` pages are repaired, the
289
+ rest are already correct, and no approval is asked for.
290
+
291
+ If there is even one page that needs an answer and no approval was supplied, the
292
+ migration stops and names the settings it did not get. It does not defer those
293
+ pages to a later run and it does not pick for you — a page going dark and a page
294
+ staying live are both defensible, and which one is right is not in the database.
295
+
296
+ An approval is honoured even when the scan would have been content to proceed
297
+ alone, which is the way to record the case the count cannot see: a page the
298
+ default would have kept alive that somebody has decided should stay dark.