@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 CHANGED
@@ -8,6 +8,131 @@ matches `package.json` exactly. See
8
8
  Versions 0.1.0 through 0.78.0 predate this repository and were reconstructed
9
9
  from the published npm tarballs.
10
10
 
11
+ ## 0.92.1
12
+
13
+ - Fix the page-builder save and publish RPC call by removing the unsupported
14
+ `_source_page_id` argument. The database supplies the source page internally,
15
+ preserving self-reveal protection. No migration is added or changed, and
16
+ platform contract v3 is unchanged.
17
+ - Clarify publication RPC errors so an argument mismatch is not presented as
18
+ proof that migrations must be replayed. Include only a recognized diagnostic
19
+ code and request a schema comparison before applying changes.
20
+
21
+ ## 0.92.0
22
+
23
+ **Breaking: platform contract v3.** A site pinned to this package must call
24
+ `assertPlatformContract(3)`, and its database must have applied
25
+ `20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24.sql`. Upgrading a live
26
+ site is an ordered procedure, not a deploy — read the next four bullets before
27
+ starting one.
28
+
29
+ - **The publication reader changed, and on some sites that takes a page down.**
30
+ `0.90.0-m7.12` dropped closed publication rows and then took the newest of
31
+ what was left; v3 takes the newest row that has started and only then asks
32
+ whether it is still open. The new order is what makes a scheduled window mean
33
+ what it says, but where a page's newest row has expired and an older one is
34
+ still open, the old reader served the open row and the new one serves nothing.
35
+ The page goes dark on deploy with no error anywhere.
36
+ - **`cms-review` is the procedure for that**, and ships as a `bin` of this
37
+ package. `cms-review scan` reads the site and classifies every live page;
38
+ pages the upgrade can keep alive on its own are repaired without being asked
39
+ about, and the three classifications that carry two opposite intentions —
40
+ contradictory rows, the two readers serving different versions, and a page
41
+ whose only window has not opened yet — stop the upgrade until somebody
42
+ answers. `cms-review approve` seals the answers to one database, one release,
43
+ one package version, one migration file and one hour; the migration recomputes
44
+ all of it and refuses an approval that no longer matches. Full procedure in
45
+ `docs/PUBLICATION-REVIEW.md`.
46
+ - **`cms-review record` carries an approval to a machine with no psql prompt.**
47
+ A self-hosted VPS installs the approval as a file and reads it back with a
48
+ shell function in the deployment template; that file used to be written by
49
+ hand against a format nobody generated, and four things about it disagreed
50
+ with what `cms-review` emitted — hyphenated nonces, millisecond timestamps, a
51
+ missing `decision_count`, and two email addresses the record had no slot for,
52
+ so the migration recorded a reviewed change as unattended. Both roads are now
53
+ rendered from one builder, so they cannot drift again. The record is nineteen
54
+ ordered `key=value` lines, printable ASCII, checked field by field before it
55
+ is printed. `decisions` is canonical JSON, which is what makes the shell's
56
+ `sha256sum` of that text, the migration's digest of the same value as `jsonb`
57
+ and `decisions_digest` agree; a zero-decision approval is expressible as
58
+ `decision_count=0` with `decisions=[]`. `ops/approval-record-roundtrip.mjs`
59
+ proves the whole path end to end against the deployment template's real
60
+ reader and a real PostgreSQL; it is `bun run test:roundtrip`, and step 3 of
61
+ the release process asks for it whenever the record's format moves.
62
+ - **Two identities, and they are not interchangeable.** `database_identity`
63
+ names the cluster, which is what the deployment tooling checks; the new
64
+ `review_database_identity` names the database the scan actually read, which
65
+ is what the migration checks and what it now requires. A cluster holds more
66
+ than one database and only one of them was reviewed. The run records the
67
+ sharper one, in `cms_publication_review_runs.review_database_identity` — a
68
+ column named for the setting it holds, rather than for the other one.
69
+ - **Release order, and it is not optional.** Apply every missing canonical
70
+ migration; scan, review and apply the publication decisions; then deploy this
71
+ package with the site pinned to contract v3. The migration itself publishes,
72
+ unpublishes and edits nothing — applying it changes what the site can be
73
+ *asked*, not what it serves — which is what makes the first step safe to take
74
+ ahead of the deploy.
75
+ - **A site with nothing to decide upgrades with no ceremony.** The migration
76
+ counts the pages needing an answer first; where there are none it is an
77
+ ordinary schema change. Where there is even one and no approval was supplied,
78
+ it stops and names what it did not get rather than choosing.
79
+ - **Five facts that used to be one.** "The deployment succeeded" stood for the
80
+ migration having run, the schema therefore offering a capability, somebody
81
+ having understood what the new reader does to the existing rows, those
82
+ decisions having been applied, and the routes having been activated. Each now
83
+ has its own record and the later ones refuse to be written while an earlier
84
+ one is missing. `cms_schema_capabilities` is written by the migration that
85
+ makes it true, so it cannot drift from the schema it describes.
86
+ - **Duplicating a page is one transaction.** It was a sequence of unrelated
87
+ inserts that could leave a page with no version, or two copies behind one
88
+ retried click. `cms_duplicate_page` takes the page lock, checks that the
89
+ source still holds the version and the content the caller read, and records
90
+ the copy against a caller-supplied idempotency key inside the same
91
+ transaction — so a retry returns the first copy instead of making a second,
92
+ and a rollback leaves the key unspent.
93
+ - **A route has one owner.** Two pages could hold the same URL, because nothing
94
+ in the database was responsible for the pair. `(origin, path)` is now a
95
+ primary key, and a release's claims are written against it. A clash comes
96
+ back as a value listing every colliding route and changes nothing, rather
97
+ than raising on the first one — a deploy can then report every clash at once
98
+ instead of sending an operator to find the rest by hand.
99
+ - **One lock order everywhere**: pages, then publications, then review, with the
100
+ route advisory lock taken after the page lock. Two deploys racing for the same
101
+ route take turns instead of interleaving.
102
+ - **One list of canonical migrations.** Two modules used to carry the same sixty
103
+ names, and the test comparing them could only report that they had already
104
+ drifted apart — it could not say which was right. The catalog is now
105
+ generated from the `migrations/` directory with a digest per file, and
106
+ `bun run manifest:check` fails the build — and the release — when the file
107
+ and the catalog disagree. `cms-review` cross-checks it before sealing
108
+ anything, because an approval binds itself to what the package ships.
109
+ - **Replace mode clears once, before anything is written.** It used to delete
110
+ inside the per-table loop, so a later table's `DELETE` could remove rows an
111
+ earlier one had just written, and a table the archive said nothing about was
112
+ skipped before the delete ran — leaving it populated in the one mode whose
113
+ whole job is to replace it. The selected groups are now emptied in a single
114
+ pass in reverse dependency order, with deferred pointers released first, and
115
+ a clear that fails aborts that table's children instead of warning.
116
+ - **Breaking for callers of `backup/restore-core`**: deciding what to clear and
117
+ rendering it are now two functions. `buildClearPlan(groups)` returns the
118
+ steps, and it is the one that drops insert-only tables; `buildClear(step)`
119
+ renders one and always answers a statement, where it used to be
120
+ `Statement | null` and returned null for an insert-only table. TypeScript
121
+ rejects the old call, but a suite that only runs tests will see a `DELETE`
122
+ for a table that used to be spared — so read `buildClearPlan` first if you
123
+ clear tables yourself.
124
+ - **Archive schema 3.** Backups written by this version declare
125
+ `SCHEMA_VERSION` 3 and carry the three publication-review tables. The archive
126
+ *format* is unchanged at 2, so a 0.91.x build still opens the container — and
127
+ then refuses it, because it has no branch that says what schema 3 means. That
128
+ refusal is the point: an archive is read by a build that knows what every
129
+ version in it meant, or it is not read.
130
+ - **Restoring a 0.91.x backup into a v3 site is supported.** Schema 2 has no
131
+ review history, no page status and no publication signature, but everything
132
+ it does say still means what it said, and what it omits is recoverable by
133
+ scanning the restored content. The restore records a reconciliation run
134
+ rather than inventing a review that never happened.
135
+
11
136
  ## 0.91.1
12
137
 
13
138
  - Documentation correction, and nothing else. The npm-facing `README.md` said
package/README.md CHANGED
@@ -43,6 +43,7 @@ exactly as it did before locks existed.
43
43
  | `docs/MEDIA-FOLDERS.md` | Automatic month folders, custom upload folders and their sanitising, library browsing |
44
44
  | `docs/GOOGLE-SHEETS.md` | Service-account form delivery, the connection test, and the server variables it needs |
45
45
  | `docs/TYPOGRAPHY.md` | Independent Google Fonts for the menu and the footer |
46
+ | `docs/PUBLICATION-REVIEW.md` | The `cms-review` procedure for upgrading a live site to contract v3 |
46
47
 
47
48
  ## Server variables
48
49
 
@@ -59,21 +60,31 @@ related is ever read in the browser, and the font picker needs no API key. See
59
60
  releases are always compatible.
60
61
  - `PLATFORM_CONTRACT_VERSION` (`platform/contract`) is bumped when a shared
61
62
  screen starts requiring schema or adapter capabilities an older site lacks.
62
- Sites pin it in a test. It is **v2**: page saves, publishing, trashing and
63
- restoring go through database functions. Upgrading straight from the last
64
- pre-v2 published package, `0.90.0-m7.12`, means applying all three new
65
- canonical migrations, in chronological order:
63
+ Sites pin it in a test. It is **v3**: page saves, publishing, trashing and
64
+ restoring go through database functions (v2), and a release records the five
65
+ facts it used to assert with one sentence — migration applied, schema
66
+ capability, publication scan, decisions applied, routes activated — as five
67
+ separate records the later of which refuse to be written without the earlier
68
+ (v3). Upgrading straight from the last pre-v2 published package,
69
+ `0.90.0-m7.12`, means applying all four new canonical migrations, in
70
+ chronological order:
66
71
 
67
72
  1. `20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql`
68
73
  2. `20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql`
69
74
  3. `20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql`
75
+ 4. `20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24.sql`
70
76
 
71
- The last two are the pair that establishes contract-v2 publication
77
+ Files 2 and 3 are the pair that establishes contract-v2 publication
72
78
  behaviour, but the first is canonical schema too, so applying only the pair
73
- still leaves `missingMigrations` and `compareSchema` reporting drift. Apply
74
- them before the v2 package is deployed, then verify the two things
75
- separately: `missingMigrations(applied)` reports nothing missing, **and**
76
- `assertPlatformContract(2)` passes. The second only compares version numbers
79
+ still leaves `missingMigrations` and `compareSchema` reporting drift. File 4
80
+ adds contract v3: the release ledger, the review model, the route claims, one
81
+ lock order every page mutation follows, and duplication as a single
82
+ transaction. It replaces five publication function bodies at the signatures
83
+ they already have, without changing what any of them decides, so nothing a
84
+ visitor sees today changes.
85
+ Apply them before the v3 package is deployed, then verify separately:
86
+ `missingMigrations(applied)` reports nothing missing, **and**
87
+ `assertPlatformContract(3)` passes. The second only compares version numbers
77
88
  in the site's own code and never reads the database, so it is no substitute
78
89
  for the first. `docs/NEW-SITE.md` gives the full order.
79
90
  - `migrations/` is schema only. Site content, SEO copy and seeds never belong
package/docs/NEW-SITE.md CHANGED
@@ -34,34 +34,42 @@ Add one test that pins the contract version the site was built against and one
34
34
  that asserts `missingMigrations(appliedFilenames)` is empty. See
35
35
  `tests/platform-contract.test.ts` in any existing site for the exact shape.
36
36
 
37
- The current contract is **v2**, because saving a page version — an ordinary
38
- draft save included — is a single database function call with no JavaScript
39
- fallback. Three canonical migrations are new since the last published
40
- `0.90.0-m7.12` package, so a site upgrading from that baseline is missing all
41
- three:
37
+ The current contract is **v3**. It is v2 for the reason v2 existed — saving a
38
+ page version, an ordinary draft save included, is a single database function
39
+ call with no JavaScript fallback — and v3 because a release now records the
40
+ five facts it used to assert with one sentence. "The deployment succeeded" used
41
+ to mean, all at once: the migration ran, the schema therefore offers a
42
+ capability, somebody looked at the existing publication rows and understood
43
+ what the new reader does differently to them, those decisions were applied, and
44
+ the routes were activated. Each fails on its own, so each has its own record,
45
+ and the later ones refuse to be written while an earlier one is missing.
46
+
47
+ Four canonical migrations are new since the last published `0.90.0-m7.12`
48
+ package, so a site upgrading from that baseline is missing all four:
42
49
 
43
50
  | File | Adds |
44
51
  | --- | --- |
45
52
  | `20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql` | restates the `draft` default, records the `pages.status` column semantics at that migration step, and adds two partial `pages` indexes: one on `key` for undeleted status-published rows, one on `status`/`updated_at` for undeleted rows |
46
53
  | `20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql` | `page_publications` — the row that decides what is public — the backfill for pages that were already live, and the first transactional page functions |
47
54
  | `20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql` | the rest of the transactional page lifecycle: reveals, trash, restore and cancellation, plus two repair `UPDATE`s |
55
+ | `20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24.sql` | contract v3: the installation identity, the schema-capability record, the canonical digest functions, the publication review model, the route claims, the readiness and activation records, one lock order every page mutation follows, and `cms_duplicate_page` |
48
56
 
49
- The last two are the contract-v2 publication pair: they are what
50
- `PLATFORM_CONTRACT_VERSION = 2` means. All three are in the canonical schema
51
- manifest, though, so `missingMigrations` and `compareSchema` report every one
52
- that has not been applied — applying only the pair still leaves the schema out
53
- of sync. Go out in this order, and not in another:
57
+ Files two and three are the contract-v2 publication pair, and the fourth is
58
+ contract v3. All four are in the canonical schema manifest, so
59
+ `missingMigrations` and `compareSchema` report every one that has not been
60
+ applied — applying only the pair still leaves the schema out of sync. Go out in
61
+ this order, and not in another:
54
62
 
55
63
  1. Apply every missing canonical migration. Upgrading from `0.90.0-m7.12`, that
56
- is all three files above, in chronological order.
57
- 2. Deploy the package and the site pinned to v2.
64
+ is all four files above, in chronological order.
65
+ 2. Deploy the package and the site pinned to v3.
58
66
  3. Verify both: `missingMigrations(applied)` is empty, and
59
- `assertPlatformContract(2)` passes. The second compares two version numbers
67
+ `assertPlatformContract(3)` passes. The second compares two version numbers
60
68
  in the site's own code and never reads the database, so it is no substitute
61
69
  for the first.
62
70
  4. Let editors back in.
63
71
 
64
- The migrations are designed to be applied before the v2 package is deployed,
72
+ The migrations are designed to be applied before the v3 package is deployed,
65
73
  and the still-running m7.12 package remains compatible during that ordered
66
74
  rollout. They are not pure DDL and not function-only: the set carries an
67
75
  `ALTER … SET DEFAULT`, indexes and a column comment, a backfill `INSERT`, two
@@ -117,12 +125,19 @@ Wrap the shared server cores in the site's own `createServerFn` files:
117
125
  | `server/forms-core` | form definitions, versions, settings, delivery log |
118
126
  | `server/redirects-core` | path normalisation and redirect writes |
119
127
  | `server/page-seo-core` | slug conflicts and slug-change 301s |
128
+ | `site/route-index` | the one spelling of a page's address, shared by all of the above |
120
129
  | `server/media-usage-core` | usage scan and remote import |
121
130
  | `server/media-cleanup-core` | duplicate/rename planning and apply |
122
131
 
123
132
  The site supplies clients, storage and page-key → path maps; the package owns
124
133
  the rules. Keep privileged clients inside handlers.
125
134
 
135
+ `resolvePathCore` and `detectSlugConflictsCore` both need `defaultLocale`, and
136
+ `resolvePathCore` also needs the `locale` the site derived from the request's
137
+ domain. Pass the site's real values: a wrong default locale moves every
138
+ non-default page's prefix, and passing the default locale for every request
139
+ turns a locale domain back into a site that serves one language.
140
+
126
141
  ## 7. Backoffice routes
127
142
 
128
143
  Each backoffice route is a thin adapter around the package view
package/docs/PAGES.md CHANGED
@@ -30,6 +30,41 @@ the same question of the same rows.
30
30
 
31
31
  Knowing the URL gets a visitor a 404.
32
32
 
33
+ ## One address, spelled one way
34
+
35
+ A page's address is the locale prefix plus its slug, and every part of the
36
+ platform that needs to know one asks the same two functions for it:
37
+ `servedPath` builds it and `normalizeRoutePath` spells it. The site's own link
38
+ building, the conflict check an editor sees while typing a slug, the resolver
39
+ that turns an incoming request into a page and the route claims written at
40
+ deploy all go through that pair, in `site/route-index`.
41
+
42
+ They used to have three opinions between them, and each disagreement was a bug
43
+ you could see from the outside:
44
+
45
+ - The resolver put every locale's slugs into one map, first one in wins. Two
46
+ pages sharing a slug in different locales were one page, and the loser was
47
+ unreachable at every address it was supposed to answer at.
48
+ - The conflict check compared bare slug strings, one locale at a time. `/Om-Oss`
49
+ and `/om-oss` were two pages to the editor and one page to the browser; a
50
+ Swedish page slugged `en` and the English front page both answered at `/en`
51
+ and nothing ever said so.
52
+ - Neither knew that a page with no slug in a locale is still served there under
53
+ its default-locale slug, so an address the site really serves could be claimed
54
+ twice.
55
+
56
+ `resolvePathCore` now takes the locale the host derived from the domain, and a
57
+ locale prefix in the path overrides it — the address the visitor typed is the
58
+ address they asked for. Its answer carries the locale it resolved under, so the
59
+ caller renders the language the URL named rather than the one the host guessed.
60
+ Within that locale a slug answers both prefixed and bare, because a locale
61
+ served from its own domain has no prefix to strip; across locales nothing is
62
+ shared.
63
+
64
+ Stored redirect rows are the exception: they are literal strings an editor
65
+ typed, and they are matched as they were stored. Re-spelling one through the
66
+ route identity would look it up at an address the table does not hold.
67
+
33
68
  ## One rule decides what is public
34
69
 
35
70
  `src/cms/page-publication.ts` holds it, in a sentence:
@@ -164,42 +199,65 @@ carry a 404 five seconds past the moment a page goes live.
164
199
 
165
200
  ### The migrations, and the order they go out in
166
201
 
167
- Three files are new since the last published `0.90.0-m7.12` package, so a site
168
- upgrading from that baseline is missing all three:
202
+ Four files are new since the last published `0.90.0-m7.12` package, so a site
203
+ upgrading from that baseline is missing all four:
169
204
 
170
205
  | File | Adds | Needed for |
171
206
  | --- | --- | --- |
172
207
  | `20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql` | restates the `draft` default, records the `pages.status` column semantics at that migration step, and adds two partial `pages` indexes: one on `key` for undeleted status-published rows, one on `status`/`updated_at` for undeleted rows | canonical schema parity |
173
208
  | `20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql` | `page_publications`, the backfill, `cms_save_page_version`, `cms_set_page_status`, `cms_schedule_page_reveal` | contract-v2 publication behaviour |
174
209
  | `20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql` | reveals and audit metadata inside `cms_save_page_version`, `cms_soft_delete_page`, `cms_restore_page`, `cms_cancel_page_publication`, `cms_schedule_page_version`, and two repair `UPDATE`s | contract-v2 publication behaviour |
175
-
176
- The last two are the pair that establishes contract-v2 publication behaviour:
177
- together they are what `PLATFORM_CONTRACT_VERSION = 2` means. A site declares the
178
- contract it was built against and `assertPlatformContract` refuses the mismatch
179
- at startup, rather than letting an editor discover it when a publish fails
180
- halfway.
181
-
182
- All three are listed in `SCHEMA_MIGRATIONS`. `compareSchema` names whichever of
210
+ | `20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24.sql` | `app.cms_lock_pages` and the rewritten bodies of `cms_save_page_version`, `cms_set_page_status`, `cms_schedule_page_reveal`, `cms_schedule_page_version` and `cms_cancel_page_publication` — the same signatures, taking the same locks in the same order — plus `cms_duplicate_page` and the ledger it retries against | contract-v3 publication behaviour |
211
+
212
+ The last three establish contract-v3 publication behaviour: together they are
213
+ what `PLATFORM_CONTRACT_VERSION = 3` means. A site declares the contract it was
214
+ built against and `assertPlatformContract` refuses the mismatch at startup,
215
+ rather than letting an editor discover it when a publish fails halfway.
216
+
217
+ The fourth file changes no signature and no decision: every one of the five
218
+ bodies it replaces answers exactly what it answered before. What changes is the
219
+ order in which each one takes its locks. Every page a statement will touch is
220
+ locked first, in `uuid` order, by a single `app.cms_lock_pages` call; only then
221
+ are that page's publication rows locked, by `id`. Two things were wrong before.
222
+ A save that also scheduled a reveal locked its own page and then, still holding
223
+ it, reached for the reveal's target — so two editors saving two pages that point
224
+ at each other could each end up holding what the other was waiting for. And
225
+ `cms_cancel_page_publication` locked the publication before the page, the
226
+ reverse of every sibling. One order, with the page tier taken in one statement,
227
+ is what removes both.
228
+
229
+ `cms_duplicate_page` is that same rule applied to a copy. Source and destination
230
+ are locked together, the source's blocks are re-identified and written to a new
231
+ draft, and the caller's idempotency key is recorded — one transaction that
232
+ either produces the whole copy or leaves nothing behind. The caller sends a
233
+ digest of the content it read, so a source that moved in between is refused
234
+ rather than quietly copied from something the editor never saw, and a retry
235
+ carrying the same key answers with the copy that already exists instead of
236
+ making a second one.
237
+
238
+ All four are listed in `SCHEMA_MIGRATIONS`. `compareSchema` names whichever of
183
239
  them is missing and does not report in sync until every canonical migration has
184
240
  been applied, so applying only the publication pair leaves the drift check
185
- pointing at the first file.
241
+ pointing at the first file and the fourth.
186
242
 
187
243
  Release order, and it is not interchangeable:
188
244
 
189
245
  1. **Apply every missing canonical migration.** Upgrading from `0.90.0-m7.12`,
190
- that is all three files above, in chronological order. They add a column
246
+ that is all four files above, in chronological order. They add a column
191
247
  default, indexes and a comment, a table, functions and replaced function
192
- bodies, and the running m7.12 package keeps working against them. They are
193
- not, however, pure DDL: the second backfills an open publication for every
194
- published page that has none, and the third runs two repair `UPDATE`s over
195
- rows the old code left inconsistent — retiring publications still open on
196
- pages already in the trash, and setting those pages' status back to `draft`.
197
- Both are described below. Nothing is deleted and no page that is public
198
- before the upgrade stops being public because of it.
199
- 2. **Deploy the contract-v2 package.** It calls the new signatures, which by then
248
+ bodies, and the running m7.12 package keeps working against them — including
249
+ the fourth, whose replaced bodies keep both the signatures and the answers
250
+ that package already expects. They are not, however, pure DDL: the second
251
+ backfills an open publication for every published page that has none, and the
252
+ third runs two repair `UPDATE`s over rows the old code left inconsistent —
253
+ retiring publications still open on pages already in the trash, and setting
254
+ those pages' status back to `draft`. Both are described below. Nothing is
255
+ deleted and no page that is public before the upgrade stops being public
256
+ because of it.
257
+ 2. **Deploy the contract-v3 package.** It calls the new signatures, which by then
200
258
  exist.
201
259
  3. **Verify both.** `compareSchema` reports in sync — no canonical migration
202
- missing — and `assertPlatformContract(2)` returns. The second compares two
260
+ missing — and `assertPlatformContract(3)` returns. The second compares two
203
261
  version numbers in the site's own code and never reads the database, so on
204
262
  its own it says nothing about whether the migrations ran.
205
263
  4. **Let editors back in.**