Skip to content

Commit 2866d5f

Browse files
Elon Muskclaude
andauthored
feat(cli): os migrate duplicates reports the rows blocking the kernel:ready index tightenings (#11031)
Three migrations replace a declared UNIQUE index with the NULL-safe — and sometimes active-rows-only — form it was always meant to have, at `kernel:ready` on a serving boot: `ensureMetadataOverlayIndexes` (`sys_metadata`, one index per overlay state), `ensureViewDefinitionActiveIndex` (`sys_view_definition`) and `ensureSysSettingIdentityIndex` (`sys_setting`). Each is a tightening, so rows an installation already holds can block it; the migration then refuses under ADR-0120 D4 — previous index kept, no row touched, boot continues — and reports at `error` on the boot channel. That channel was the only one, and not by omission. These indexes are invisible to the drift differ by construction, twice over: after the tightening `isRuntimeManagedIndex` excludes the index (without that exclusion a boot would propose rebuilding away the guarantee it had just created), and before it each migration deliberately reuses the DECLARED index's name, so the reconciler's name-matched slot reads as filled whichever physical form is really there. A prior round measured it with a matched control — one database carrying the same duplicate damage under a declared organization-unique index and under `sys_view_definition`'s runtime one — and `os migrate plan` named the declared one in full while saying nothing whatsoever about the runtime one. Maintainer ruling, 2026-08-22: the reporting path is `os migrate duplicates`, which already boots read-only and owns the "inventory, never repair" contract, keeping `os migrate plan`'s drift contract untouched. `plan` describes work `os migrate apply` will do; this work is applied by the next SERVING boot, by a different applier. What lands: * `@objectstack/metadata-protocol` gains `runtime-index-preflight.ts` — `runtimeIndexProbes()` and `collectRuntimeIndexPreflight()`. The descriptors read each migration's OWN exported builders rather than restating the keys, so the pre-flight and the boot report cannot describe different duplicates, and the `sys_setting` probe uses the migration's MySQL spelling on MySQL, where the bare form is ERROR 1064 on the reserved word `key`. * The report gains `runtimeIndexPreflight` (one entry per index: blocked/clear/table-absent/unreadable, a blocked one naming every colliding key group and its row count) plus `summary.runtimeIndexesBlocked` and `summary.runtimeIndexBlockingRows`. `reportVersion` moves 1 → 2: every version-1 field keeps its name, shape and meaning, and the bump says there is more in the document for a consumer that validates it strictly. * `collectDuplicateIdentifierReport`'s new option is REQUIRED rather than optional. An optional section defaults to `[]`, and `[]` is also what a clean database produces, so a caller that forgot to wire it would ship a clean bill of health from a probe that never ran — the #10677 failure, one section over. Required moves that mistake to a compile error. * Liveness is keyed on whether the seam returns a RESULT SET, reusing the sibling migration's `isResultSet` rather than copying it. A no-op seam would otherwise report all four tightenings as `table-absent`, which is that same failure again wearing a different status. * Nine referral sites repointed, not deleted (the ruling is explicit): three conflict-error strings that told the operator to "run `os migrate plan`" — an instruction the measurement proved false — and the six doc comments that state the same referral as part of the D4 disposition. Their pins now assert both that the new command is named and that the false one is gone. Nothing about a migration's behaviour changes: no tightening is armed, deferred or altered, and `plan` is untouched. The pre-flight only makes the refusal's evidence readable one command before the restart. Read-only is pinned LOGICALLY — schema plus every row, ordered — in both the new unit suite and the CLI integration test, never by a file hash: a raw hash over a SQLite file moves on any read-write open and would accuse this command of mutating the install it exists to describe. Fixes #8725 Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r Co-authored-by: Claude <noreply@anthropic.com>
1 parent cec9d23 commit 2866d5f

17 files changed

Lines changed: 1071 additions & 42 deletions
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
`os migrate duplicates` now reports the rows blocking the three `kernel:ready`
7+
NULL-safe index tightenings, and the three migrations' conflict messages point
8+
there instead of at `os migrate plan` (#8725).
9+
10+
**The gap.** Three migrations replace a declared UNIQUE index with the NULL-safe
11+
— and sometimes active-rows-only — form it was always meant to have, at
12+
`kernel:ready` on a serving boot:
13+
14+
| table | index(es) | migration |
15+
| --- | --- | --- |
16+
| `sys_metadata` | overlay `active` + `draft` | `ensureMetadataOverlayIndexes` |
17+
| `sys_view_definition` | `idx_sys_view_def_active` | `ensureViewDefinitionActiveIndex` |
18+
| `sys_setting` | the declared row identity | `ensureSysSettingIdentityIndex` |
19+
20+
Each is a tightening, so rows an installation already holds can block it. The
21+
migration then refuses — previous index kept, no row touched, boot continues —
22+
and reports at `error` on the boot channel. That channel was the only one:
23+
these indexes are invisible to `os migrate plan` **by construction**, twice
24+
over. After the tightening, `isRuntimeManagedIndex` excludes the index (without
25+
that exclusion a boot would propose rebuilding away the guarantee it had just
26+
created); before it, each migration deliberately reuses the *declared* index's
27+
name, so the reconciler's name-matched slot reads as filled whichever physical
28+
form is really there. Measured with a matched control — one database carrying
29+
the same duplicate damage under a declared index and under
30+
`sys_view_definition`'s runtime one — `plan` named the declared one in full and
31+
said nothing whatsoever about the runtime one.
32+
33+
**What is new.** The report gains a `runtimeIndexPreflight` section, one entry
34+
per index, each `blocked` (with every colliding key group and its row count),
35+
`clear`, `table-absent` (`sys_setting` arrives with the optional settings
36+
service) or `unreadable` (with the driver's own message), plus
37+
`summary.runtimeIndexesBlocked` and `summary.runtimeIndexBlockingRows`.
38+
`reportVersion` moves `1` → `2`. Every `1` field keeps its name, shape and
39+
meaning; the bump says there is more in the document, for consumers that
40+
validate it strictly.
41+
42+
The probes are the migrations' own duplicate-listing statements —
43+
`@objectstack/metadata-protocol` exports `collectRuntimeIndexPreflight` and
44+
`runtimeIndexProbes`, which read those builders rather than restating the keys,
45+
so the pre-flight and the boot report cannot describe different duplicates. On
46+
MySQL the `sys_setting` probe uses the migration's MySQL spelling, where the
47+
bare form is `ERROR 1064` on the reserved word `key`.
48+
49+
**The referral, repointed rather than deleted** (maintainer ruling, 2026-08-22).
50+
All three conflict messages told the operator to "run `os migrate plan`" as an
51+
alternative way to list the blocking rows, and that instruction was false: they
52+
now name `os migrate duplicates`, which answers it. The six doc comments that
53+
state the same referral as part of the ADR-0120 D4 disposition are updated with
54+
them.
55+
56+
**Nothing about a migration's behaviour changes.** No tightening is armed,
57+
deferred or altered, and `os migrate plan`'s drift contract is untouched. The
58+
pre-flight only makes the refusal's evidence readable one command before the
59+
restart — from a command that boots read-only and writes nothing, which is
60+
pinned logically (schema plus every row, ordered) rather than by a file hash: a
61+
raw hash over a SQLite file moves on any read-write open and would accuse this
62+
command of mutating the install it exists to describe.

‎content/docs/deployment/cli.mdx‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,7 @@ where the data lives.
677677
| `os migrate value-shapes` | Scan stored reference and structured-JSON field values against the platform's value contract, and record the deployment's migration flag when clean |
678678
| `os migrate summary-nulls` | Backfill roll-up `count` / `sum` columns still stored as `NULL` on parent rows created before the insert-time seed. Repairs values; no flag, nothing depends on it having run |
679679
| `os migrate meta --stored` | Replay the metadata conversion chain over this deployment's `sys_metadata` rows and rewrite the ones still carrying a pre-protocol shape. Hygiene, not a gate — nothing depends on it having run |
680-
| `os migrate duplicates` | Report business identifiers already minted twice across the organization partitions — a read-only inventory as JSON on stdout. Renumbers nothing and writes nothing at all; run it before the boot-time tenancy repair, which overwrites part of the evidence |
680+
| `os migrate duplicates` | Report business identifiers already minted twice across the organization partitions, and the rows blocking the boot-time NULL-safe index tightenings — a read-only inventory as JSON on stdout. Renumbers nothing and writes nothing at all; run it before the boot-time tenancy repair, which overwrites part of the evidence |
681681

682682
```bash
683683
os migrate files-to-references # Dry run: full report, writes nothing
@@ -1011,6 +1011,57 @@ that could not be probed is listed under `skipped` with its reason, because
10111011
a driver with no raw SQL seam (memory, MongoDB) fails the whole run with
10121012
`error: "no_sql_seam"` rather than returning an empty inventory.
10131013

1014+
##### The `kernel:ready` index pre-flight
1015+
1016+
The report carries a second section, `runtimeIndexPreflight`, answering a
1017+
different question: **will the next server start be able to finish tightening
1018+
the platform's own unique indexes?**
1019+
1020+
Three migrations run at `kernel:ready` on a serving boot (`os dev`, `os serve`,
1021+
`os start`) and replace a declared UNIQUE index with the NULL-safe — and
1022+
sometimes active-rows-only — form it was always meant to have:
1023+
1024+
| Table | Index | What the tightening adds |
1025+
| :--- | :--- | :--- |
1026+
| `sys_metadata` | overlay `active` and `draft` | package-less overlays stop being NULL-distinct |
1027+
| `sys_view_definition` | `idx_sys_view_def_active` | shared and environment-level views stop being NULL-distinct, and only active rows are constrained |
1028+
| `sys_setting` | the declared row identity | tenant- and global-scope rows stop being NULL-distinct on `user_id` |
1029+
1030+
Each is a **tightening**, so rows an installation already holds can block it.
1031+
When that happens the migration refuses — the previous index stays in place, no
1032+
row is touched, and the server keeps running — and reports it at `error` in the
1033+
boot log. Until this section existed that log line was the only channel: these
1034+
indexes are invisible to `os migrate plan` by construction, because the drift
1035+
reconciler deliberately excludes runtime-managed indexes (otherwise the next
1036+
boot would propose rebuilding away the guarantee it just created), and because
1037+
each migration reuses the *declared* index's name, so the reconciler's slot for
1038+
it reads as correctly filled whichever form is physically there.
1039+
1040+
So the pre-flight lives here instead, on the command that already boots
1041+
read-only and repairs nothing. It runs the migrations' own duplicate-listing
1042+
queries — the exact statements the boot log prints — and reports one entry per
1043+
index:
1044+
1045+
| `status` | Meaning |
1046+
| :--- | :--- |
1047+
| `blocked` | Rows collide under the tightened key. `groups` lists each colliding key and how many rows hold it. The next serving boot will refuse this index |
1048+
| `clear` | The probe ran and nothing collides |
1049+
| `table-absent` | The table is not installed here. `sys_setting`, for instance, arrives with the optional settings service |
1050+
| `unreadable` | The probe could not run; `detail` carries the driver's message |
1051+
1052+
`summary.runtimeIndexesBlocked` and `summary.runtimeIndexBlockingRows` are the
1053+
same finding counted at the head of the document.
1054+
1055+
Read `blocked` as **work to do before the restart, not damage**: nothing is
1056+
lost while an index stays untightened, but the guarantee it carries is not in
1057+
force until the listed rows are resolved — and only an operator can decide which
1058+
of two colliding rows survives, which is why the platform refuses rather than
1059+
picking one.
1060+
1061+
`--object` does not narrow this section. It is a fixed set of platform indexes
1062+
rather than a slice of your registry, and `filter` describes the object scan
1063+
only.
1064+
10141065
### Scaffolding
10151066

10161067
| Command | Alias | Description |

‎docs/qa/platform-checklist/areas/cli.json‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,7 +1047,7 @@
10471047
"title": "os migrate duplicates: a read-only JSON inventory of identifiers minted across partitions — within-partition repeats excluded, nothing written, the live two-counter condition reported, and runnable BEFORE the #8686 repair destroys the evidence",
10481048
"since": "v17",
10491049
"status": "active",
1050-
"revision": 2,
1050+
"revision": 3,
10511051
"priority": "P1",
10521052
"surface": "cli",
10531053
"personas": ["operator (local shell, pre-repair audit)"],
@@ -1066,15 +1066,16 @@
10661066
"boot the scratch app once with `os dev -d file:/tmp/<run>/dup.db` so the base schema exists; stop it",
10671067
"seed via direct SQL per the fixture recipe: the cross-partition duplicate, the within-partition repeat, an organizations row for '<org>', and the paired sequence counters",
10681068
"md5sum the DB file; run `os migrate duplicates > report.json; echo $?`; md5sum again and byte-compare",
1069-
"jq the report: .report/.reportVersion/.generatedAt/.database/.globalPartition/.filter/.counters/.scanned/.skipped/.duplicates/.liveConditions/.summary",
1069+
"jq the report: .report/.reportVersion/.generatedAt/.database/.globalPartition/.filter/.counters/.scanned/.skipped/.duplicates/.liveConditions/.runtimeIndexPreflight/.summary",
1070+
"seed the kernel:ready blocker too (#8725): two ACTIVE sys_view_definition rows with the SAME name and organization_id/owner both NULL — then jq .runtimeIndexPreflight and .summary.runtimeIndexesBlocked",
10701071
"run `os migrate duplicates --object <the-object>` and `--object <an-object-with-no-findings>` — capture .filter in both payloads",
10711072
"run `os migrate duplicates --database-url file:/tmp/<run>/dup.db` and confirm it reaches the same DB (the flag also honors OS_DATABASE_URL)",
10721073
"negative: from the memory-driver scratch config run `os migrate duplicates; echo $?` and capture the refusal payload",
10731074
"stderr/stdout split: confirm report.json parses as ONE JSON document — the boot's own log lines must have gone to stderr (#6217)"
10741075
],
10751076
"acceptance": [
10761077
{
1077-
"clause": "the report is the declared machine-readable contract on stdout: report 'duplicate-identifiers', reportVersion 1, generatedAt, database, globalPartition, filter, counters {table, status read|absent}, scanned[], skipped[], duplicates[], liveConditions[], summary — and each duplicate carries object/field/value/holderCount/partitions plus per-holder id/organization/partition/createdAt (createdAt null when the object has no such column, never a failed probe)",
1078+
"clause": "the report is the declared machine-readable contract on stdout: report 'duplicate-identifiers', reportVersion 2, generatedAt, database, globalPartition, filter, counters {table, status read|absent}, scanned[], skipped[], duplicates[], liveConditions[], runtimeIndexPreflight[], summary {…, runtimeIndexesBlocked, runtimeIndexBlockingRows} — and each duplicate carries object/field/value/holderCount/partitions plus per-holder id/organization/partition/createdAt (createdAt null when the object has no such column, never a failed probe)",
10781079
"oracle": "log",
10791080
"verify": "jq walks every declared key of the seeded run's payload; shape pinned by duplicates.contract.test.ts — cite its pass for the full-shape guarantee, drive the CLI for the instance",
10801081
"evidence": "report.json + the jq walk"
@@ -1091,6 +1092,12 @@
10911092
"verify": "the two md5sums match; duplicates.pre-repair.test.ts pins the same invariant down to _objectstack_sequences",
10921093
"evidence": "the md5 pair"
10931094
},
1095+
{
1096+
"clause": "the kernel:ready pre-flight (#8725) reports the RUNTIME-migration class os migrate plan cannot see: one entry per index the three kernel:ready migrations tighten (four — the overlay migration owns two), each blocked|clear|table-absent|unreadable, a blocked one naming every colliding key group and its row count",
1097+
"oracle": "log",
1098+
"verify": ".runtimeIndexPreflight names idx_sys_view_def_active as blocked with the seeded view name, organization_id_key '__global__' and owner_key '' — and the SAME database run through `os migrate plan` mentions neither the index nor the view name (the matched control: the declared-index duplicate above IS reported by plan, this one is not)",
1099+
"evidence": "the pre-flight section beside the plan output for one database"
1100+
},
10941101
{
10951102
"clause": "the live condition (#8928 point 4) fires exactly when a __global__ counter sits beside an org-scoped counter for the same object/field — reported as a prediction with globalLastValue and the per-org counters, and counters.status says where it was read from ('absent' still yields a complete duplicates inventory)",
10961103
"oracle": "log",

‎packages/cli/src/commands/migrate/duplicates.contract.test.ts‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ import { tmpdir } from 'node:os';
2121
import { join } from 'node:path';
2222
import { SqlDriver } from '@objectstack/driver-sql';
2323
import {
24+
collectRuntimeIndexPreflight,
2425
normalizeRows,
26+
runtimeIndexProbes,
2527
GLOBAL_TENANT,
2628
ORGANIZATION_FIELD,
2729
SEQUENCES_TABLE,
@@ -120,7 +122,7 @@ afterAll(async () => {
120122
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
121123
});
122124

123-
const collect = (objectFilter?: string) =>
125+
const collect = async (objectFilter?: string) =>
124126
collectDuplicateIdentifierReport({
125127
exec,
126128
normalize: normalizeRows,
@@ -131,14 +133,31 @@ const collect = (objectFilter?: string) =>
131133
sequencesTable: SEQUENCES_TABLE,
132134
client: 'better-sqlite3',
133135
now: () => new Date('2026-08-17T12:00:00.000Z'),
136+
// The real pre-flight against the real fixture — never a stand-in. This
137+
// database has none of the four platform tables, so every entry is
138+
// `table-absent`, and the `blocked` shape is pinned in its own test below
139+
// over a database that really carries the damage.
140+
runtimeIndexPreflight: await collectRuntimeIndexPreflight(exec, { client: 'better-sqlite3' }),
134141
...(objectFilter ? { objectFilter } : {}),
135142
});
136143

144+
/**
145+
* The four probes, as `@objectstack/metadata-protocol` declares them.
146+
*
147+
* Read from the producer rather than restated here: the descriptor (table,
148+
* index name, key parts, row scope, the two statements) is the migration's own
149+
* definition of its key, and a second copy in this file would be a second
150+
* definition to keep in step. What this file pins is that the report carries
151+
* that descriptor through UNCHANGED, plus a status and its groups.
152+
*/
153+
const PROBES = runtimeIndexProbes({ client: 'better-sqlite3' });
154+
137155
describe('#8928 os migrate duplicates — the report document', () => {
138156
it('is exactly this shape, whole', async () => {
139157
expect(await collect()).toEqual({
140158
report: 'duplicate-identifiers',
141-
reportVersion: 1,
159+
// #8725 added `runtimeIndexPreflight` and its two summary counters.
160+
reportVersion: 2,
142161
generatedAt: '2026-08-17T12:00:00.000Z',
143162
database: 'better-sqlite3 (fixture)',
144163
globalPartition: '__global__',
@@ -212,16 +231,40 @@ describe('#8928 os migrate duplicates — the report document', () => {
212231
organizationCounters: [{ organization: 'org_x', lastValue: 4 }],
213232
},
214233
],
234+
// One entry per index the three `kernel:ready` migrations tighten — FOUR,
235+
// because the overlay migration builds one per state and either can be
236+
// blocked on its own. Present whatever the outcome: an index left out
237+
// would make "nothing blocks it" and "it was never probed" the same
238+
// absence, which is the rule this command already applies to `skipped`.
239+
runtimeIndexPreflight: PROBES.map((probe) => ({
240+
...probe,
241+
status: 'table-absent',
242+
groups: [],
243+
})),
215244
summary: {
216245
objectsScanned: 2,
217246
fieldsScanned: 3,
218247
duplicateValues: 3,
219248
duplicateRows: 6,
220249
liveConditions: 1,
250+
runtimeIndexesBlocked: 0,
251+
runtimeIndexBlockingRows: 0,
221252
},
222253
});
223254
});
224255

256+
it('names the four kernel:ready indexes, and the migration behind each', async () => {
257+
const report = await collect();
258+
expect(
259+
report.runtimeIndexPreflight.map((p) => `${p.migration}:${p.table}:${p.index}`),
260+
).toEqual([
261+
'ensureMetadataOverlayIndexes:sys_metadata:idx_sys_metadata_overlay_active',
262+
'ensureMetadataOverlayIndexes:sys_metadata:idx_sys_metadata_overlay_draft',
263+
'ensureViewDefinitionActiveIndex:sys_view_definition:idx_sys_view_def_active',
264+
'ensureSysSettingIdentityIndex:sys_setting:uniq_sys_setting_organization_id_namespace_key_scope_user_id',
265+
]);
266+
});
267+
225268
it('does NOT report a value repeated inside ONE partition — the narrow ruled definition', async () => {
226269
// `REF-1` is held twice, both times by `org_x`. That is a repeat the
227270
// partitioned unique index already refuses; reporting it would be the wider
@@ -248,6 +291,7 @@ describe('#8928 os migrate duplicates — the report document', () => {
248291
organizationField: ORGANIZATION_FIELD,
249292
sequencesTable: SEQUENCES_TABLE,
250293
client: 'better-sqlite3',
294+
runtimeIndexPreflight: [],
251295
});
252296
expect(report.skipped).toEqual([
253297
expect.objectContaining({

0 commit comments

Comments
 (0)