Skip to content

Commit a0920b4

Browse files
fix(driver-turso): refuse media column move planning on the remote face instead of answering "nothing to move" (#20014)
Fixes #19894 Clause-②: yes (narrowing) A remote `TursoDriver` now refuses `planMediaColumnMove()` with the transport's `NOT_IMPLEMENTED` / 501 envelope instead of answering an empty scan. `os migrate files-to-references` reports that refusal as a column step it could not judge, beside its full backfill, verify and flag report, instead of printing "nothing to move". ## Why `Clause-②: yes (narrowing)` and not the claim's `no` The claim (comment 5819382636) carries `Clause-②: no`, set for the driver-only surface. H2 below put the command file in scope, and the command's `--json` document gains one key, `columnMoveRefused`. This repo reads a new `--json` key as a clause-② widening: `.changeset/17080-per-release-spec-changes.md` declares "`Clause-②: yes (widening)` — … plus one new `os validate --json` key". The driver refusal is the narrowing. A diff that widens one surface and narrows another is the `yes (narrowing)` combination `scripts/pm/clause2-line.mjs` names. Both packages are graded `minor`, so the level axis and the ADR-0087 gate hold under either reading. The seat decides which line stands; if it is `no (narrowing)`, the changeset line changes with it. ## What changed - `packages/drivers/driver-turso/src/turso-driver.ts`: a `refuseRemoteMediaColumnMove()` builder, the same shape as the four sibling remote refusals, and a `planMediaColumnMove()` override. The remote face refuses. The local and replica faces call `super` unchanged. The constructor guard, `detectMode` and `toKnexConfig` are untouched. - `packages/cli/src/commands/migrate/files-to-references.ts`: `runColumnStep` catches a planner throw whose `code` is `StandardErrorCode.enum.NOT_IMPLEMENTED`. It returns a stated skip, `driver_cannot_plan`, carrying `{ error, code }` built by `errorCodeFields`. Any other throw is re-thrown to the outer `catch`, as before. The text face prints one `Column step: NOT JUDGED` warning that carries the driver's message. `--json` gains `columnMoveRefused`, which is `null` unless the driver refused. The exit code stays the self-check's, as it already is for the command's other column-step skips (`runColumnStep`'s own docblock: "a deployment whose driver cannot plan a column move is not a deployment whose backfill failed"). - Two pins and a changeset (`@objectstack/driver-turso` and `@objectstack/cli`, both `minor`; BREAKING banner; `adr-0087: not-required (no-migration-prescription)`). ## Measurements (tree `61609edf8`, this branch's base) **H1: confirmed.** A table `m` with `doc: file` and `pic: image`, synced on the transport's SQLite-backed double. On each remote door the physical columns were `id, created_at, updated_at, title, doc, pic`, all TEXT. | face | door | `managedObjectFields` | placeholder `hasTable('m')` | scan | |:--|:--|:--|:--|:--| | local `:memory:` | `initObjects` | `m` | true | 2 `unquote` plans | | replica | `initObjects` | `m` | true | 2 `unquote` plans | | remote | `syncSchemasBatch` | empty | false | `{ plans: [], refusals: [] }` | | remote | `syncSchema` | empty | false | `{ plans: [], refusals: [] }` | | remote | `initObjects` | empty | false | `{ plans: [], refusals: [] }` | `mediaFields` read `{ m: [doc, pic] }` on every remote door. The planner walks `managedObjectFields` and probes through the placeholder, and either one alone empties the answer. **H2: the command misreported a driver refusal, so the command file is in scope.** Measured on the base command with this PR's CLI doubles, a planner that throws `NOT_IMPLEMENTED`: - `--apply --yes --json`: exit 1, and stdout was the error alone: `{"error":"Planning the media column move is not supported by this transport.","code":"NOT_IMPLEMENTED"}`. There was no backfill, verify or `flag` key, although `runFilesToReferencesMigration` records the flag before the column step runs. There was no status either: `errorCodeFields` forwards `httpStatus`, and the driver stamps `status`. - `--apply --yes`, text face: exit 1. Only the header, the boot line and one error line printed: no backfill report and no "deployment flag recorded" line. - The pre-fix remote answer, an empty scan: exit 0, and the text face printed "Column step: nothing to move — this datastore declares no single-value media column." In `--json`, `columnMove` was `null`. **H3: confirmed and reused.** `turso-driver.ts` at base has four `NOT_IMPLEMENTED` / 501 refusal builders: autonumber, transaction, deferred DDL and drift detection. This one copies their shape. It adds no new code. **H4: a remote `TursoDriver` cannot reach the moved state.** `fileColumnsMoved` is set in exactly two places: the constructor, from `SqlDriverConfig.fileColumnsMoved`, and `resolveFileColumnsMoved()`. `toKnexConfig` builds the base config from fixed keys and forwards no `fileColumnsMoved` on any arm. `resolveFileColumnsMoved()` is called only from `SqlDriver.initObjects`, which none of the three remote doors calls. Measured with a stand-in resolver that answers `true`: `setFileColumnsMovedResolver` returned `true` (taken), and on every remote door the resolver was asked 0 times. `fileColumnsMoved` stayed `false`. A remote write of `file_abc` stored `"file_abc"` (the JSON encoding) and read back as `file_abc`. The `SqlDriverConfig.fileColumnsMoved` docblock names this posture as the intended fail-toward: "A host outside that path … simply never resolves it and stays on the JSON arm, which is again the correct fail-toward." This PR stops the one in-repo route that could have stamped `columns_moved_at` through a remote datasource, which is this command's column step. Whether a remote database can hold that stamp by an out-of-band route, such as one imported from a moved local file, was not measured. No finding filed; see Acceptance notes. **H5: no in-repo doc, skill or example tells an operator to run the command against a remote Turso database, and none describes `nothing_to_move`.** Searches at `61609edf8`, over `content/docs`, `skills`, `docs`, `examples` and `.claude/skills`: - `files-to-references`: 10 files. Of these, 4 also mention turso or libsql. In each of the 4 the mention is outside the command's section: `content/docs/deployment/cli.mdx` in its `os serve` / `os start` flag tables, and `types.mdx`, `releases/v17/17-0.mdx` and the QA checklist in unrelated lines. The positive control is `content/docs/deployment/cli.mdx`, which lists the command (lines 955 to 965). - `nothing_to_move` or "nothing to move": 0 hits. Positive control: 6 hits in `packages/cli/src`. - `columnMove`, `columnsMovedAt` or "column step": 1 hit, in ADR-0104 line 1272, which is about boot refusal and not this output. Positive control: `columnsMovedAt` has 1 hit in `packages/cli/src`. - turso or libsql on the same line as `os migrate`: 2 hits, both unrelated (a removed turso config key and the `mongo` driver-id rename). Positive control: 65 `os migrate` lines in `cli.mdx`. No doc documents the command's `--json` key set, so the new key needs no doc edit. ## Verification (HEAD `672da75c7`) - **driver-turso**: `typecheck` exit 0. It compiles test files: its first run caught a TS2339 in the new pin, now fixed. `vitest run`: 63 files, 1417 tests passed. - **New driver pin** `turso-remote-media-column-move-refusal.test.ts`, 9 tests. The controls: `:memory:` and `file:` local faces equal a plain `SqlDriver` scan of the same declaration, and the replica face plans 2 `unquote` moves. The remote refusal runs on all three doors, with the media columns asserted on disk. It asserts `code`, `status`, the first sentence, and that no statement was sent. A last pin checks the encoding sentence the refusal ends with. - **Driver ablation** via `scripts/ablation-replace.mjs`, on the guard line. Anchor 1 to 0, blob `9919ee7a80bd` to `b4b6eb8df1b1`. The 3 remote cases went red with `expected a refusal, got an answer: {"dialect":"sqlite","plans":[],"refusals":[]}`, which is the pre-fix answer. The 6 other cases stayed green. Restore was proven: blob equal to HEAD, `git diff HEAD` empty. The subject resolves through the relative `./turso-driver.js` import, so no `dist/` is in the path. - **CLI**: `typecheck` exit 0 (`tsc --noEmit`, then `check:test-typecheck` OK with the debt ledger unchanged). The unit layer ran 224 files, with 3129 tests passed and 29 skipped. 2 files failed only on a prerequisite, "packages/cli is not built (./dist/index.js is absent)". After `pnpm --filter @objectstack/cli build`, those 2 files plus the new pin ran green: 3 files, 34 tests. The integration layer is declared to CI. The diff touches no integration-tier file and no spawn entry, and the new pin is unit-tier: it mocks `bootSchemaStack` through `vi.hoisted` and value-imports no driver package. - **New CLI pin** `files-to-references.column-step-refusal.test.ts`, 5 tests, end to end through `MigrateFilesToReferences.run`. It covers the exit code and `--json` for `--apply --yes --json` and for the dry run, plus the text face. Controls: an empty scan still reads "nothing to move" and has `columnMoveRefused: null`. A throw without a capability code still exits 1 with `{"error":"connection reset","code":"ECONNRESET"}`. - **CLI ablation** on the `NOT_IMPLEMENTED` catch line, anchor 1 to 0: the 3 refusal cases went red (exit 1, the pre-change shape), the 2 controls stayed green, and the restore was proven. - **Gates**: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `672da75c7` derived 64 commands. All 64 exit 0. `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity` first exited 3 (PREREQUISITE NOT MET, workspace not built) and were re-run green after the build. `--ran`: "✓ dispatch-gates --ran: 64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED (a DERIVED zero — all 64 recorded an exit code and none of them is 3)." - `check-adr-0087-registration --base origin/main`: "✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition." The changeset is read as `[BREAKING+clause-②-narrowing]`, `not-required (no-migration-prescription)`. - `check-changeset-no-major --base origin/main`: "✓ This diff introduces no `major` bump." Its level axis reads "NOT APPLICABLE" locally, with no `pull_request` payload; see the `--event` run below. - `check:driver-conformance`, before (at `61609edf8`) and after: "OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt." - The diff-scoped `node scripts/check-issue-citations.mjs --base 61609ed`: "✅ check-issue-citations: every citation this change adds resolves". - `pnpm check:error-code-casing`: "✓ no unlisted lowercase error codes in 6509 scanned file(s) (ADR-0112)." - **Lint** was narrowed to the 4 changed `.ts` files with `eslint --no-inline-config --format json`: 4 files, 0 errors, 0 warnings. The population is eslint's own: `--print-config` resolves a config for each file, and none reports "File ignored". This narrowing cannot move any untouched file's verdict, because `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules; its own comment says so). The repo-wide `pnpm lint` is CI's. - NOT MEASURED locally, and owned by CI: the path-scheduled jobs (Test Core shards, Temporal Conformance, Dogfood, Build Core) and the workspace type-check lanes. ## Acceptance notes - **Why the refusal does not point at the local or replica transport** (the suggested route asked for that, like the siblings). Moving a local SQLite copy does not move the remote database. For a remote database that did hold moved columns, H4 measured that the remote face keeps writing the JSON encoding. Whether an embedded replica's column step reaches the primary was not measured. So the message says what the remote datasource keeps, and names no remedy that was not measured. - **README left untouched.** It documents none of the four sibling remote refusals, and adding only this one would be partial. Separately, line 25 of `packages/drivers/driver-turso/README.md` lists `RemoteTransport: beginTransaction, commit, rollback`, while the remote face refuses all three with `NOT_IMPLEMENTED` / 501 (`refuseRemoteTransaction`). That is reported to the seat as a finding and not changed here. - **Observation, not filed**: `errorCodeFields` forwards `httpStatus` and drops `status`, so a driver refusal reaching any CLI `--json` error envelope carries its `code` without a status. The new `columnMoveRefused` uses the same helper, which keeps it consistent with the command's own error envelope. - **Observation, not filed**: any other throw after the backfill of an `--apply` run still takes the outer `catch` and replaces the report with the error. This PR leaves that path as it was. There is no named reproduction of a non-refusal throw at that point. - **H4 posture, not filed**: the remote face takes the engine's ADR-0104 resolver and never asks it. That is the documented fail-toward, and after this PR no in-repo command can stamp a remote database as moved. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fc6ddb8 commit a0920b4

5 files changed

Lines changed: 590 additions & 2 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/driver-turso': minor
3+
'@objectstack/cli': minor
4+
---
5+
6+
fix(driver-turso): a REMOTE `TursoDriver` refuses to plan the ADR-0104 media column move instead of answering that there is nothing to move, and `os migrate files-to-references` reports that refusal as a column step it could not judge (#19894)
7+
8+
Clause-②: yes (narrowing)
9+
10+
**BREAKING for callers that plan the media column move on a remote Turso datasource** — `TursoDriver.planMediaColumnMove()` in `remote` transport mode (for example a `libsql://` URL) now throws a `NOT_IMPLEMENTED` / `501` error, where it used to answer `{ plans: [], refusals: [] }`. The `local` (`:memory:` and `file:`) and `replica` modes plan exactly as before, and the local modes plan exactly what `SqlDriver` plans for the same declaration.
11+
12+
What the refusal replaces, measured on the transport's SQLite-backed test double: a table with a `file` and an `image` field, synced through each of the three remote schema doors (`syncSchemasBatch`, `syncSchema`, `initObjects`), held its two TEXT media columns on the remote database, and the remote face answered an empty scan on every door. The inherited planner walks the objects `SqlDriver`'s own schema sync registers, which no remote schema door reaches, and probes each table through the placeholder in-memory Knex connection a remote driver is built with. The local and embedded-replica faces planned two `unquote` moves for the same declaration. `os migrate files-to-references` printed that empty scan as "Column step: nothing to move — this datastore declares no single-value media column".
13+
14+
- **The command reports the refusal instead of failing on it.** `os migrate files-to-references` calls the planner only after the backfill and its self-check have passed, and an `--apply` run has recorded the deployment flag by then. Measured on the command's own test doubles before this change, a planner that throws ended the run with the error alone (`--json`: `{"error": …, "code": "NOT_IMPLEMENTED"}`) and exit 1, with no backfill report, no verify report and no word about the flag it had recorded. The column step now catches a `NOT_IMPLEMENTED` refusal by its code and reports it as a skip: the text face prints `Column step: NOT JUDGED` with the driver's message, and `--json` gains `columnMoveRefused` — `{ error, code }` when the driver refused, `null` otherwise — beside `columnMove: null` and `columnsMovedAt: null`. The backfill, verify and flag reports are emitted as on any other run, nothing is stamped, and the exit code is the self-check's, as it already was for the command's other column-step skips.
15+
- **Any other throw from the planner still fails the command**, through the same error report and exit 1 as before.
16+
- **A genuinely empty scan still reads "nothing to move".**
17+
- **No new error code.** `NOT_IMPLEMENTED` / `501` is a standard code, the envelope this transport already uses for its remote transaction, auto-number, deferred-DDL and drift-detection refusals.
18+
19+
**If you are refused:** the backfill, its self-check and, on `--apply`, the deployment flag are unaffected. A remote Turso datasource keeps its single-value media columns on the JSON encoding: measured on the same double, the remote face writes a file id as a JSON string and reads it back as the id, and it does not read the record of a completed column move.
20+
21+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or reshaped: no spec key, no export, no stored row and no config key — `planMediaColumnMove` keeps its name and its signature, and the command's `--json` document only gains a key. There is no old spelling that maps to a new one: the refused call asked the remote transport for a capability it never delivered, and the refusal itself says what the datasource keeps. -->
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19894] `os migrate files-to-references` when the active driver REFUSES to
5+
* plan the ADR-0104 column step, the way the Turso REMOTE face does
6+
* (`NOT_IMPLEMENTED` / 501).
7+
*
8+
* ## The two wrong answers this replaces
9+
*
10+
* Before the driver refused, the remote face answered the planner with an
11+
* empty scan, and the command mapped that to `nothing_to_move`: "this
12+
* datastore declares no single-value media column", on a database whose media
13+
* columns were present. A driver refusal alone would have swapped that for a
14+
* second wrong report. The planner is called only after the backfill and its
15+
* self-check passed, and on an `--apply` run the deployment flag is already
16+
* recorded by then. A throw from the planner reached the command's outer
17+
* `catch`, which printed the refusal and exited 1 with no backfill report, no
18+
* verify report and no word about the flag it had just written. Measured on
19+
* this suite's own doubles before the command change: `--apply --yes --json`
20+
* emitted one line, `{"error": …, "code": "NOT_IMPLEMENTED"}`, and exit 1.
21+
*
22+
* ## What is pinned
23+
*
24+
* 1. A planner refusal is a stated, non-failing column-step outcome, which is
25+
* the contract `runColumnStep` already keeps for a driver that cannot plan:
26+
* the full backfill / verify / flag report is emitted, the refusal rides
27+
* beside it as `columnMoveRefused` (the command's own error-envelope shape),
28+
* nothing is stamped, and the exit code is the gate's.
29+
* 2. The text face says the step was not judged, carries the driver's reason,
30+
* and never says "nothing to move".
31+
* 3. Controls: a genuinely empty scan still reads "nothing to move", and a
32+
* throw that is NOT a capability refusal still fails the command through
33+
* the outer `catch`, as before.
34+
*
35+
* The seams that would boot a database or walk a real engine are replaced;
36+
* the command's own parse and control flow run for real. The refusal fixture
37+
* is the driver's envelope shape, `code` + `status`; the driver-side pin is
38+
* `turso-remote-media-column-move-refusal.test.ts` in `@objectstack/driver-turso`.
39+
*/
40+
41+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
42+
import { dirname, resolve } from 'node:path';
43+
import { fileURLToPath } from 'node:url';
44+
import MigrateFilesToReferences from './files-to-references.js';
45+
import { isExitSignal } from '../../utils/format.js';
46+
47+
// ⛔ `vi.hoisted`, never a value import of `bootSchemaStack`: this suite boots
48+
// nothing, and a value import of that symbol is what files a test in the
49+
// integration tier (`vitest-tiers.ts`).
50+
const mocks = vi.hoisted(() => ({
51+
bootSchemaStack: vi.fn(),
52+
runFilesToReferencesMigration: vi.fn(),
53+
}));
54+
55+
vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: mocks.bootSchemaStack }));
56+
vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
57+
OCCUPANCY_HINT: 'occupancy hint',
58+
probeMigrationTarget: vi.fn(async () => ({ status: 'free' })),
59+
}));
60+
vi.mock('../../utils/data-migration-plugins.js', () => ({ buildDataMigrationPlugins: vi.fn(async () => []) }));
61+
vi.mock('@objectstack/service-storage', () => ({
62+
runFilesToReferencesMigration: mocks.runFilesToReferencesMigration,
63+
formatBackfillReport: () => 'BACKFILL-REPORT',
64+
formatFileReferenceReport: () => 'VERIFY-REPORT',
65+
}));
66+
67+
const HERE = dirname(fileURLToPath(import.meta.url));
68+
const CLI_ROOT = resolve(HERE, '..', '..', '..');
69+
/** oclif builds its whole command table on the first `run()` in a process. */
70+
const RUN_TIMEOUT = 60_000;
71+
72+
/** The driver's refusal: the ADR-0112 envelope the Turso remote face throws. */
73+
const REFUSAL_MESSAGE = 'Planning the media column move is not supported by this transport.';
74+
const refusal = () => Object.assign(new Error(REFUSAL_MESSAGE), { code: 'NOT_IMPLEMENTED', status: 501 });
75+
76+
/** The flag row an `--apply` run has already recorded when the column step starts. */
77+
const FLAG = { migrationId: 'adr-0104-file-references', passed: true };
78+
79+
const result = (apply: boolean) => ({
80+
backfill: {
81+
scannedObjects: ['product'], scannedRecords: 2, converted: 1, alreadyReferences: 1,
82+
externalUrls: 0, unresolvable: 0, truncated: false, actions: [],
83+
},
84+
verify: {
85+
scannedObjects: ['product'], scannedRecords: 2, heldReferences: 2, ownedFiles: 2,
86+
counts: {}, blocking: 0, ok: true, truncated: false, issues: [],
87+
},
88+
gatePassed: true,
89+
gateFailures: [],
90+
flag: apply ? FLAG : null,
91+
});
92+
93+
/** The engine surface the command checks before it runs: `sys_file`, and one app object. */
94+
const engine = {
95+
getObject: (name: string) => (name === 'sys_file' ? {} : undefined),
96+
getConfigs: () => ({ product: {}, sys_file: {} }),
97+
};
98+
99+
function bootWith(planMediaColumnMove: () => Promise<unknown>) {
100+
mocks.bootSchemaStack.mockResolvedValue({
101+
driver: {
102+
detectManagedDrift: async () => [],
103+
applyMigrationEntries: async () => ({ applied: [], skipped: [] }),
104+
planMediaColumnMove: vi.fn(planMediaColumnMove),
105+
},
106+
kernel: { getService: (name: string) => (name === 'objectql' ? engine : {}) },
107+
dbLabel: 'libsql://media.turso.io',
108+
shutdown: vi.fn(async () => {}),
109+
});
110+
}
111+
112+
let stdout: ReturnType<typeof vi.spyOn>;
113+
let log: ReturnType<typeof vi.spyOn>;
114+
let stderr: ReturnType<typeof vi.spyOn>;
115+
beforeEach(() => {
116+
mocks.bootSchemaStack.mockReset();
117+
mocks.runFilesToReferencesMigration.mockReset();
118+
mocks.runFilesToReferencesMigration.mockImplementation(async (_e: unknown, _s: unknown, _l: unknown, o: { apply: boolean }) => result(o.apply));
119+
// `emitJson` awaits the write's DRAIN callback, so the double must invoke it.
120+
stdout = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, enc?: unknown, cb?: unknown) => {
121+
const done = typeof enc === 'function' ? enc : cb;
122+
if (typeof done === 'function') done();
123+
return true;
124+
}) as typeof process.stdout.write);
125+
log = vi.spyOn(console, 'log').mockImplementation(() => {});
126+
stderr = vi.spyOn(console, 'error').mockImplementation(() => {});
127+
});
128+
afterEach(() => {
129+
stdout.mockRestore();
130+
log.mockRestore();
131+
stderr.mockRestore();
132+
});
133+
134+
/** Run the command; answer its exit code (0 when `run` resolved) and what it emitted. */
135+
async function runCommand(argv: string[]) {
136+
const err = await MigrateFilesToReferences.run(argv, { root: CLI_ROOT }).then(() => null, (e: unknown) => e);
137+
if (err !== null && !isExitSignal(err)) throw err;
138+
const exit = err === null ? 0 : (err as { oclif?: { exit?: number } }).oclif?.exit;
139+
const json: string = stdout.mock.calls.map((c: unknown[]) => String(c[0])).join('');
140+
const lines: string[] = log.mock.calls.map((c: unknown[]) => c.map(String).join(' '));
141+
return { exit, json, lines };
142+
}
143+
144+
describe('a planner refusal is a stated column-step outcome, never a failed run (#19894)', () => {
145+
it('--apply --yes --json: the full report, the flag already recorded, the refusal beside it, exit 0', async () => {
146+
bootWith(async () => { throw refusal(); });
147+
148+
const { exit, json } = await runCommand(['--apply', '--yes', '--json']);
149+
150+
expect(exit).toBe(0);
151+
const payload = JSON.parse(json);
152+
// The durable half of the run is reported: the flag this run wrote.
153+
expect(payload.gatePassed).toBe(true);
154+
expect(payload.flag).toEqual(FLAG);
155+
expect(payload.backfill.converted).toBe(1);
156+
expect(payload.verify.blocking).toBe(0);
157+
// The column step: nothing planned, nothing stamped, and why.
158+
expect(payload.columnMove).toBeNull();
159+
expect(payload.columnsMovedAt).toBeNull();
160+
expect(payload.columnMoveRefused).toEqual({ error: REFUSAL_MESSAGE, code: 'NOT_IMPLEMENTED' });
161+
}, RUN_TIMEOUT);
162+
163+
it('dry run --json: the same shape, no flag, exit 0', async () => {
164+
bootWith(async () => { throw refusal(); });
165+
166+
const { exit, json } = await runCommand(['--json']);
167+
168+
expect(exit).toBe(0);
169+
const payload = JSON.parse(json);
170+
expect(payload.apply).toBe(false);
171+
expect(payload.flag).toBeNull();
172+
expect(payload.columnMove).toBeNull();
173+
expect(payload.columnsMovedAt).toBeNull();
174+
expect(payload.columnMoveRefused).toEqual({ error: REFUSAL_MESSAGE, code: 'NOT_IMPLEMENTED' });
175+
}, RUN_TIMEOUT);
176+
177+
it('text face: both reports, the flag line, and a column step that says it was not judged', async () => {
178+
bootWith(async () => { throw refusal(); });
179+
180+
const { exit, lines } = await runCommand(['--apply', '--yes']);
181+
182+
expect(exit).toBe(0);
183+
expect(lines).toContain('BACKFILL-REPORT');
184+
expect(lines).toContain('VERIFY-REPORT');
185+
expect(lines.some((l) => l.includes('deployment flag recorded'))).toBe(true);
186+
const step = lines.filter((l) => l.includes('Column step'));
187+
expect(step).toHaveLength(1);
188+
expect(step[0]).toContain('NOT_IMPLEMENTED');
189+
expect(step[0]).toContain(REFUSAL_MESSAGE);
190+
expect(lines.some((l) => l.includes('nothing to move'))).toBe(false);
191+
}, RUN_TIMEOUT);
192+
});
193+
194+
describe('controls — what the refusal branch must not absorb', () => {
195+
it('a genuinely empty scan still reads "nothing to move", with no refusal field set', async () => {
196+
bootWith(async () => ({ dialect: 'sqlite', plans: [], refusals: [] }));
197+
198+
const text = await runCommand(['--apply', '--yes']);
199+
expect(text.exit).toBe(0);
200+
expect(text.lines.some((l) => l.includes('Column step: nothing to move'))).toBe(true);
201+
202+
stdout.mockClear();
203+
const { exit, json } = await runCommand(['--apply', '--yes', '--json']);
204+
expect(exit).toBe(0);
205+
const payload = JSON.parse(json);
206+
expect(payload.columnMove).toBeNull();
207+
expect(payload.columnMoveRefused).toBeNull();
208+
}, RUN_TIMEOUT);
209+
210+
it('a throw that is not a capability refusal still fails the command through the outer catch', async () => {
211+
bootWith(async () => { throw Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }); });
212+
213+
const { exit, json } = await runCommand(['--apply', '--yes', '--json']);
214+
215+
expect(exit).toBe(1);
216+
expect(JSON.parse(json)).toEqual({ error: 'connection reset', code: 'ECONNRESET' });
217+
}, RUN_TIMEOUT);
218+
});

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

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ import {
1414
emitJson,
1515
isExitSignal,
1616
errorCodeFields,
17+
type ErrorCodeFields,
1718
} from '../../utils/format.js';
19+
import { StandardErrorCode } from '@objectstack/spec/api';
1820
import { bootSchemaStack } from '../../utils/schema-migrate.js';
1921
import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
2022
import { describeOccupancy } from '../../utils/sqlite-occupancy.js';
@@ -37,12 +39,24 @@ import type { MediaColumnMoveScan, SqlDialectName } from '@objectstack/driver-sq
3739
* is the one outcome that leaves storage an operator has to be told about.
3840
*/
3941
interface ColumnStepOutcome {
40-
skipped: 'gate_not_passed' | 'no_sql_driver' | 'no_sql_seam' | 'nothing_to_move' | null;
42+
skipped:
43+
| 'gate_not_passed'
44+
| 'no_sql_driver'
45+
| 'no_sql_seam'
46+
| 'driver_cannot_plan'
47+
| 'nothing_to_move'
48+
| null;
4149
failed: boolean;
4250
/** `sys_migration.columns_moved_at` as written, or `null` if it was not written. */
4351
stampedAt: string | null;
4452
/** Set when the columns moved and RECORDING that failed — a durability failure. */
4553
stampError?: string;
54+
/**
55+
* Set with `skipped: 'driver_cannot_plan'`: the driver's own refusal, in this
56+
* command's error-envelope shape (`{ error, code }`), so a `--json` consumer
57+
* reads it with the parser it already has for the command's failures.
58+
*/
59+
planRefusal?: { error: string } & ErrorCodeFields;
4660
report: {
4761
dialect: SqlDialectName;
4862
apply: boolean;
@@ -308,6 +322,7 @@ export default class MigrateFilesToReferences extends Command {
308322
flag: result.flag,
309323
columnMove: columnMove.report,
310324
columnsMovedAt: columnMove.stampedAt,
325+
columnMoveRefused: columnMove.planRefusal ?? null,
311326
duration: timer.elapsed(),
312327
});
313328
if (!result.gatePassed || columnMove.failed) this.exit(1);
@@ -391,7 +406,27 @@ export default class MigrateFilesToReferences extends Command {
391406
return { skipped: 'no_sql_driver', failed: false, stampedAt: null, report: null };
392407
}
393408

394-
const scan = await stack.driver.planMediaColumnMove();
409+
let scan: MediaColumnMoveScan;
410+
try {
411+
scan = await stack.driver.planMediaColumnMove();
412+
} catch (error: any) {
413+
// [#19894] A driver that answers NOT_IMPLEMENTED cannot plan the move on
414+
// this datastore (the Turso remote face is the measured case). That is a
415+
// stated skip, and ⛔ never "nothing to move" nor a failed run: the
416+
// backfill and its self-check have already reported, and an `--apply`
417+
// run has already recorded the deployment flag, so letting the refusal
418+
// reach the outer `catch` would replace that whole report with a bare
419+
// error. Asked by the error's CODE, so any other throw is still a failure
420+
// and still takes the outer `catch`.
421+
if (error?.code !== StandardErrorCode.enum.NOT_IMPLEMENTED) throw error;
422+
return {
423+
skipped: 'driver_cannot_plan',
424+
failed: false,
425+
stampedAt: null,
426+
report: null,
427+
planRefusal: { error: error.message || String(error), ...errorCodeFields(error) },
428+
};
429+
}
395430
if (scan.plans.length === 0 && scan.refusals.length === 0) {
396431
return { skipped: 'nothing_to_move', failed: false, stampedAt: null, report: null };
397432
}
@@ -473,6 +508,13 @@ export default class MigrateFilesToReferences extends Command {
473508
'Column step: SKIPPED — the active driver exposes no usable raw SQL seam, so the media ' +
474509
'columns were neither inspected nor moved. The deployment stays on the JSON encoding.',
475510
);
511+
} else if (outcome.skipped === 'driver_cannot_plan') {
512+
printWarning(
513+
`Column step: NOT JUDGED — the active driver cannot plan the media column move here ` +
514+
`(${outcome.planRefusal?.code}), so the media columns were neither inspected nor moved ` +
515+
'and no column move was recorded. The deployment stays on the JSON encoding. ' +
516+
`The driver says: ${outcome.planRefusal?.error}`,
517+
);
476518
} else if (outcome.skipped === 'nothing_to_move') {
477519
printInfo('Column step: nothing to move — this datastore declares no single-value media column.');
478520
}

0 commit comments

Comments
 (0)