Skip to content

Commit 759dbe9

Browse files
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
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
feat(cli): `os migrate unmapped-columns --object NAME` reads the values of a retired field's columns, keyed by record id, for a conversion before `os migrate apply --allow-destructive` drops them (#21573)
6+
7+
Clause-②: yes (widening)
8+
9+
- **What it reads.** The columns `os migrate plan` reports as `unmapped_column` for one object's table: a column that is still in the table and that no metadata declares, typically one a retired field left behind. The column set is the plan's own findings, from the same differ on the same read-only boot, so the command never reads a column the plan does not report. Each record is emitted as `{ id, values }`. `--json` prints one document, `{ database, object, table, columns, count, records, duration }`. The text face lists the columns and each record's values.
10+
- **Why it exists.** A read or a write through the engine now serves an object's declared fields only, and naming an undeclared column is refused. An app that moves a retired field's values into the field that replaced it reads them once with this command, writes them with its own script, and then drops the columns with `os migrate apply --allow-destructive`. That is the route the read and write narrowing in `@objectstack/objectql` names for this case.
11+
- **Operator-only and read-only.** It runs under the database credentials you pass (`--database-url`, else `OS_DATABASE_URL`, else the project database), and it reads every organization's rows. No REST route, API flag or per-request option serves these values, and the runtime doors are unchanged. It boots the way `os migrate plan` does: no schema DDL, no seed data, and no database file created.
12+
- **Values as stored.** An unmapped column has no declared type, so each value is emitted as the database client returns it, with no field-type decoding; a PostgreSQL `timestamp` arrives as a date and is emitted as its ISO 8601 text. A value JSON cannot carry as stored (binary bytes, a `bigint`, or a non-finite number) is refused in both faces with exit 1, naming the column and the record id, and no record is emitted: read that column with the database's own client. No column the platform creates for a field type answers with one of these, on SQLite or on PostgreSQL.
13+
- **Answers.** An object with no unmapped column, or with no table yet: empty work, exit 0. No SQL driver: `os migrate plan`'s own `no_sql_driver` answer, exit 0. An undeclared object name: `OBJECT_NOT_FOUND`, exit 1. An object the plan does not diff (federated, or bound to another datasource): refused, exit 1. A read that cannot be complete, such as one stopped by `--max-records`: refused, exit 1, and no partial set is emitted. A value JSON cannot carry as stored: refused, exit 1, as above.
14+
- `MigrateUnmappedColumnsCommand` is exported from `@objectstack/cli` beside the other `os migrate` commands.
15+
16+
Nothing that ran before changes. This is a new command.

‎content/docs/data-modeling/queries.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,11 @@ system columns (`id`, `created_at`, `updated_at`, and the tenant, owner and audi
290290
the registry adds). A column no metadata declares is never returned — for example one a
291291
retired field left in the table until `os migrate apply --allow-destructive` drops it — and
292292
naming it in `fields` on the data API is refused with `400 INVALID_FIELD`. To read such a column's values
293-
for a one-time conversion, run the conversion before the field is retired.
293+
for a one-time conversion, run the conversion before the field is retired, or, once it is
294+
retired, read them with the operator-only
295+
[`os migrate unmapped-columns`](/docs/deployment/cli#os-migrate-unmapped-columns) and convert
296+
them before `os migrate apply --allow-destructive` drops the column. No runtime door serves
297+
them.
294298

295299
### Nested / Related Fields
296300

‎content/docs/deployment/cli.mdx‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,7 @@ as the project's **default** datasource it is refused at boot the same way.
519519
**What it boots:**
520520
- Reads the artifact's `manifest`, `objects`, `views`, `flows`, …
521521
- Auto-registers the platform services declared in `requires: [...]` (e.g. `ai`, `automation`, `analytics`, `auth`, `ui`). Declaring a **service** capability (`automation`, `analytics`, `ai`, `audit`, …) is a *requirement*: if its provider package isn't installed, boot **fails fast** with a clear error instead of silently starting without a capability you asked for. (`auth` and `ui` are tier-gated with their own opt-in rules — `auth`'s secret-gated skip is described below.)
522-
- Auto-detects the driver from the database URL scheme (`libsql://`/`https://*.turso.*` → Turso — via the optional `@objectstack/driver-turso` package, and a loud failure with the install command when it is missing rather than a fallback to sqlite —, `postgres[ql]://`/`pg://` → pg, `mongodb[+srv]://` → MongoDB, otherwise sqlite; `:memory:` is SQLite's own in-memory database). `memory://` and `mingo://` are refused — see the callout below.
522+
- Auto-detects the driver from the database URL scheme (`libsql://`/`https://*.turso.*` → Turso — via the optional `@objectstack/driver-turso` package, and a loud failure with the install command when it is missing rather than a fallback to sqlite —, `postgres[ql]://`/`pg://` → pg, `mongodb[+srv]://` → MongoDB, otherwise sqlite; `:memory:` is SQLite's own in-memory database). `memory://` and `mingo://` are refused — see the callout above.
523523
- Runs standalone boot mode with one active environment.
524524
525525
**Authentication:**
@@ -853,6 +853,7 @@ diverges from the live schema, and the physical column wins at write time.
853853
| `os migrate plan` | Dry-run: show how the database has drifted from metadata, categorised safe / needs-confirm / destructive (no changes applied) |
854854
| `os migrate apply` | Reconcile the database to metadata. Applies loosening changes; destructive ones require `--allow-destructive` |
855855
| `os migrate multi-value-columns` | Migrate a stale `varchar`/`text` column to `json` where the field declares `multiple: true` — one of three drift ops `apply` never reconciles for you. Dry run by default; `--apply` runs the statement the finding prints |
856+
| `os migrate unmapped-columns` | Read the values of the columns `plan` reports as `unmapped_column` for one object, keyed by record id — the conversion route for a retired field's values before `apply --allow-destructive` drops its columns. Read-only, and operator-only: no runtime door serves these values |
856857
857858
```bash
858859
os migrate plan # Preview drift (no changes)
@@ -861,6 +862,7 @@ os migrate apply --yes # Skip the prompt (CI / scripts)
861862
os migrate apply --allow-destructive --yes # Also drop orphaned columns, tighten NOT NULL, narrow types
862863
os migrate apply --force # Migrate even though another process is using the database
863864
os migrate plan --json # Machine-readable output
865+
os migrate unmapped-columns --object contact --json # A retired field's stored values, keyed by record id (read-only)
864866
```
865867
866868
#### Nothing is written before you confirm
@@ -1048,6 +1050,54 @@ hook or an integration already copied into some *other* single-value column is
10481050
not something it looks for, and it is deliberately not something it will grow
10491051
into: that repair is specific to what your automations did with the value.
10501052
1053+
#### `os migrate unmapped-columns`
1054+
1055+
Retiring a field leaves its column in the table: the additive sync never drops
1056+
one, and `os migrate plan` reports it as `unmapped_column` until
1057+
`os migrate apply --allow-destructive` does. In between, the values are still
1058+
stored, and no runtime door serves them: a read or a write through the data API
1059+
returns the object's declared fields only, and naming the column in `fields` is
1060+
refused. When the values have to move into the field that replaced it, this is
1061+
the read:
1062+
1063+
```bash
1064+
os migrate unmapped-columns --object contact # The columns, and every record's values
1065+
os migrate unmapped-columns --object contact --json > out.json # The same, for a conversion script
1066+
os migrate unmapped-columns --object contact --max-records 1000000 --json
1067+
os migrate unmapped-columns --object contact --database-url postgres://…
1068+
```
1069+
1070+
The conversion route is three steps: read the values with this command, write
1071+
them into the declared fields with your own script, then run
1072+
`os migrate apply --allow-destructive` to drop the columns.
1073+
1074+
- **One column set.** It reads exactly the columns `os migrate plan` reports as
1075+
`unmapped_column` for that object's table: the same differ, the same boot.
1076+
Columns the differ never reports are never read either, such as the driver's
1077+
own `id`, `created_at` and `updated_at`.
1078+
- **Operator-only and read-only.** It runs under the database credentials you
1079+
pass, and covers every organization's rows. There is no REST route or API
1080+
flag behind it. It boots the way `plan` does, so it writes nothing, and it
1081+
drops nothing.
1082+
- **Values as stored.** An unmapped column has no declared type, so each value is
1083+
emitted as the database client returns it, with no field-type decoding: a
1084+
retired `json` field on SQLite reads as its stored text, a retired `boolean`
1085+
as `0` or `1`, and a retired `datetime` on PostgreSQL arrives as a date and is
1086+
emitted as its ISO 8601 text. A value JSON cannot carry as stored (binary
1087+
bytes, a `bigint`, or a non-finite number) is refused with exit 1, naming the
1088+
column and the record id, and no record is emitted; read that column with
1089+
the database's own client.
1090+
- **Empty work, exit 0**, for an object with no unmapped column, or with no table
1091+
in this database yet. `--json` prints one document:
1092+
`{ object, table, columns, count, records: [{ id, values }] }`.
1093+
- **Refused, exit 1**: an object name the deployment does not declare
1094+
(`OBJECT_NOT_FOUND`); an object `plan` does not diff (federated, or bound to
1095+
another datasource), whose empty answer would be unmeasured; a read that
1096+
cannot be complete, such as one stopped by `--max-records`; and a value JSON
1097+
cannot carry as stored, described above. A partial set is
1098+
never emitted, because a conversion over part of a table, followed by the
1099+
drop, loses the rest.
1100+
10511101
#### Data migrations
10521102
10531103
The commands above reconcile **schema**. A *data* migration rewrites rows, and

‎packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@
5858
* (`--json` and human), a booted database holding one row of work for each
5959
* (the control: the door READS the table and reports the row), and the four
6060
* doors that already exited 0 on the absent database, which still do.
61+
*
62+
* [#21573] `os migrate unmapped-columns` joined the roster later, born with
63+
* the same answer: it asks `tableAbsent` before the differ. Its control row is
64+
* a retired field's column on `os21529_contact`, holding a value.
6165
*/
6266

6367
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -418,6 +422,9 @@ await k('sys_secret').insert({
418422
await k('sys_file').insert({
419423
id: 'file_21552', key: 'attachments/os21552.txt', name: 'os21552.txt', size: 12, scope: 'attachments', status: 'committed',
420424
});
425+
// A retired field's column: in the table, in no metadata, holding a value.
426+
await k.raw('ALTER TABLE os21529_contact ADD COLUMN legacy_note text');
427+
await k('os21529_contact').insert({ id: 'con_21573', name: 'Ann', legacy_note: 'kept-21573' });
421428
await raw.disconnect();
422429
process.stderr.write('[fixture] seeded\\n');
423430
process.exit(0);
@@ -513,6 +520,19 @@ const DOORS: readonly Door[] = [
513520
tables: ['sys_file', 'sys_attachment'],
514521
work: (doc) => expect(doc).toMatchObject({ filesScanned: 1, stranded: 1 }),
515522
},
523+
{
524+
// Born after #21552 with the family's answer: it asks `tableAbsent` before
525+
// the differ, so an absent table is empty work and is never read.
526+
name: 'migrate unmapped-columns',
527+
argv: ['migrate', 'unmapped-columns', '--object', 'os21529_contact'],
528+
empty: (doc) => expect(doc).toMatchObject({ object: 'os21529_contact', columns: [], count: 0, records: [] }),
529+
humanEmpty: /No unmapped column on os21529_contact/,
530+
tables: ['os21529_contact'],
531+
work: (doc) => {
532+
expect(doc.columns).toEqual([{ column: 'legacy_note', actual: 'text' }]);
533+
expect(doc.records).toContainEqual({ id: 'con_21573', values: { legacy_note: 'kept-21573' } });
534+
},
535+
},
516536
];
517537

518538
/** The four doors that already answered the absent database with exit 0: they must still. */

0 commit comments

Comments
 (0)