Skip to content

Commit fe71032

Browse files
os-muskclaude
andauthored
feat(driver-sql,objectql,cli)!: the ADR-0104 file-family column step and its kernel→driver supply (#15989) (#17859)
Fixes #15989 Clause-②: yes Step 2 of the maintainer ruling on #15041, completed. PR #17403 landed the encoding half; this lands the two things it named as still owed — **the kernel→driver supply seam** and **the column step** — under the director ruling `5643444495` (decision batch #120 item 1). ## ⛔ The ruling supersedes the card body in two places, and both are honoured **1 · Keying.** The card body's "Shape of the change" item 1 says the wiring is keyed on the `adr-0104-file-references` flag. That is **overturned**. The arm is keyed on `sys_migration.columns_moved_at`, and it additionally requires the flag to be verified — never the flag alone. The reason is measured, not formal: every creation-attested store since 17.0 holds that flag **and** JSON-quoted ids in a JSON column, so flag-keying would read every existing deployment as migrated and then write bare ids into a JSON column. `adr0104-file-columns-moved-supply.test.ts` pins exactly that row — verified, unstamped — as "not moved". **2 · The migration SQL.** The addendum's `USING (col #>> '{}')` is **not used bare**. It is preceded by the pre-check the dev seat measured, which aborts on a non-JSON-string cell. See the ablation below: the superseded form was executed on live PostgreSQL 16.13 in this PR's own suite, and it does not abort. ⭐ Worth carrying forward, in the card's own words: *"an Execution paragraph is authoritative about intent, not immune to being wrong about SQL."* ## The ablation — the single most important measurement here Both clauses, executed on one fixture (one converted cell, one inline blob the backfill has not reached), in `sql-driver-15989-file-column-move.test.ts` §1, on every dialect this step serves. | clause | what happened | |:--|:--| | **superseded** — `ALTER … TYPE varchar(2048) USING (col #>> '{}')`, alone | **ACCEPTED**, exit 0, no warning. The column is no longer `json`, so the unconverted object is now a plain string in a column whose declared contents are bare ids. | | **this PR** — the same DDL behind `json_typeof(col) IS DISTINCT FROM 'string'` | pre-check answers **1** ⇒ the step runs **no statement**. Cells byte-identical, column still `json`. | | **CONTROL** — the same pre-check on a fixture with no unconverted row | answers **0** ⇒ the move proceeds, producing bare ids, `NULL` preserved. So the `1` above is a reading, not a pre-check that always blocks. | ⚠️ **What is lost differs by dialect, and the PR states the two separately rather than averaging them.** On PostgreSQL the `json` type stores its input text verbatim and `#>> '{}'` hands that same text back, so the **bytes survive and the TYPE is destroyed** — which is exactly why the defect is invisible to a byte diff. On SQLite there is no type to lose, so the loss is in the **bytes**: `json_extract` re-serialises the blob and its formatting is gone. Either way the superseded form wrote to a row it was required to refuse. Reproduced outside the suite first, against a cluster started for the purpose (PostgreSQL 16.13, port 54331, torn down afterwards); the readings are in the report. ## The seam — "every way of not knowing answers not moved" ⚠️ **Why the wiring is owed: ruling item 2, not a zero-hit reading.** `fileColumnsMoved` had no supplier outside `driver-sql` on the dispatch base — re-measured here with a same-subject control, asking the identical question of each of `SqlDriverConfig`'s own added keys over the same corpus and package boundary: `schemaMode` 72 files · `autoMigrate` 14 · `sqliteAbsentFile` 6 (the firing control, and it includes a real host wiring site, `service-datasource/src/default-datasource-driver-factory.ts`) · `sqliteJournalMode` **0** · `fileColumnsMoved` **0**. ⛔ So the zero proves only that there was no supplier — `sqliteJournalMode` reads zero too and is a perfectly ordinary optional key no host sets. What makes this one owed is the director ruling's item 2, which places the kernel→driver wiring inside this dispatch rather than after it. `ObjectQL.registerDriver` hands every driver that has the seam a closure over the new `ObjectQL.haveFileColumnsMoved()`. Pinned on both sides — thirteen engine-side cases and twelve driver-side ones: - option omitted · resolver throws · resolver rejects · resolver answers a non-`true` value · resolver never runs (the host never calls `initObjects`) · driver has no seam at all; - no `sys_migration` object · no row · unreadable table · null stamp · empty-string stamp · stamp on an unverified row · stamp on a row with blocking findings; - ⭐ and the **control** in both files: a verified row **with** a stamp answers `true`, so every `false` above is a reading rather than a welded-shut arm. ⛔ **A host that names `fileColumnsMoved` in its own config wins, in either polarity.** The engine only fills an empty slot. Overruling a declared `false` is the bare-ids-into-a-JSON-column failure this whole mechanism exists to prevent; overruling a declared `true` writes JSON into columns already retyped. The host is the more specific authority about its own storage, so the engine never contradicts it. A resolver arriving **after** the first `initObjects` is also refused: `registerObjectMetadata` has already frozen `isJsonField`'s answer for every media column, so a late resolver would be a promise the driver cannot keep. ## The column step `os migrate files-to-references --apply` gains it, and it moves nothing until three gates pass: the migration's own gate (zero blocking rows), **every** pre-check across **every** planned column, and no refusals. Two-phase on purpose — a step that moved three columns and aborted on the fourth leaves a datastore in a state no flag can describe. The shape is read off the column's **physical type**, never off the dialect: a `json` column is retyped; a column already `varchar` — the population `os generate migration --format sql` creates and a JSON-arm driver fills with quoted ids (#15771) — has its values unquoted in place. Classifying by dialect would have left that population full of quoted ids behind a `columns_moved_at` stamp claiming it was converted. ⛔ **MySQL is out of scope and refused by name**, per ruling item 3 — #17788 owns it, on a real instance, because the addendum leaves its statement ORDER unsettled. `mediaColumnMoveDialect('mysql')` answers `null` and the step reports a named refusal. §5 pins that refusal so nobody "completes the matrix" by transcribing a form nobody has run — which is the move that produced the Postgres clause this card had to overturn. ## Per-dialect pin results `sql-driver-15989-file-column-move.test.ts`, both arms, both encodings, across the window: - **SQLite** — 7 passed, 1 skipped (the unprovisioned PG cell, named). - **live PostgreSQL 16.13** — 13 passed, 0 skipped, with `OS_TEST_POSTGRES_URL` provisioned. - ⛔ **MySQL — NOT MEASURED, and not a cell.** No `mysqld`/`mariadb` binary and no docker daemon in this container; more to the point the step has no MySQL statements to measure. This is a named absence, never a pass and never a fail. §3 is the reverse verification the card asks for: after the move, a driver on the **moved** arm writes a bare id and reads it back unchanged, and reads the row the move itself converted; §3b holds the other half of the window, where an **unmoved** deployment still stores and reads its JSON-quoted id. ⚠️ One fixture correction worth naming: the SQLite cell is `:memory:`, so two driver instances are two **separate** databases — a §3 written against it would have been green while measuring nothing (the second driver creates its own table, its own writes read back perfectly, and the migrated rows are simply absent). The suite uses a file-backed SQLite database so the two arms share bytes, as the live cells already do. ## Changeset `.changeset/15989-file-family-column-step.md` — `minor` on the five packages that publish a change, with the launch-window `**BREAKING**` banner, and the ADR-0087 disposition **not-required (no-migration-prescription)** derived from this diff: nothing authorable moves. `DataMigrationFlagSchema` and its `columns_moved_at` member landed under #16185 and are read here, not edited; the one `packages/spec` edit is a new exported predicate function over that existing type. So `os migrate meta` has nothing to visit, `spec-changes.json` nothing to project, the upgrade guide no row to gain. The other four categories are closed on facts in the changeset itself. ## Verification Every exit code captured by redirect-then-`$?`, never across a pipe. **Suites — each package's own `test` target, unnarrowed. ⛔ No `--project` filter anywhere** (a `--project` filter silently drops a named file outside that project and reports it as passing — #17853). | package | tiers | files | tests | |:--|:--|--:|--:| | `@objectstack/driver-sql` | single | 182 passed, 3 skipped (185) | 3213 passed, 81 skipped (3294) | | `@objectstack/cli` | **unit + integration, both** | 246 passed (246) | 3247 passed (3247) | | `@objectstack/objectql` | single | 298 passed (298) | 4990 passed (4990) | | `@objectstack/spec` | single | 472 passed (472) | 13434 passed (13434) | | `@objectstack/platform-objects` | single | 40 passed (40) | 571 passed (571) | `driver-sql` ran against **live PostgreSQL 16.13** with the server at `Asia/Shanghai` and the process at `America/New_York`, matching how `ci.yml` provisions the live matrix — the live suites assert a three-way zone skew and are vacuous on a UTC server, which is a guard, not an obstacle. ⛔ **MySQL: NOT MEASURED.** No `mysqld`/`mariadb` binary and no docker daemon in this container — and the column step has no MySQL statements to measure in the first place (#17788). A named absence: never a pass, never a fail. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived **103** families for this diff; all 103 were run and reconciled with `--ran`, each carrying its real exit code, so the NOT-MEASURED zero is derived rather than claimed. ⛔ Four hit `exit 3` (PREREQUISITE NOT MET — never a pass) and went green after a full `pnpm build`: `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`. **Four gates went genuinely RED on my own first draft, and are recorded rather than smoothed over:** - `check:doc-authoring` — a tracker id in two runtime strings an operator reads. Fixed by removing the id from the driver's refusal text and by writing the engine's log literal once instead of duplicating it. - `check:engine-double-contract` / `check:objectql-double-limit` / `check:where-matcher` — all three on my new fakes. They now route `update()` through `assertEngineUpdateDispatch`, apply the caller's `limit` by presence after the filter, and REFUSE a combinator they do not implement instead of reading it as a field name. The last one matters here more than usual: a matcher that answers "no rows" for a filter it does not understand is indistinguishable from an empty ledger, which is exactly the false "not moved" every case in those files is trying to tell apart from a real reading. **Cross-package reverse verification** (an additive optional member on a type crossing a package boundary is exactly the shape a cached `.d.ts` answers the same way twice, so both legs were run): planting `MediaColumnMoveScan['plansZZ']` in `@objectstack/cli` goes RED with `TS2339: Property 'plansZZ' does not exist on type 'MediaColumnMoveScan'` (leg 2 exit 1), while the real member typechecks (leg 1 exit 0) — so `tsc` is reading `driver-sql`'s rebuilt declaration, not a cached one. Restored under a `trap … EXIT INT TERM` with repo-absolute paths via `git checkout HEAD -- path`, and the restore proven by state rather than by an exit code: `git hash-object` back to `88f1ad9944` byte-identical to the HEAD blob, `git diff HEAD` empty. All gate figures above are read at the final commit `303d7946e4`; the reconciliation and the suites were re-run on the head that produced them rather than on the tree that first did. The last commit is a one-line correction inside the ADR-0087 marker — the seat's review measured my rationale's counts as understated, I re-measured them independently off the three-dot diff and got the same answer, and the marker now NAMES what it counts so a reader can recount it: **13** new declarations reaching a package entry (11 on `@objectstack/driver-sql`'s entry — 6 values and 5 types — plus `recordFileColumnMove` on `@objectstack/platform-objects/system` and `hasMovedFileColumns` on `@objectstack/spec`) and **3** new public methods on exported classes (`SqlDriver.planMediaColumnMove`, `SqlDriver.setFileColumnsMovedResolver`, `ObjectQL.haveFileColumnsMoved` — none private or protected, on classes exported at `sql-driver.ts:4514` and `engine.ts:2768`). The level, the BREAKING banner and the disposition itself are unchanged; the clause those counts support is simply more true at 13/3 than at the 5/2 I first wrote. Control-byte self-scan over all changed files: zero hits, with a control that fires on an in-class byte (0x0B). `pnpm check:nul-bytes` exit 0. ## Acceptance notes - **filed as #17869:** a non-`Command` module under `packages/cli/src/commands/` makes EVERY `os` invocation warn `findCommand … not found` on stderr, because the oclif command table is a `**/*.js` glob — and it corrupts machine-readable output for any consumer that reads stdout and stderr together (`os validate --json` then fails `JSON.parse`). Found because my own first draft put the column-step helper there; that instance is fixed in this PR by moving it to `src/utils/`, where every other CLI helper already lives. The missing GUARD is the card: `check:cli-command-ids` walks the same population but asks the opposite question, and the trap was caught by exactly one of 3247 CLI cases. Dedup ran before filing, with a firing control. - `noted, not filed:` the docblock on `SqlDriverConfig.fileColumnsMoved` pointed at `SqlDriver.setFileColumnsMoved`, a method that did not exist — a dangling link left when the option landed without its supply. Repaired in place by this PR, since the method the link wanted is the one this PR adds. Successor: none needed, it is fixed here. - `noted, not filed:` `readDataMigrationFlag` dropped `columns_moved_at` on the way out, so a moved deployment was indistinguishable from an unmoved one to every caller reading the ledger through it. Fixed here because the column step is the first caller that has to tell them apart. Successor: none needed. - `noted, not filed:` the TS and SQL halves of the generator still disagree on this family's width — `generateMigrationSql` emits `VARCHAR(2048)` while `generateMigrationTs` emits knex's `varchar(255)`. Carried from the #17403 round, which named the column-step seat as its successor. This PR retypes to 2048, the SQL half's width and the one the ruling calls the end-state, so the disagreement is now between the generator's two halves alone. Successor: a `packages/cli/src/commands/generate.ts` card; the file is untouched here. - `noted, not filed:` `os migrate plan` prose for a media column still describes the JSON end-state, generated from the drift entry's message. Correct for an unmoved deployment and wrong once one can be moved — which this PR makes possible for the first time. Successor: the drift-message half of #16184, which rewrites that arm. Authored by the `domain:engine` execution seat, session `https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM`. (Carried in prose: on this repository the PR-body UPDATE channel does not recognise the session-URL footer block and appends a second, bare one — measured on the create/update pair for this very PR, so the platform's own appended footer below is left as the single one.) --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e7fea46 commit fe71032

20 files changed

Lines changed: 2697 additions & 15 deletions
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@objectstack/driver-sql": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/platform-objects": minor
5+
"@objectstack/spec": minor
6+
"@objectstack/cli": minor
7+
---
8+
9+
feat(driver-sql,objectql,cli)!: the ADR-0104 file-family column step, and the kernel→driver supply that arms it (#15989)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves. No `packages/spec` key, no Zod schema, no authored metadata property, no object definition and no accepted request shape changes its spelling, type or legality in this diff: `DataMigrationFlagSchema` and its `columns_moved_at` member landed under #16185 and are READ here, not edited, and the one `packages/spec` edit is a new exported PREDICATE function over that existing type. So `objectstack migrate meta` has nothing to visit, `spec-changes.json` has nothing to project and the upgrade guide has no row to gain — the ledger's whole subject is authored metadata, and what moves here is a physical column's type plus the encoding of the values inside it, on a deployment whose operator ran a command to move them. ADR-0104's row-data side already has its own declared, operator-run surface (`os migrate files-to-references`), which is not a metadata upgrade. The other four categories are closed on facts: every package here publishes to npm, declares no `private` and ships `dist` in `files[]` (not `unpublished`); no ADR-0087 id is minted in this diff (not `registered`) and none pre-dates the base that would cover it (not `already-registered`); and exported declarations DO change — 13 new declarations reaching a package entry (11 on `@objectstack/driver-sql`'s: the 6 values MEDIA_COLUMN_MOVE_DIALECTS, MEDIA_COLUMN_MOVE_ROLLBACK_NOTES, MEDIA_ID_MOVE_WIDTH, isJsonColumnType, mediaColumnMoveDialect, mediaColumnMovePlan and the 5 types MediaColumnMoveDialect, MediaColumnMoveKind, MediaColumnMovePlan, MediaColumnMoveRefusal, MediaColumnMoveScan; plus recordFileColumnMove on `@objectstack/platform-objects/system` and hasMovedFileColumns on `@objectstack/spec`) and 3 new public methods on exported classes (SqlDriver.planMediaColumnMove, SqlDriver.setFileColumnsMovedResolver, ObjectQL.haveFileColumnsMoved) — so neither `runtime-interface-only` nor `type-surface-only` applies. The `**BREAKING**` banner below is carried rather than dropped, because published storage behaviour of `@objectstack/driver-sql` changes. -->
12+
13+
**BREAKING** on the published storage behaviour of `@objectstack/driver-sql`. A deployment that runs `os migrate files-to-references --apply` now has its media columns **retyped and their values rewritten** into the bare-`sys_file`-id encoding, and its driver writes bare ids from the next boot. This completes the maintainer ruling on #15041 (「15041 应该改为实际 id 保存。选A,其他同意」) whose encoding half shipped in the previous release.
14+
15+
Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level.
16+
17+
## The column step
18+
19+
`os migrate files-to-references --apply` gains a further step, run **only after** the backfill and its self-check report zero blocking rows — and it moves nothing at all until three gates pass:
20+
21+
1. the migration's own gate (zero blocking rows);
22+
2. **every** abort pre-check, across **every** planned column, before a single statement runs;
23+
3. no refusals — a column the driver could not plan stops the columns it could.
24+
25+
**PostgreSQL** and **SQLite** only. ⛔ MySQL is refused by name and belongs to #17788, where its statement ORDER is settled against a real instance rather than transcribed.
26+
27+
Per column, the shape is read off the column's **physical type**, not off the dialect: a `json` column is retyped (`ALTER … TYPE varchar(2048) USING (col #>> '{}')`), while a column that is already `varchar` — the population `os generate migration --format sql` creates and a JSON-arm driver fills with quoted ids — has its values unquoted in place. SQLite has only the second shape, since it has no json type.
28+
29+
### ⛔ The abort clause is NOT the one the ADR sketched
30+
31+
The #15041 addendum prescribed the retype with nothing in front of it while *requiring* the step to abort "on the first cell that is not a JSON string". Those two sentences contradict each other, and which was wrong was settled by running it. Measured on live PostgreSQL 16.13, `USING (col #>> '{}')` is **accepted** over a row holding an inline metadata blob, because `#>> '{}'` extracts *any* json type as text: the bytes survive, but the column is no longer `json`, so an object becomes a plain string in a column whose declared contents are ids — silently, in a migration that reports success. The director ruling (decision batch #120 item 1) replaced the clause with the pre-check that implements the requirement: `json_typeof(col) IS DISTINCT FROM 'string'` on PostgreSQL, and `json_valid(col) AND json_type(col) <> 'text'` on SQLite, where excluding invalid JSON is what keeps a re-run idempotent over cells a previous run already moved.
32+
33+
Both the destructive form and the guarded one are executed side by side, on one fixture, in this release's own test suite — so the difference stays a measurement rather than a comment.
34+
35+
## The kernel→driver supply seam
36+
37+
`SqlDriverConfig.fileColumnsMoved` shipped last release and no host outside the driver supplied it. It is supplied now: `ObjectQL.registerDriver` hands every driver that has the seam a closure over the new `ObjectQL.haveFileColumnsMoved()`, which reads `sys_migration.columns_moved_at` — and requires the `adr-0104-file-references` flag to be verified **as well**, since the stamp alone would attest a column move with nothing attesting the values inside it.
38+
39+
⭐ **Every way of not knowing still answers "not moved".** The option omitted, a resolver that throws or rejects or answers a non-`true` value, a resolver that never runs because the host never calls `initObjects`, a driver with no such seam, no `sys_migration` object, no row, an unreadable table, a null or empty stamp — all the JSON arm. That is the encoding every deployment in the world is on, and a driver that guessed the other way would write bare ids into a JSON column.
40+
41+
⛔ **A host that names `fileColumnsMoved` in its own config wins**, in either polarity. The engine only ever fills an empty slot, and never contradicts an explicit composition: overruling a declared `false` is precisely the bare-ids-into-a-JSON-column failure this mechanism exists to prevent.
42+
43+
## New published surface
44+
45+
- `@objectstack/spec` — `hasMovedFileColumns(flag)`, the single arbiter of the conjunction above, beside `isDataMigrationFlagVerified` and `authorisesIrreversibleAction`.
46+
- `@objectstack/objectql` — `ObjectQL.haveFileColumnsMoved()`, sharing one memoized read (and one `invalidateDataMigrationFlags()`) with `isFileReferencesMigrationVerified()`, so the two answers can never come out of one another's date.
47+
- `@objectstack/platform-objects` — `recordFileColumnMove(engine, migrationId)`, which refuses to stamp a deployment with no verified flag row. `readDataMigrationFlag` now carries `columns_moved_at`; it previously dropped it, which made a moved deployment indistinguishable from an unmoved one to every caller.
48+
- `@objectstack/driver-sql` — `SqlDriver.setFileColumnsMovedResolver()`, `SqlDriver.planMediaColumnMove()`, and the statement builders `mediaColumnMovePlan` / `mediaColumnMoveDialect` / `isJsonColumnType` with `MEDIA_COLUMN_MOVE_DIALECTS`, `MEDIA_COLUMN_MOVE_ROLLBACK_NOTES` and `MEDIA_ID_MOVE_WIDTH`. The statements live in the package that owns the dialects and measured them; a second copy in the CLI would be a second copy of the clause the ruling got wrong.
49+
50+
## What does NOT change
51+
52+
A deployment that does not run `--apply` is byte-for-byte where it was: the column stays `json`, the write still JSON-encodes, and the read still accepts both encodings. A backfill re-run does not set the stamp and — deliberately — cannot clear it either: `recordDataMigrationRun` omits the key rather than writing a preserved value, so a ledger read that FAILS cannot demote a moved deployment back onto the JSON arm. A partial or failed column step records nothing at all, which leaves such a datastore on the arm that reads both encodings.
53+
54+
`multiple: true` media is untouched on both arms: its value is a list of ids and a JSON column on every deployment.

‎packages/cli/src/commands/migrate/files-to-references.ts‎

Lines changed: 230 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,42 @@ import { bootSchemaStack } from '../../utils/schema-migrate.js';
1919
import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
2020
import { describeOccupancy } from '../../utils/sqlite-occupancy.js';
2121
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
22+
import {
23+
describeFileColumnMoveRefusal,
24+
runFileColumnMove,
25+
type FileColumnMoveResult,
26+
} from '../../utils/file-column-move.js';
27+
import type { IObjectQLEngine } from '@objectstack/spec/contracts';
28+
import type { SqlDriverLike } from '../../utils/schema-migrate.js';
29+
import type { MediaColumnMoveScan, SqlDialectName } from '@objectstack/driver-sql';
30+
31+
/**
32+
* What {@link MigrateFilesToReferences.runColumnStep} did, or declined to do.
33+
*
34+
* `skipped` and `failed` are deliberately separate: every skip is a stated,
35+
* non-failing reason (this command's subject is the backfill), and only a
36+
* column step that ran and could not finish fails the command — because that
37+
* is the one outcome that leaves storage an operator has to be told about.
38+
*/
39+
interface ColumnStepOutcome {
40+
skipped: 'gate_not_passed' | 'no_sql_driver' | 'no_sql_seam' | 'nothing_to_move' | null;
41+
failed: boolean;
42+
/** `sys_migration.columns_moved_at` as written, or `null` if it was not written. */
43+
stampedAt: string | null;
44+
/** Set when the columns moved and RECORDING that failed — a durability failure. */
45+
stampError?: string;
46+
report: {
47+
dialect: SqlDialectName;
48+
apply: boolean;
49+
blocking: number;
50+
outcomes: FileColumnMoveResult['outcomes'];
51+
refusals: MediaColumnMoveScan['refusals'];
52+
executedStatements: string[];
53+
recordable: boolean;
54+
/** Carried from the driver, because the renderer cannot `await import`. */
55+
rollbackNotes: readonly string[];
56+
} | null;
57+
}
2258

2359
async function confirm(question: string): Promise<boolean> {
2460
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
@@ -221,6 +257,26 @@ export default class MigrateFilesToReferences extends Command {
221257
includeUnreferenced: flags['include-unreferenced'],
222258
});
223259

260+
// ── The COLUMN step (#15989, the ruling on #15041 step 2) ────────────
261+
//
262+
// Runs only after the backfill and its self-check reported zero blocking
263+
// rows — the ruling's own "abort otherwise", and the reason it lives
264+
// here rather than in a command of its own: the gate's verdict is what
265+
// authorises it, and this is the only place that verdict exists.
266+
//
267+
// ⛔ The move and the arm flip are ONE act. Measured on SQLite: after
268+
// the columns are converted a JSON-arm driver still READS the migrated
269+
// column correctly but its next WRITE re-quotes. So `columns_moved_at`
270+
// is stamped in the same block that moved the columns, and only when
271+
// every one of them moved.
272+
const columnMove = await this.runColumnStep({
273+
stack,
274+
engine,
275+
apply,
276+
gatePassed: result.gatePassed,
277+
json: flags.json,
278+
});
279+
224280
if (flags.json) {
225281
await emitJson({
226282
database: stack.dbLabel,
@@ -250,9 +306,11 @@ export default class MigrateFilesToReferences extends Command {
250306
gatePassed: result.gatePassed,
251307
gateFailures: result.gateFailures,
252308
flag: result.flag,
309+
columnMove: columnMove.report,
310+
columnsMovedAt: columnMove.stampedAt,
253311
duration: timer.elapsed(),
254312
});
255-
if (!result.gatePassed) this.exit(1);
313+
if (!result.gatePassed || columnMove.failed) this.exit(1);
256314
return;
257315
}
258316

@@ -291,9 +349,11 @@ export default class MigrateFilesToReferences extends Command {
291349
: 'Fix the records listed above, then re-run (and finally with --apply).',
292350
);
293351
}
352+
this.renderColumnStep(columnMove);
353+
294354
console.log(chalk.dim(` ${timer.display()}`));
295355
console.log('');
296-
if (!result.gatePassed) this.exit(1);
356+
if (!result.gatePassed || columnMove.failed) this.exit(1);
297357
} catch (error: any) {
298358
if (isExitSignal(error)) throw error;
299359
if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); }
@@ -303,4 +363,172 @@ export default class MigrateFilesToReferences extends Command {
303363
await stack.shutdown();
304364
}
305365
}
366+
367+
/**
368+
* The column step — plan, pre-check, move, stamp (#15989).
369+
*
370+
* Every early return is a NON-failure with a stated reason: this command's
371+
* subject is the backfill, and a deployment whose driver cannot plan a
372+
* column move is not a deployment whose backfill failed. The one thing that
373+
* fails the command is a column step that was asked to run, ran, and could
374+
* not finish — because that leaves storage the operator must be told about.
375+
*/
376+
private async runColumnStep(args: {
377+
stack: { driver: SqlDriverLike | null; kernel: unknown };
378+
engine: unknown;
379+
apply: boolean;
380+
gatePassed: boolean;
381+
json: boolean;
382+
}): Promise<ColumnStepOutcome> {
383+
const { stack, apply, gatePassed, json } = args;
384+
385+
if (!gatePassed) {
386+
// ⛔ The ruling's "abort unless backfill + verify report zero blocking".
387+
// Not an error of this step's own — the gate already reported why.
388+
return { skipped: 'gate_not_passed', failed: false, stampedAt: null, report: null };
389+
}
390+
if (!stack.driver || typeof stack.driver.planMediaColumnMove !== 'function') {
391+
return { skipped: 'no_sql_driver', failed: false, stampedAt: null, report: null };
392+
}
393+
394+
const scan = await stack.driver.planMediaColumnMove();
395+
if (scan.plans.length === 0 && scan.refusals.length === 0) {
396+
return { skipped: 'nothing_to_move', failed: false, stampedAt: null, report: null };
397+
}
398+
399+
// Lazily, at the point of use — ⛔ never a static value import of a driver
400+
// package in a command module (#5726).
401+
const { MEDIA_COLUMN_MOVE_ROLLBACK_NOTES } = await import('@objectstack/driver-sql');
402+
const { resolveSeedTenancyExec, normalizeRows } = await import('@objectstack/metadata-protocol');
403+
const exec = resolveSeedTenancyExec(args.engine as IObjectQLEngine | undefined);
404+
// Loud absence, never a silent success. A driver can expose an `execute`
405+
// that accepts every statement and performs none (#10677) — and "moved 3
406+
// columns" from a seam that ran nothing, followed by a `columns_moved_at`
407+
// stamp, is the worst report this command could produce: the driver would
408+
// then write bare ids into columns that never moved.
409+
const answers = exec
410+
? await exec('select 1 as os_seam_probe')
411+
.then((r) => normalizeRows(r).length > 0)
412+
.catch(() => false)
413+
: false;
414+
if (!exec || !answers) {
415+
return { skipped: 'no_sql_seam', failed: false, stampedAt: null, report: null };
416+
}
417+
418+
const run = await runFileColumnMove({
419+
scan,
420+
exec,
421+
rows: normalizeRows,
422+
apply,
423+
onStatement: json ? undefined : (statement: string) => printStep(chalk.dim(statement)),
424+
});
425+
426+
let stampedAt: string | null = null;
427+
let stampError: string | undefined;
428+
if (run.recordable) {
429+
try {
430+
const { recordFileColumnMove } = await import('@objectstack/platform-objects/system');
431+
const { FILE_REFERENCES_MIGRATION_ID } = await import('@objectstack/spec/system');
432+
stampedAt = await recordFileColumnMove(args.engine as any, FILE_REFERENCES_MIGRATION_ID);
433+
} catch (error: any) {
434+
// The columns MOVED and the ledger does not say so. That is a
435+
// durability degradation in the sense AGENTS.md names: the next boot
436+
// stays on the JSON arm and re-quotes its writes into a column that
437+
// has already been converted. It must fail the command.
438+
stampError = error?.message ?? String(error);
439+
}
440+
}
441+
442+
const failed =
443+
run.outcomes.some((o) => o.status === 'failed') || stampError !== undefined;
444+
445+
return {
446+
skipped: null,
447+
failed,
448+
stampedAt,
449+
stampError,
450+
report: {
451+
dialect: scan.dialect,
452+
rollbackNotes: MEDIA_COLUMN_MOVE_ROLLBACK_NOTES,
453+
apply: run.apply,
454+
blocking: run.blocking,
455+
outcomes: run.outcomes,
456+
refusals: run.refusals,
457+
executedStatements: run.executedStatements,
458+
recordable: run.recordable,
459+
},
460+
};
461+
}
462+
463+
/** The human-mode half of {@link runColumnStep}. JSON mode reports the same facts. */
464+
private renderColumnStep(outcome: ColumnStepOutcome): void {
465+
if (outcome.skipped === 'gate_not_passed' || outcome.report === null) {
466+
if (outcome.skipped === 'no_sql_driver') {
467+
printInfo(
468+
'Column step: not applicable — the ADR-0104 file-family column move is a SQL-driver step ' +
469+
'and no SQL driver is active here.',
470+
);
471+
} else if (outcome.skipped === 'no_sql_seam') {
472+
printWarning(
473+
'Column step: SKIPPED — the active driver exposes no usable raw SQL seam, so the media ' +
474+
'columns were neither inspected nor moved. The deployment stays on the JSON encoding.',
475+
);
476+
} else if (outcome.skipped === 'nothing_to_move') {
477+
printInfo('Column step: nothing to move — this datastore declares no single-value media column.');
478+
}
479+
return;
480+
}
481+
482+
const report = outcome.report;
483+
console.log('');
484+
console.log(chalk.bold(`Column step · ${report.dialect}`));
485+
for (const o of report.outcomes) {
486+
const mark =
487+
o.status === 'moved' ? chalk.green('✓')
488+
: o.status === 'blocked' || o.status === 'failed' ? chalk.red('✗')
489+
: chalk.yellow('•');
490+
console.log(`${mark} ${chalk.bold(`${o.table}.${o.column}`)} ${chalk.dim(`(${o.kind})`)}`);
491+
console.log(` ${chalk.cyan(o.statement)}`);
492+
if (o.error) console.log(` ${chalk.red(o.error)}`);
493+
}
494+
for (const refusal of report.refusals) {
495+
printWarning(`${refusal.table}.${refusal.column}: ${refusal.detail}`);
496+
}
497+
498+
const refusal = describeFileColumnMoveRefusal({
499+
apply: report.apply,
500+
outcomes: report.outcomes,
501+
refusals: report.refusals,
502+
executedStatements: report.executedStatements,
503+
blocking: report.blocking,
504+
recordable: report.recordable,
505+
});
506+
console.log('');
507+
if (refusal) {
508+
printError(refusal);
509+
} else if (!report.apply) {
510+
printInfo(
511+
`Dry run — every abort pre-check passed and nothing was executed. ${report.outcomes.length} ` +
512+
'column(s) would move. Take a backup, then re-run with --apply.',
513+
);
514+
} else if (outcome.stampError) {
515+
printError(
516+
`The columns MOVED but recording it failed (${outcome.stampError}). This deployment's ` +
517+
'driver will stay on the JSON encoding and re-quote its next write into a column that ' +
518+
'has already been converted — re-run this command to record it.',
519+
);
520+
} else if (outcome.stampedAt) {
521+
printSuccess(
522+
`Column step complete — ${report.outcomes.length} media column(s) moved to the bare-id ` +
523+
`encoding and recorded (sys_migration.columns_moved_at = ${outcome.stampedAt}). The SQL ` +
524+
'driver writes bare ids from its next boot, and keeps reading the legacy encoding.',
525+
);
526+
}
527+
528+
if (refusal || report.outcomes.some((o) => o.status === 'failed')) {
529+
console.log('');
530+
console.log(chalk.bold('If it goes wrong:'));
531+
for (const note of report.rollbackNotes) console.log(` ${chalk.dim('·')} ${note}`);
532+
}
533+
}
306534
}

0 commit comments

Comments
 (0)