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