Repository navigation
Commit 759dbe9
feat(cli): os migrate unmapped-columns reads a retired field's columns, keyed by record id, for conversion before the destructive drop (#21643)
Fixes #21573
Clause-②: yes (widening)
`os migrate unmapped-columns --object NAME` reads the values of the
columns `os migrate plan` reports as `unmapped_column` for one object,
and emits them keyed by record id. It is the operator-only, read-only
route for converting a retired field's values. Since the read narrowing
(#21571) and the write narrowing (#21613), no runtime door serves those
columns. They stay in the table until `os migrate apply
--allow-destructive` drops them. The door runs under the operator's own
database credentials, through the SQL driver the plan's differ ran on.
No REST route, API flag, engine verb or driver changes.
## Measured first (at BASE `3222c57404`)
### A1. No existing door reads an unmapped column's values (hypothesis
confirmed)
- **The `os migrate` family.** 13 modules sit under
`packages/cli/src/commands/migrate/`.
- `plan` and `apply` name the column only (`kind`, `table`, `column`,
`actual`, `op`).
- `account-issuer` reads one fixed column of one platform table through
the driver. It reports collision groups, not values.
- Every other module reads declared fields.
- **Data-migration plans.** The `migration-plans` registry holds one
plan, `metadata.recorded-by-sentinel-to-null`. A plan's rows load
through `step.load(engine)`, which is the narrowed engine verbs.
- **Direct driver readers in the CLI** (`git grep` for
`getDriverForObject` and `driver.find` under `packages/cli/src`):
`account-issuer`, `secret orphans`, `secret rewrap` and the
secret-reference union. Each reads declared columns of fixed platform
tables.
- **`os data query` and `os data get`** read through REST.
So this is a new subcommand, and `Clause-②: yes (widening)` stands as
the claim declared it.
### A2. One column set (reused and pinned)
- **Same differ, same boot.** The door calls the same
`stack.driver.detectManagedDrift()` the plan calls, on the plan's own
boot: `deferSchemaDdl`, `readOnlyProbe` and `composeHostStack`. It keeps
the `unmapped_column` findings for the object's table
(`unmappedColumnsOf`). It writes no second diff.
- **The differ's exclusions hold unchanged.** These are the driver's
`id`, `created_at` and `updated_at`, and any column ending in the
hash-shadow suffix.
- **Pinned on SQLite.** The integration pin adds a hash-shadow column to
its fixture. It asserts the door's column list equals `os migrate plan
--json`'s `unmapped_column` findings for the table, and that the shadow
is in neither.
- **Measured by hand on PostgreSQL 16.** On a temporary server I started
and stopped, the door's list was identical to the plan's (seven columns,
same order, same `actual`).
### A3. Drivers with no drift report: the plan's answers, reused
| case | `os migrate plan` | this door |
| --- | --- | --- |
| no SQL driver | `{ error: 'no_sql_driver', changes: [] }`, exit 0 | `{
error: 'no_sql_driver', object, columns: [], records: [] }`, exit 0 |
| Turso remote | the read-only boot refuses (`setDeferredDdl(true)` is
refused on that face) | the same boot, so the same refusal
(`boot_failed`, exit 1) |
| an object outside the set the plan diffs (federated, bound to no
driver, or bound to another datasource) | reports nothing; its coverage
pass calls that UNMEASURED | refused, exit 1, in the coverage pass's own
words, not empty work |
### A4. The read carrier (confirmed on SQLite and PostgreSQL 16, with no
driver or engine change)
A throwaway probe created a table with six typed fields, re-registered
it with them retired, and added a blob column and a hash-shadow column
by hand.
- `detectManagedDrift()` reported the seven undeclared columns and
skipped the shadow.
- `driver.find(object, { fields: ['id', ...columns] })` returned exactly
`id` plus those columns, on `better-sqlite3` and on PostgreSQL 16.
- A projection naming a column the table lacks returns the whole row.
This is the driver's unknown-column recovery, and the row includes
declared fields and the shadow. So the door never passes a driver row
through. It picks the reported columns, and it refuses a row that is
missing one.
The read goes through the driver, never through the engine's verbs.
### A5. Values as stored
The driver's output pass decodes declared fields only, so an unmapped
column comes back exactly as the database client returns it. The door
adds no codec.
**Patch round (the seat's REWORK, answer B).** A value JSON cannot carry
as stored is refused, not emitted. That covers binary bytes (a `Buffer`
or any `ArrayBufferView`), a `bigint`, and a non-finite number (`NaN`,
`Infinity`, `-Infinity`). The refusal applies in both faces, with exit
1; it names the column and the record id, says to read that column with
the database's own client, and emits no record (`unrepresentableKind`).
Each of these would reach JSON as a stand-in: an object shaped `{ type,
data }`, a thrown serialisation, or a `null`. A conversion would write
that stand-in as the value. ⛔ No codec.
Measured first, before pinning. A throwaway probe created the column the
platform creates for each retired field type, re-registered the table
with the fields retired, and read it through the door's projection,
classified with the door's own predicate. **None of the platform-created
columns lands in the refused set, on either dialect** (refused count 0
of 24 values on each):
| retired field type | SQLite column | SQLite value | PostgreSQL 16
column | PostgreSQL value |
| --- | --- | --- | --- | --- |
| text | `text` | string | `text` | string |
| number | `float` | number | `numeric` | string at the column's scale |
| currency | `float` | number | `numeric` | string at the column's scale
|
| percent | `float` | number | `numeric` | string at the column's scale
|
| boolean | `boolean` | `1` / `0` | `boolean` | `true` / `false` |
| json (object and array) | `text` | the stored text | `json` | parsed
by the client |
| date | `date` | string | `date` | string |
| time | `time` | string | `time without time zone` | string |
| datetime | `datetime` | the stored text | `timestamp with time zone` |
a `Date`, emitted as its ISO 8601 text |
| a binary column added by hand | `blob` | Buffer: **refused** | `bytea`
| Buffer: **refused** |
The platform creates no binary column for any field type. The column
emitter has no binary arm, and only the SQLite table rebuild preserves a
binary column that already exists. So the refusal fires only on a column
added by hand. A `Date` passes: it serialises to unambiguous ISO 8601
text. The real CLI was run by hand on PostgreSQL over the probe table:
exit 0, 12 columns, the `Date` as ISO text. Over a `bytea` column it
answered exit 1, one document, naming the column and the record.
### A6. Family conventions
- **Flags.** `--database-url` (env `OS_DATABASE_URL`), `--max-records`
and `--json` use `account-issuer`'s declarations. Reaching
`--max-records` refuses. There is also a required `--object`, the
family's spelling for an object filter.
- **Read-only boot.** It is the plan's boot. The door is entered in
`schema-migrate.one-shot-family.integration.test.ts`: the database stays
byte-identical, no SQLite file is created, and no key material is
written.
- **No database yet.** The door asks `stack.tableAbsent` before the
differ, says on stderr that the table is not there yet, and answers
empty work. It is entered in the absent-database roster, with a control
row that holds a retired column.
- **Exit signal.** The catch rethrows `isExitSignal` as its first
statement. `test/exit-signal.pin.test.ts` discovers the command
structurally.
- **`--json`.** It prints one document. The door is entered in the
stdout purity family.
- **Unknown object name.** The family's existing `--object` filters do
not refuse an unknown name: `value-shapes`, `summary-nulls`,
`files-to-references` and `duplicates` filter it out. An empty answer
from this door is what an operator acts on before dropping columns. So a
name the deployment does not declare refuses with the platform's own
envelope (`objectNotFoundError`: `OBJECT_NOT_FOUND`), not with empty
work. What the sibling behaviour reaches is in the report's out-of-scope
findings.
### The name
The command is `unmapped-columns` because the plan's finding kind is
`unmapped_column`, so the finding names the command that reads it. This
follows the family's noun-phrase style (`multi-value-columns`,
`summary-nulls`, `value-shapes`). The finding's message says "orphaned",
but no flag or JSON key uses that word.
## Changes
- `packages/cli/src/commands/migrate/unmapped-columns.ts` (new):
- `unmappedColumnsOf`, a filter over the plan's findings;
- `readUnmappedColumnValues`, a keyset walk over `driver.find` (via the
shared `keysetWalk`), keyed by id, that refuses a partial read;
- `unrepresentableKind` (patch round), the predicate the read refuses a
value JSON cannot carry as stored by;
- the command, wrapped the way `plan` is
(`refuseWhenHostConfigUnloadable`, then `exitOneShotCommand`). Patch
round: the `--json` refusal path now hands `emitJson` exit 1, as the
family does. The exit status was already 1.
- `packages/cli/src/index.ts`: `MigrateUnmappedColumnsCommand` is
re-exported beside `MigrateAccountIssuerCommand`. oclif discovers
commands by file pattern, so `package.json` needs no change (measured:
`oclif.commands` is `pattern` over `./dist/commands/**`).
- Tests: `unmapped-columns.test.ts` (unit) and
`unmapped-columns.integration.test.ts` (the public door), plus one
roster entry each in:
- `data-commands.absent-database.integration.test.ts` (with its control
row);
- `schema-migrate.one-shot-family.integration.test.ts`;
- `test/json-stdout-purity.e2e.test.ts`.
- Docs:
- `content/docs/deployment/cli.mdx`: a table row, an example line, a
short subsection with the three-step route and the door's answers, and
"the callout below" corrected to "above" (the callout sits above its
sentence).
- `content/docs/data-modeling/queries.mdx`: the conversion sentence now
names the door as the route after retirement.
- `content/docs/upgrading.mdx` is untouched. It states no conversion
route (grep for conversion, retire, orphan, unmapped and
allow-destructive hits only protocol conversions and the apply line).
- `.changeset/21573-migrate-unmapped-columns.md`: `@objectstack/cli`
minor, `Clause-②: yes (widening)`.
The two landed changesets, `packages/objectql`, `packages/spec`, the
drivers' `src/` and every runtime door are untouched. The patch round
merged `origin/main` (`1cbe165bfc`, 5 commits, none on this diff's
paths) with a merge commit. There was no rebase and no force-push.
## Pins
- **Integration (the CLI spawned against SQLite).** Release one writes
three contacts while two text fields are declared. Release two retires
both. A hash-shadow column is added by hand.
- The door emits the two retired columns for all three records, keyed by
id. A record written with the fields empty is emitted with two NULLs.
- No declared field, built-in column or shadow appears in the output.
- The column list equals `os migrate plan --json`'s for the table.
- The human face lists the columns and the records.
- The database is byte-identical after the door ran (schema and every
row, read on a connection of the test's own).
- An object with none: empty work, exit 0, on both faces.
- An undeclared name: `OBJECT_NOT_FOUND`, exit 1, one document.
- `--max-records 2` over three rows: refused, exit 1, with no `records`
key.
- Patch round: a BLOB column added by hand to a third object, holding
three bytes, is refused in both faces with exit 1. The refusal names the
column and the record id, emits no record, and neither face carries the
Buffer stand-in.
- **Unit.**
- The filter keeps only `unmapped_column` findings of the named table,
in the differ's order. A type mismatch, an orphaned index over the same
column and another table's orphan are all left out.
- The read asks for `id` plus exactly the columns, keys records by id,
and seeks past one page. It decodes nothing: a JSON-looking string, a
`0`/`1`, a `Date`, a parsed json object and a string pass through as the
same instances, and the `Date` serialises to its ISO 8601 text.
- It refuses on the cap, on a row missing a column (the whole-row shape)
and on a non-array answer. A table holding exactly the cap is read
without refusing.
- Patch round: one refusal case per class, each with the bad value on
the second row and each naming the column and the record id. The cases
are a Buffer, another `ArrayBufferView`, a `bigint`, `NaN`, `Infinity`
and `-Infinity`. A control asserts the predicate answers `null` for
`null`, strings, finite numbers, booleans, a `Date`, an object and an
array.
- **Absent-database roster.** On a project with no database: empty work,
exit 0, on both faces, saying the table is not there yet. On the booted
control: the retired column and its value are read.
- **Runtime-door control.** #21571's and #21613's declared-field pins
ran unchanged and green (below). The engine's doors still never return
these columns.
## Reverse verification (implementation committed first, at
`d5b349d4de`)
- **The mutation.** `node scripts/ablation-replace.mjs` replaced the
column selection's predicate with a kind that never matches. The anchor
went from 1 hit to 0, the replacement from 0 to 1, and the blob changed
from `9d976e14` to `ddaa4be0`.
- **No rebuild was needed.** The spawned CLI loads this command from
`src/`: oclif runs in development mode, and `dist/` held no build of the
new file while the pins ran it.
- **Result: 6 red, 13 green.**
- Red: both column-selection unit cases, plus four integration cases
(the retired columns' emission, the one-column-set comparison with the
plan, the human face, and the cap refusal, which now reads nothing).
- Green: the no-retired-columns control (empty work),
`OBJECT_NOT_FOUND`, byte-identity, and the read walk's own unit cases.
- **Restore.** The tool restored with `git checkout HEAD -- PATH`. Blob
`9d976e14` equals the HEAD blob, `git diff HEAD` is empty, and `git
status --porcelain` is clean. The anchor counts 1 and the mutation
counts 0 on disk.
**Patch-round leg: the refusal predicate** (the refusal committed first,
at `242cbc64f2`).
- **A void first attempt.** It is recorded here because it never
measured anything. Its replacement text contained the anchor, so the
anchor count read 1 to 1 and `ablation-replace` rejected the leg before
the command started (exit 1, lock held 0 s). The file was restored: blob
`35c1a6c6` equals HEAD.
- **The mutation that ran.** The predicate's first check, `if
(ArrayBuffer.isView(value)) return 'binary bytes';`, became an
unconditional `return null`. The anchor went from 1 to 0, the
replacement from 0 to 1, and the blob changed from `35c1a6c6` to
`25e968b9`.
- **Result: 7 red, 20 green.**
- Red: the six per-class unit refusals and the public-door bytes
refusal.
- Green: the pass-through control (a `Date`, a parsed json object, a
string), the predicate's `null` control, every retired-column case,
empty work, `OBJECT_NOT_FOUND`, the cap refusal and byte-identity.
- **Restore.** Blob `35c1a6c6` equals HEAD, `git diff HEAD` is empty,
`git status --porcelain` is clean, and the anchor counts 1 and the
marker 0 on disk.
## Local verification (final head `242cbc64f2`, after merging
`origin/main` at `1cbe165bfc`)
- **`@objectstack/cli` unit project in full:** 256 files, 3765 passed.
That is the round-one 3758 plus the seven new unit cases.
- **`@objectstack/cli` typecheck:** exit 0, with `check:test-typecheck`
holding its existing ledger (3 files, none of them new).
- **The four `os migrate` integration pins, on real built packages** (a
repo build of the merged tree, VERDICT 0): 4 files, 134 passed, 1
skipped.
- the new pin: 9 passed;
- the absent-database roster: 39 passed;
- the one-shot family: 78 passed;
- the read-only preview: 8 passed, plus its live-PostgreSQL cell as a
named skip. That cell ran 12 of 12 against my temporary server in round
one; the server is stopped now.
- **Nightly tier:** `json-stdout-purity.e2e.test.ts` under
`OS_TEST_TIERS=nightly`, 50 passed.
- **Runtime-door control.** Unchanged since round one, and the patch
touches no runtime path: rest 14 passed, objectql 48 passed (round one).
- **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths) derived 95 commands at
`242cbc64f2`, the same 95 as round one. All 95 were run there and each
exited 0. `--ran` reconciles: 95 derived, 95 run, 0 NOT-MEASURED, 0
UNRUN.
- Round one's notes still hold: the three `PREREQUISITE NOT MET` gates
read packages outside the CLI closure and exit 0 after a repo build, and
the unit double holds the caller's bound for
`check:objectql-double-limit`.
- Added beyond the derived list: `check:cli-command-ids`, exit 0.
- **Full `pnpm lint`** (`eslint . --no-inline-config` over the whole
repo): exit 0 at `242cbc64f2`, with no error or warning printed.
- **After the final push,** `origin/main` gained two commits
(`0c50b5dfe7`, `15fe567c9c`: a CI workflow and spec citation
re-anchors). Neither touches this diff's paths, so they were not merged
again; CI runs on the merge ref.
## Acceptance notes
- **A composition edge.** If the composed boot's coverage pass sees the
driver refuse registration for the very object named, the plan reports
nothing for it and so does this door. The door's membership check (the
object is declared, not federated, and bound to the plan's driver)
cannot see that case without reading driver-private state. The plan's
own notes report such a refusal. Carrier: none.
- **Unscoped by design.** No tenant scope is passed, so the read covers
every organization's rows. The docs and the changeset say so.
- **The text face prints values.** It lists each record's values, as the
JSON face does. Both run only under the operator's credentials.
---
_Generated by [Claude
Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 1968d5e commit 759dbe9
10 files changed
Lines changed: 1112 additions & 2 deletions
File tree
- .changeset
- content/docs
- data-modeling
- deployment
- packages/cli
- src
- commands/migrate
- utils
- test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
290 | 290 | | |
291 | 291 | | |
292 | 292 | | |
293 | | - | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
294 | 298 | | |
295 | 299 | | |
296 | 300 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
519 | 519 | | |
520 | 520 | | |
521 | 521 | | |
522 | | - | |
| 522 | + | |
523 | 523 | | |
524 | 524 | | |
525 | 525 | | |
| |||
853 | 853 | | |
854 | 854 | | |
855 | 855 | | |
| 856 | + | |
856 | 857 | | |
857 | 858 | | |
858 | 859 | | |
| |||
861 | 862 | | |
862 | 863 | | |
863 | 864 | | |
| 865 | + | |
864 | 866 | | |
865 | 867 | | |
866 | 868 | | |
| |||
1048 | 1050 | | |
1049 | 1051 | | |
1050 | 1052 | | |
| 1053 | + | |
| 1054 | + | |
| 1055 | + | |
| 1056 | + | |
| 1057 | + | |
| 1058 | + | |
| 1059 | + | |
| 1060 | + | |
| 1061 | + | |
| 1062 | + | |
| 1063 | + | |
| 1064 | + | |
| 1065 | + | |
| 1066 | + | |
| 1067 | + | |
| 1068 | + | |
| 1069 | + | |
| 1070 | + | |
| 1071 | + | |
| 1072 | + | |
| 1073 | + | |
| 1074 | + | |
| 1075 | + | |
| 1076 | + | |
| 1077 | + | |
| 1078 | + | |
| 1079 | + | |
| 1080 | + | |
| 1081 | + | |
| 1082 | + | |
| 1083 | + | |
| 1084 | + | |
| 1085 | + | |
| 1086 | + | |
| 1087 | + | |
| 1088 | + | |
| 1089 | + | |
| 1090 | + | |
| 1091 | + | |
| 1092 | + | |
| 1093 | + | |
| 1094 | + | |
| 1095 | + | |
| 1096 | + | |
| 1097 | + | |
| 1098 | + | |
| 1099 | + | |
| 1100 | + | |
1051 | 1101 | | |
1052 | 1102 | | |
1053 | 1103 | | |
| |||
Lines changed: 20 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
58 | 58 | | |
59 | 59 | | |
60 | 60 | | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
61 | 65 | | |
62 | 66 | | |
63 | 67 | | |
| |||
418 | 422 | | |
419 | 423 | | |
420 | 424 | | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
421 | 428 | | |
422 | 429 | | |
423 | 430 | | |
| |||
513 | 520 | | |
514 | 521 | | |
515 | 522 | | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
516 | 536 | | |
517 | 537 | | |
518 | 538 | | |
| |||
0 commit comments