Skip to content

Commit fdbcec0

Browse files
fix(cli): six read-only arms claim no row or table, and name the journal-mode switch on a SQLite file (#23725)
Fixes #23622 Clause-②: no The remainder of #23562, with PR #23608's pattern. Six commands boot through the SQL driver's connect before they print. On a SQLite file still on a rollback journal, that connect switches the journal mode, which rewrites the file header and no row. Their sentences said "writes nothing", "nothing was written" or "Nothing was changed". Each now claims no row or table, and on a SQLite target it is followed by the exported `JOURNAL_MODE_SWITCH_NOTE`: "Opening a SQLite file can still switch its journal mode (to WAL by default), which changes its header only." The basis for `Clause-②: no` is in the claim: this is operator text only. No flag, exit code, accepted input, `--json` document or export moves, and there is no `packages/spec` edit. ## The premise, measured per command (H1) I ran the real CLI (`bin/run-dev.js`) on one served-shape fixture. A full boot created every table these commands read, and one row of work was seeded for each. The file was then put on `journal_mode = delete`, and each run got its own copy. Bytes 18 and 19 of the header are the journal mode (1,1 means a rollback journal; 2,2 means WAL), and bytes 24 to 27 are the change counter. "Rows" means the schema plus every row of every table, read on a connection of my own. | Run | Header 18,19 · counter | Rows | |---|---|---| | `os migrate security-catalog-overlays`, beside an idle holder process | 1,1 · 4 → 2,2 · 5 | unchanged | | `os migrate recorded-by` (dry run, one sentinel row) | 1,1 · 4 → 2,2 · 5 | unchanged | | `os migrate resume` (list, one interrupted run) | 1,1 · 4 → 2,2 · 5 | unchanged | | `os secret orphans` (report) | 1,1 · 4 → 2,2 · 5 | unchanged | | `os secret rewrap` (dry run) | 1,1 · 4 → 2,2 · 5 | unchanged | | `os storage orphans` | 1,1 · 4 → 2,2 · 5 | unchanged | | `os secret orphans --delete`, declined at the prompt (under a pty) | 1,1 · 5 → 2,2 · 6 | unchanged | | the same run, with `sys_setting` dropped first | 1,1 · 6 → 2,2 · 8 | `sys_setting` re-created by the plain boot | | `os migrate recorded-by --apply`, declined | 1,1 · 4 → 2,2 · 5 | unchanged | | `os migrate resume --run run_23622`, declined | 1,1 · 4 → 2,2 · 5 | unchanged | | `os secret rewrap --apply` (its prompt was not reached: every row was "left") | 1,1 · 5 → 2,2 · 6 | unchanged | The before runs were at `4c21e57169` and the after runs at `585275807`. Both measured the same header movement. Only the printed lines differ. H2 holds by reading: `recorded-by` and `resume` probe no occupancy, and they print their line on every dialect. So the base sentence is corrected on every dialect, and the note is added only when the boot's primary datasource is a SQLite file. ## The texts | # | Where | FROM | TO | |---|---|---|---| | 1 | `migrate/security-catalog-overlays.ts:198` | "The preview below writes nothing." | "The preview below writes no row or table." plus the note. It prints only on a busy SQLite file, so the note is always there | | 2 | `migrate/recorded-by.ts:158` | "Nothing was changed. Re-run with --apply to rewrite them to NULL." | "No row or table was changed. Re-run with --apply to rewrite them to NULL." plus the note on SQLite | | 3 | `migrate/resume.ts:172` | "Nothing was changed. Re-run with --run ID to act on one." | "No row or table was changed. Re-run with --run ID to act on one." plus the note on SQLite | | 4 | `secret/orphans.ts:332` | "Report only — nothing was written or deleted." | "Report only — no row or table was written or deleted." plus the note on SQLite, before the elapsed time | | 5 | `secret/rewrap.ts:316` | "Dry run — nothing was written." | "Dry run — no row or table was written." plus the note on SQLite, before the elapsed time | | 6 | `storage/orphans.ts:61` (help) | "… Writes nothing, tombstones nothing, deletes nothing." | "… Writes no row or table, tombstones nothing, deletes nothing." Help text carries no note | | 7 | `migrate/plan.ts:210` (a comment) | "a plan writes nothing" | "a plan writes no row or table" | | 8 | `secret/orphans.ts:372` (`--delete` declined) | "Aborted — nothing was written or deleted." | "Aborted — no export was written and no row was deleted." plus the note on SQLite | In row 3, ID stands for the placeholder the command prints, `id` inside angle brackets. **Row 8 claims no row, not "no row or table".** Its boot is the plain write-mode boot, which performs schema sync. With `sys_setting` dropped, that boot re-created the table before the prompt, and the old line still said "nothing was written". The new line names the export and the rows, which is what the prompt guards. **Folded in under the bounded in-place rule**, all in files on this claim's surface. Each is the same sentence on the same mechanism, mechanical, in the same gate family, and measured: - the same declined-prompt line after a plain boot: `migrate/recorded-by.ts:179` and `migrate/resume.ts:218` ("Aborted — nothing changed." becomes "Aborted — no row was changed.", plus the note on SQLite, measured above), and `secret/rewrap.ts:294` ("Aborted — nothing was opened or written." becomes "Aborted — no row was opened or written.", plus the note on SQLite; its boot was measured, and its line is pinned against a replaced boot); - the help and flag descriptions of the read-only default modes, the same class as row 6: `secret/orphans.ts:98` and `:115`, and `secret/rewrap.ts:69` and `:85` ("writes nothing" becomes "writes no row or table"). `security-catalog-overlays.ts:222` ("Aborted — nothing deleted.") is kept. It prints before that command boots, so it is true as written. **The SQLite test.** Rows 2 to 5 and the declined lines ask the booted stack: `describeDriverSqliteFile(stack.driver?.config) !== undefined`. This is the existing `utils/connection-display.ts` helper, read and not edited. `SqlDriver` keeps the declared target in `config`, even where a read-only boot opened an absent file as `:memory:`, so this test gives the same answer as PR #23608's `occupancy.status !== 'not_applicable'` for the ceremony. PostgreSQL and MySQL answer `undefined`. `utils/sqlite-occupancy.ts` is read, not edited. ## Pins (one per printed text) - **Real rollback-journal file:** `secret/orphans.journal-mode.integration.test.ts`. It reads the header (1,1 → 2,2), checks that schema and rows are unchanged, and checks the corrected line and the note. It is where the SQLite test meets a real driver config. The oclif config is loaded in `beforeAll`, so the case itself runs in 1.73 s. - **Printed line, with the boot replaced** (a SQLite config gets the note; a PostgreSQL config gets the base sentence and no note): `migrate/recorded-by.test.ts` and the new `migrate/resume.test.ts` (dry run or list, and the declined prompt); `secret/orphans.guards.test.ts` and `secret/rewrap.guards.test.ts` (report or dry run, the declined prompt, and help and flag text); the new `migrate/security-catalog-overlays.preview-note.test.ts` (a busy probe); the new `storage/orphans.test.ts` (help). - `migrate/data-commands.absent-database.integration.test.ts`: its two `humanEmpty` regexes now read the corrected sentences. **Ablation**, at `7fe367ec7`, through `scripts/ablation-replace.mjs`, with an EXIT/INT/TERM trap. No rebuild was needed: the subject is the CLI's own `src`, reached by relative imports. I put back the old `secret/orphans.ts` report line (blob `cc1721741181` → `71acbbdcf46c`, anchor count 1 → 0). Result: 3 failed, 13 passed. The SQLite and PostgreSQL guards cases went red, receiving "Report only — nothing was written or deleted.", and so did the real-file case, after its header assertion had passed. Restore: the blob equals HEAD (`cc1721741181`) and `git diff HEAD` is empty. The direction was red, as expected. ## Verification (head `dbaa3358a`) - `@objectstack/cli` build and `typecheck` (`tsc --noEmit` plus `check:test-typecheck`, held at 3 files, 28 errors and 6 signatures): exit 0. `tsc --listFiles` puts all 8 touched test files in the 1578-file program. - CLI unit project (`vitest run --project unit --maxWorkers=2`): Test Files 295 passed (295), Tests 4362 passed (4362), VERDICT command-exit 0. That run was at `1a2c2aa63`. The two unit files changed since then were re-run by path at `dbaa3358a`: 2 files, 10 tests passed. - Integration files this diff touches, run by path: `data-commands.absent-database`, both guards files and the real-file pin. Test Files 4 passed (4), Tests 66 passed (66), VERDICT 0. The rest of the integration layer is declared to CI. - `eslint packages/cli --no-inline-config`: exit 0, 696 files, 0 errors, 0 warnings. - `dispatch-gates --commands --repo objectstack-ai/objectstack` derived 64 families at `dbaa3358a` (16 paths, 862 lines). All 64 ran there, with each exit code captured before any pipe: all exited 0. `--ran` printed "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero from 64 recorded exit codes. Among them, `check-empty-changeset --base origin/main` printed "No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)". This PR adds a changeset and edits none, so the designed red PR #23608 met does not arise. Two first runs, at `1a2c2aa63`, were red because of my new doubles: `check:engine-double-contract` (write methods without the dispatch predicate) and `check:objectql-double-limit` (a find that ignored the caller's limit). `dbaa3358a` fixed both: the doubles now only read. `check:dual-build-cjs-loads` and `check:i18n-coverage` first exited 3 because build output was missing. After a build of the packages they read, both exit 0. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **The same false sentence, outside this claim's file surface**, measured on the same runs. I left these alone and named them for the seat as this family's remainder: - `os storage orphans` still prints "This pass is READ-ONLY: nothing was written, tombstoned or deleted." That line comes from `services/service-storage/src/stranded-orphan-inventory.ts:413`. - `os secret rewrap`'s dry run still prints "Dry run: every attributed row was opened, re-sealed and verified in memory, and nothing was written." That is `REWRAP_NOTES.dryRun` in `cli/src/utils/sys-secret-rewrap.ts:504`, which is in `--json` too. - `os migrate summary-nulls`' dry run still prints "Dry run — nothing was written. Re-run with --apply to backfill." That line comes from `objectql/src/summary-backfill.ts:534`. #23562's dev measured this boot's switch. - **Developer comments not changed** (not operator text; the ruling named only `plan.ts:210`): `recorded-by.ts:62` ("… and writes nothing"), `secret/rewrap.ts:44` ("… and writes nothing"), and `secret/orphans.ts:221` ("this report promises to write nothing", about key material). - **Design, not a finding:** a write-mode boot (`--apply`, `--run`, `--delete`) runs schema sync before its prompt, so a declined run can leave a table it created. The sentences now claim only rows. - **No merge of `origin/main` before opening.** The two commits since the base (`c03077e29`, `9e0945cf4`) touch the runtime sandbox and metadata-protocol draft saves. They touch nothing in `cli`, the boot or the drivers. This is a declared narrowing; CI's merge-ref run covers the joint tree. Built by `session_01FLwgbTSmGMrqsXkBDsiwfG`, the dev for `domain:cli` seat 1's claim `6200653126`. --- _Generated by [Claude Code](https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6ee483c commit fdbcec0

16 files changed

Lines changed: 839 additions & 21 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate security-catalog-overlays`, `recorded-by` and `resume`, `os secret orphans` and `rewrap`, and `os storage orphans` say they write no row or table, and that opening a SQLite file can still switch its journal mode, instead of saying they write nothing
6+
7+
**What was wrong.** Each of these commands boots through the SQL driver before it prints, and the driver's connect switches a SQLite file to the journal mode it asks for (WAL, unless `OS_DATABASE_SQLITE_JOURNAL_MODE=delete` is set in the command's own environment or the project's `.env`). Measured on a file still on a rollback journal, every command's default mode moved the header's journal-mode bytes (18 and 19, from 1,1 to 2,2) and its change counter, and changed no row or table, while printing "writes nothing", "nothing was written" or "Nothing was changed". The same held for an `--apply`, `--run` or `--delete` declined at its prompt, whose plain boot can also create a table the database is missing. Nothing was lost.
8+
9+
**What changed.** Each sentence below now claims no row or table (no row, after a declined prompt) and, where the command opened a SQLite file, is followed by the note the read-only `os migrate` arms already carry: "Opening a SQLite file can still switch its journal mode (to WAL by default), which changes its header only." On PostgreSQL or MySQL the sentence ends without it.
10+
11+
- `os migrate security-catalog-overlays` (the preview), beside a process holding the file. FROM: "The preview below writes nothing." TO: "The preview below writes no row or table." and the note.
12+
- `os migrate recorded-by` (the dry run). FROM: "Nothing was changed. Re-run with --apply to rewrite them to NULL." TO: "No row or table was changed. Re-run with --apply to rewrite them to NULL." Its `--apply` declined at the prompt. FROM: "Aborted — nothing changed." TO: "Aborted — no row was changed."
13+
- `os migrate resume` (the list). FROM: `Nothing was changed. Re-run with --run <id> to act on one.` TO: `No row or table was changed. Re-run with --run <id> to act on one.` Its `--run` declined at the prompt. FROM: "Aborted — nothing changed." TO: "Aborted — no row was changed."
14+
- `os secret orphans` (the report). FROM: "Report only — nothing was written or deleted." TO: "Report only — no row or table was written or deleted." The note sits before the elapsed time. Its `--delete` declined at the prompt. FROM: "Aborted — nothing was written or deleted." TO: "Aborted — no export was written and no row was deleted." Its help description. FROM: "… Report-only by default: it writes nothing and deletes nothing." TO: "… it writes no row or table and deletes nothing." Its `--delete` flag description. FROM: "(default is a report that writes nothing)" TO: "(default is a report that writes no row or table)".
15+
- `os secret rewrap` (the dry run). FROM: "Dry run — nothing was written." TO: "Dry run — no row or table was written." The note sits before the elapsed time. Its `--apply` declined at the prompt. FROM: "Aborted — nothing was opened or written." TO: "Aborted — no row was opened or written." Its help description. FROM: "… A dry run by default: it writes nothing without --apply." TO: "… it writes no row or table without --apply." Its `--apply` flag description. FROM: "(default is a dry run that writes nothing)" TO: "(default is a dry run that writes no row or table)".
16+
- `os storage orphans`, its help description. FROM: "… Writes nothing, tombstones nothing, deletes nothing." TO: "… Writes no row or table, tombstones nothing, deletes nothing." Help text carries no note.
17+
18+
**Unchanged.** No exit code, `--json` document, flag, prompt or refusal changes; the note is printed in the text output only. What the driver does on connect is unchanged: once a file is in the mode the driver asks for, these runs leave it byte-identical.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,7 @@ const DOORS: readonly Door[] = [
503503
expect(family.status).toBe('enumerated');
504504
}
505505
},
506-
humanEmpty: /Report only — nothing was written or deleted/,
506+
humanEmpty: /Report only — no row or table was written or deleted/,
507507
tables: ['sys_secret', 'sys_setting', 'sys_metadata'],
508508
work: (doc) => expect(doc.plan.counts.total).toBe(1),
509509
},
@@ -516,7 +516,7 @@ const DOORS: readonly Door[] = [
516516
expect(family.status).toBe('enumerated');
517517
}
518518
},
519-
humanEmpty: /Dry run — nothing was written/,
519+
humanEmpty: /Dry run — no row or table was written/,
520520
tables: ['sys_secret', 'sys_setting', 'sys_metadata'],
521521
work: (doc) => expect(doc.report.counts.total).toBe(1),
522522
},

‎packages/cli/src/commands/migrate/plan.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ export default class MigratePlan extends Command {
207207
// are separate CUSTOMERS. The HARD gate runs at app install; this covers
208208
// the two populations it cannot reach (installs predating the gate, and
209209
// environments whose posture changed after install). Never fatal: a plan
210-
// writes nothing, and this reports a metadata decision, not drift.
210+
// writes no row or table, and this reports a metadata decision, not drift.
211211
const uniqueScopeAdvisory = postureGatesGlobalUniques(resolveTenancyPosture())
212212
? collectGlobalUniques(stack.allObjects())
213213
: [];

‎packages/cli/src/commands/migrate/recorded-by.test.ts‎

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,43 @@
88
* What is pinned here is the thing a unit test of the plan cannot see: that
99
* this subcommand honours #2186 — a bare `os migrate <topic>` must never
1010
* mutate the database by surprise, so writing is opt-in behind `--apply`.
11+
*
12+
* And what its human face says about the database once the boot has run.
13+
* Either mode's connect can switch a SQLite file's journal mode, which
14+
* rewrites its header and no row, so neither the dry run nor a declined
15+
* `--apply` says that nothing was changed; each names the switch on a SQLite
16+
* target, and only there. The boot is replaced by a stack over a fake engine.
1117
*/
1218

13-
import { describe, it, expect } from 'vitest';
19+
import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from 'vitest';
20+
import { Config } from '@oclif/core';
21+
import { fileURLToPath } from 'node:url';
1422
import MigrateRecordedBy from './recorded-by.js';
23+
import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js';
1524
import {
1625
RECORDED_BY_SENTINEL,
1726
RECORDED_BY_SENTINEL_PLAN_ID,
1827
createRecordedBySentinelPlan,
1928
} from '@objectstack/metadata-protocol';
2029

30+
const boot = vi.hoisted(() => vi.fn());
31+
vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: boot }));
32+
// The plugins the real boot composes; the replaced boot never reads them.
33+
vi.mock('../../utils/data-migration-plugins.js', () => ({ buildDataMigrationPlugins: async () => [] }));
34+
// The `--apply` confirmation, answered without a terminal: `undefined` leaves
35+
// readline as it is.
36+
const prompt = vi.hoisted(() => ({ answer: undefined as string | undefined }));
37+
vi.mock('node:readline', async (importOriginal) => {
38+
const real = await importOriginal<typeof import('node:readline')>();
39+
return {
40+
...real,
41+
createInterface: ((...args: Parameters<typeof real.createInterface>) => (prompt.answer === undefined
42+
? real.createInterface(...args)
43+
: { question: (_q: string, answer: (a: string) => void) => answer(prompt.answer as string), close: () => {} }
44+
)) as typeof real.createInterface,
45+
};
46+
});
47+
2148
describe('os migrate recorded-by', () => {
2249
it('is a dry run by default — --apply is opt-in (#2186)', () => {
2350
expect(MigrateRecordedBy.flags.apply.default).toBe(false);
@@ -41,3 +68,91 @@ describe('os migrate recorded-by', () => {
4168
expect(RECORDED_BY_SENTINEL).toBe('system');
4269
});
4370
});
71+
72+
const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
73+
74+
// oclif reads the package's config (its whole command table) on a first run;
75+
// it is paid here, outside the cases.
76+
let oclifConfig: Config;
77+
beforeAll(async () => {
78+
oclifConfig = await Config.load({ root: CLI_ROOT });
79+
}, 60_000);
80+
81+
const SQLITE_CONFIG = { client: 'better-sqlite3', connection: { filename: '/srv/app/.objectstack/data/objectstack.db' } };
82+
const POSTGRES_CONFIG = { client: 'pg', connection: 'postgres://db.example.com:5432/app' };
83+
84+
/**
85+
* A booted stack over one history row holding the sentinel. The engine can
86+
* only read: a write the command attempted would throw, and the run with it.
87+
*/
88+
function stackOver(config: Record<string, unknown>): void {
89+
const rows = [{ id: 'h_sentinel', recorded_by: RECORDED_BY_SENTINEL }];
90+
const engine = {
91+
find: async (_object: string, query: { where?: Record<string, unknown>; limit?: number }) => {
92+
const matched = rows.filter((row) =>
93+
Object.entries(query.where ?? {}).every(([key, value]) => (row as Record<string, unknown>)[key] === value));
94+
return typeof query?.limit === 'number' ? matched.slice(0, query.limit) : matched;
95+
},
96+
};
97+
boot.mockResolvedValue({
98+
driver: { config },
99+
kernel: { getService: (name: string) => (name === 'objectql' ? engine : undefined) },
100+
tableAbsent: () => false,
101+
shutdown: async () => {},
102+
});
103+
}
104+
105+
async function runHuman(argv: string[]): Promise<string[]> {
106+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
107+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
108+
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
109+
const savedExitCode = process.exitCode;
110+
try {
111+
await MigrateRecordedBy.run(argv, oclifConfig);
112+
return log.mock.calls.map((c) => c.map(String).join(' '));
113+
} finally {
114+
process.exitCode = savedExitCode;
115+
for (const spy of [log, warn, error]) spy.mockRestore();
116+
}
117+
}
118+
119+
describe('os migrate recorded-by — the human face names no change it cannot vouch for', () => {
120+
let savedIsTTY: PropertyDescriptor | undefined;
121+
beforeEach(() => {
122+
savedIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
123+
});
124+
afterEach(() => {
125+
prompt.answer = undefined;
126+
boot.mockReset();
127+
if (savedIsTTY) Object.defineProperty(process.stdin, 'isTTY', savedIsTTY);
128+
else delete (process.stdin as { isTTY?: boolean }).isTTY;
129+
});
130+
131+
it('the dry run claims no row or table, and names the journal-mode switch on a SQLite file', async () => {
132+
stackOver(SQLITE_CONFIG);
133+
const lines = await runHuman([]);
134+
expect(boot).toHaveBeenCalledWith(expect.objectContaining({ deferSchemaDdl: true, readOnlyProbe: true }));
135+
const closing = lines.find((line) => line.includes('Re-run with --apply'));
136+
expect(closing, lines.join('\n')).toContain(
137+
`No row or table was changed. Re-run with --apply to rewrite them to NULL. ${JOURNAL_MODE_SWITCH_NOTE}`,
138+
);
139+
expect(lines.join('\n')).not.toContain('Nothing was changed');
140+
}, 60_000);
141+
142+
it('on a target with no file header (PostgreSQL) the dry run claims no row or table and names no switch', async () => {
143+
stackOver(POSTGRES_CONFIG);
144+
const lines = await runHuman([]);
145+
const closing = lines.find((line) => line.includes('Re-run with --apply'));
146+
expect(closing, lines.join('\n')).toContain('No row or table was changed. Re-run with --apply to rewrite them to NULL.');
147+
expect(lines.join('\n')).not.toContain(JOURNAL_MODE_SWITCH_NOTE);
148+
}, 60_000);
149+
150+
it('an --apply declined at the prompt claims no row changed, and names the switch on a SQLite file', async () => {
151+
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
152+
prompt.answer = 'n';
153+
stackOver(SQLITE_CONFIG);
154+
const lines = await runHuman(['--apply']);
155+
const aborted = lines.find((line) => line.includes('Aborted'));
156+
expect(aborted, lines.join('\n')).toContain(`Aborted — no row was changed. ${JOURNAL_MODE_SWITCH_NOTE}`);
157+
}, 60_000);
158+
});

‎packages/cli/src/commands/migrate/recorded-by.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030
} from '../../utils/format.js';
3131
import { bootSchemaStack } from '../../utils/schema-migrate.js';
3232
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
33+
import { describeDriverSqliteFile } from '../../utils/connection-display.js';
34+
import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js';
3335

3436
async function confirm(question: string): Promise<boolean> {
3537
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
@@ -116,6 +118,17 @@ export default class MigrateRecordedBy extends Command {
116118
}
117119

118120
try {
121+
// [#23622] Either mode's connect may have switched a SQLite file's
122+
// journal mode (`SqlDriver.applySqliteJournalMode`, one rule for every
123+
// connect), which rewrites the file header and no row. So no line below
124+
// says that nothing changed: the read-only mode claims no row or table
125+
// (its boot defers DDL), an abort after the plain boot claims no row
126+
// (that boot may have created a missing table), and on a SQLite file
127+
// each says what the connect may still have changed. A target that is
128+
// no SQLite file (PostgreSQL, MySQL) has no header to switch.
129+
const journalNote = describeDriverSqliteFile(stack.driver?.config) !== undefined
130+
? ` ${JOURNAL_MODE_SWITCH_NOTE}`
131+
: '';
119132
const engine: IObjectQLEngine = stack.kernel.getService('objectql');
120133
if (typeof engine?.find !== 'function') {
121134
throw new Error('No ObjectQL engine on this stack — cannot read sys_metadata_history.');
@@ -155,7 +168,7 @@ export default class MigrateRecordedBy extends Command {
155168
return;
156169
}
157170
printWarning(`${pending.length} sys_metadata_history row(s) hold recorded_by = '${RECORDED_BY_SENTINEL}'.`);
158-
printInfo("Nothing was changed. Re-run with --apply to rewrite them to NULL.");
171+
printInfo(`No row or table was changed. Re-run with --apply to rewrite them to NULL.${journalNote}`);
159172
return;
160173
}
161174

@@ -176,7 +189,7 @@ export default class MigrateRecordedBy extends Command {
176189
return;
177190
}
178191
const ok = await confirm(chalk.bold(`\n${summary}? [y/N] `));
179-
if (!ok) { printInfo('Aborted — nothing changed.'); return; }
192+
if (!ok) { printInfo(`Aborted — no row was changed.${journalNote}`); return; }
180193
}
181194

182195
if (!flags.json) printStep('Converting…');

0 commit comments

Comments
 (0)