Skip to content

Commit c4adb86

Browse files
committed
fix(cli): four more read-only sentences name what they vouch for, and the journal-mode switch on a SQLite file
os migrate plan (the unmanaged-table section and the declaration boot's write-guard note), a refused summary-nulls scope, and the rewrap "Re-running is safe" note stop saying nothing was written. On a SQLite target each line that makes the claim is followed by JOURNAL_MODE_SWITCH_NOTE in the text output. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
1 parent bb14934 commit c4adb86

17 files changed

Lines changed: 477 additions & 29 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/objectql': patch
4+
---
5+
6+
Clause-②: no
7+
8+
fix(cli): `os migrate plan`, a refused `os migrate summary-nulls --recompute-undefined-on-empty` name and every `os secret rewrap` run say what they vouch for, and that opening a SQLite file can still switch its journal mode, instead of saying nothing was written
9+
10+
**What was wrong.** Each of these runs 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, each run 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 "a plan writes nothing", "nothing was written" or "a finished run writes nothing". Nothing was lost.
11+
12+
**What changed.** Each sentence below now names what it vouches for. On a SQLite file, its line is followed by the note the other read-only 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 line ends without it.
13+
14+
- `os migrate plan`, the unmanaged-table section. FROM: "They are reported for information only — nothing here drops them, and a plan writes nothing." TO: "… and a plan writes no row or table." and the note.
15+
- `os migrate plan`, the declaration boot's note when a plugin's write was refused. FROM: "Refused N write(s) during the declaration boot — a plan writes nothing: …" TO: "… — a plan writes no row or table: …", and the note at the end of that note's line.
16+
- `os migrate plan`, the same note when a raw command was forwarded. FROM: "If those commands only read, nothing was written; if one wrote, it landed." TO: "If those commands only read, no row or table was written; if one wrote, it landed.", and the note at the end of that note's line.
17+
- `os migrate summary-nulls --recompute-undefined-on-empty` naming a roll-up the run cannot find (`backfillSummaryNulls`, `@objectstack/objectql`), in either mode. FROM: "Refused before any row was read; nothing was written." TO: "Refused before the walk read or wrote any row." and the note. Under `--apply` the boot before it can also create a table a never-started database lacks, so the sentence speaks for the walk only.
18+
- `os secret rewrap`, the "Re-running is safe" note, in both modes and in `--json` `report.notes`. FROM: "… and a finished run writes nothing." TO: "… and a finished run writes no sys_secret row." Under `--apply` the note follows it; a dry run's closing line already carries it. A finished `--apply` run on a database that has never been started also creates its tables, so the sentence names the rows the re-wrap writes.
19+
20+
**Unchanged.** No exit code, flag, prompt, refusal or export signature changes, and no `--json` document gains a field: the `--json` documents carry the changed sentences, and the note is printed in the text output only. What the driver does on connect is unchanged.

‎content/docs/deployment/cli.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -649,7 +649,9 @@ What a run guarantees:
649649
about its code-declared datasources leaves that family a gap. `--apply` then refuses
650650
and names the family.
651651
- **Resumable.** A row already sealed under the current binding is skipped as done. A run
652-
that stopped part-way finishes the rest when re-run, and a finished run writes nothing.
652+
that stopped part-way finishes the rest when re-run, and a finished run writes no
653+
`sys_secret` row. On a SQLite file, opening it can still switch its journal mode (to
654+
WAL by default), which changes its header only.
653655
- **Safe against a live deployment.** Each row is written in one statement, and only if
654656
it still holds the ciphertext the run read. A row a producer changed during the run is
655657
not overwritten. It is counted, and a re-run picks it up.
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate plan` — what its human face says about the database it read.
5+
*
6+
* A plan boots read-only (schema DDL deferred, no file brought into
7+
* existence), but the SQL driver's connect still switches a SQLite file to the
8+
* journal mode it asks for, which rewrites the file header and no row. So no
9+
* line a plan prints says that it wrote nothing: the lines that vouch for the
10+
* database claim no row or table, and on a SQLite target each is followed by
11+
* the note naming the switch. Three texts make that claim:
12+
*
13+
* - the unmanaged-table section's closing line (`renderUnmanagedTables`);
14+
* - the declaration boot's write-guard note when a plugin's contract write
15+
* was refused ("— a plan writes no row or table");
16+
* - the same note when a raw command was forwarded ("If those commands only
17+
* read, no row or table was written").
18+
*
19+
* The two guard notes are the REAL guard's text: a `createDeclarationBootWriteGuard`
20+
* is armed over a fake driver, made to refuse one write or forward one raw
21+
* read, and disarmed. Only the boot, the occupancy probe and the unmanaged-table
22+
* READ are replaced; the command's control flow and the section's renderer run
23+
* for real. The `--json` document carries the same lines and no note.
24+
*/
25+
26+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
27+
import { dirname, resolve } from 'node:path';
28+
import { fileURLToPath } from 'node:url';
29+
import { stripVTControlCharacters } from 'node:util';
30+
import MigratePlan from './plan.js';
31+
import { bootSchemaStack } from '../../utils/schema-migrate.js';
32+
import { probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
33+
import { collectUnmanagedTables, type UnmanagedTablesReport } from '../../utils/unmanaged-tables.js';
34+
import { createDeclarationBootWriteGuard } from '../../utils/schema-migration-plugins.js';
35+
import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js';
36+
37+
vi.mock('../../utils/schema-migrate.js', async (importOriginal) => ({
38+
...(await importOriginal<typeof import('../../utils/schema-migrate.js')>()),
39+
bootSchemaStack: vi.fn(),
40+
}));
41+
vi.mock('../../utils/migrate-occupancy-gate.js', () => ({ probeMigrationTarget: vi.fn() }));
42+
// The sweep's READ is replaced; its renderer is the real one.
43+
vi.mock('../../utils/unmanaged-tables.js', async (importOriginal) => ({
44+
...(await importOriginal<typeof import('../../utils/unmanaged-tables.js')>()),
45+
collectUnmanagedTables: vi.fn(),
46+
}));
47+
// `run()` ends the process when the plan does (#13027); here it returns instead.
48+
vi.mock('../../utils/one-shot-exit.js', () => ({ exitOneShotCommand: vi.fn(async () => undefined) }));
49+
50+
const HERE = dirname(fileURLToPath(import.meta.url));
51+
const CLI_ROOT = resolve(HERE, '..', '..', '..');
52+
/** oclif builds its whole command table on the first `run()` in a process. */
53+
const RUN_TIMEOUT = 60_000;
54+
55+
const SQLITE_CONFIG = { client: 'better-sqlite3', connection: { filename: '/srv/app/.objectstack/data/objectstack.db' } };
56+
const POSTGRES_CONFIG = { client: 'pg', connection: 'postgres://db.example.com:5432/app' };
57+
58+
const UNMANAGED_LINE =
59+
'They are reported for information only — nothing here drops them, and a plan writes no row or table.';
60+
const ONE_UNMANAGED: UnmanagedTablesReport = {
61+
status: 'read', prefixes: ['sys_'], physicalTables: 3, tables: [{ table: 'sys_legacy_widget' }],
62+
};
63+
const NONE_UNMANAGED: UnmanagedTablesReport = { status: 'read', prefixes: ['sys_'], physicalTables: 3, tables: [] };
64+
65+
/** A driver with its members on the PROTOTYPE, as every real driver's are. */
66+
class FakeDriver {
67+
async create(_object: string, _data: Record<string, unknown>): Promise<Record<string, unknown>> { return {}; }
68+
async execute(_command: unknown): Promise<unknown> { return []; }
69+
}
70+
71+
/** The real guard's note after `act` ran against a driver it armed. */
72+
async function guardNote(act: (driver: FakeDriver) => Promise<unknown>): Promise<string> {
73+
const guard = createDeclarationBootWriteGuard();
74+
const driver = new FakeDriver();
75+
await (guard.plugin as { init(ctx: unknown): Promise<void> }).init({
76+
getServices: () => new Map<string, unknown>([['driver.default', driver]]),
77+
});
78+
await act(driver);
79+
const note = guard.disarm();
80+
expect(note).not.toBeNull();
81+
return note as string;
82+
}
83+
84+
let warn: ReturnType<typeof vi.spyOn>;
85+
beforeEach(() => {
86+
// The guard warns once per refused or forwarded call; not under test here.
87+
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
88+
vi.mocked(probeMigrationTarget).mockResolvedValue({ status: 'free' } as never);
89+
});
90+
afterEach(() => {
91+
warn.mockRestore();
92+
vi.mocked(bootSchemaStack).mockReset();
93+
vi.mocked(collectUnmanagedTables).mockReset();
94+
});
95+
96+
/** Boot onto a primary driver with `config`, composing `notes`. */
97+
function wireBoot(config: Record<string, unknown>, notes: string[], unmanaged: UnmanagedTablesReport): void {
98+
vi.mocked(bootSchemaStack).mockResolvedValue({
99+
// The primary datasource's driver, as `SchemaStack.driver` carries it.
100+
driver: { config },
101+
drivers: [],
102+
dbLabel: 'test database',
103+
dbSource: { kind: 'flag' },
104+
schemaSync: 'deferred',
105+
telemetryDatabase: null,
106+
managedTableCount: 3,
107+
pendingSchemaWork: [],
108+
detectManagedDrift: async () => [],
109+
allObjects: () => [],
110+
composition: {
111+
plugins: [],
112+
hostConfigPath: '/srv/app/objectstack.config.ts',
113+
hostConfigLoaded: true,
114+
hostConfigError: null,
115+
servedBoot: { mirrored: false, reason: 'not-composed' },
116+
notes,
117+
coverage: null,
118+
},
119+
shutdown: vi.fn(async () => {}),
120+
} as never);
121+
vi.mocked(collectUnmanagedTables).mockResolvedValue(unmanaged);
122+
}
123+
124+
/** One human-mode plan; every line `console.log` received, colour stripped. */
125+
async function runHuman(): Promise<string[]> {
126+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
127+
try {
128+
await MigratePlan.run([], { root: CLI_ROOT });
129+
return log.mock.calls
130+
.map((c: unknown[]) => stripVTControlCharacters(c.map(String).join(' ')))
131+
.join('\n')
132+
.split('\n');
133+
} finally {
134+
log.mockRestore();
135+
}
136+
}
137+
138+
/** One `--json` plan; the document it emitted. */
139+
async function runJson(): Promise<Record<string, any>> {
140+
const chunks: string[] = [];
141+
// `emitJson` awaits the write's DRAIN callback, so the double invokes it.
142+
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => {
143+
chunks.push(String(chunk));
144+
const done = rest.find((a) => typeof a === 'function') as ((e?: Error | null) => void) | undefined;
145+
done?.(null);
146+
return true;
147+
}) as typeof process.stdout.write);
148+
try {
149+
await MigratePlan.run(['--json'], { root: CLI_ROOT });
150+
} finally {
151+
stdout.mockRestore();
152+
}
153+
return JSON.parse(chunks.join('')) as Record<string, any>;
154+
}
155+
156+
interface Text {
157+
name: string;
158+
/** The composed notes and the sweep's report this case boots with. */
159+
arrange: () => Promise<{ notes: string[]; unmanaged: UnmanagedTablesReport }>;
160+
/** The printed line that carries the claim, without its print indent. */
161+
line: (notes: string[]) => string;
162+
/** The claim as the text spells it. */
163+
claim: string;
164+
}
165+
166+
const TEXTS: Text[] = [
167+
{
168+
name: 'the unmanaged-table section\'s closing line',
169+
arrange: async () => ({ notes: [], unmanaged: ONE_UNMANAGED }),
170+
line: () => UNMANAGED_LINE,
171+
claim: 'a plan writes no row or table',
172+
},
173+
{
174+
name: 'the write guard\'s note on a refused contract write',
175+
arrange: async () => ({
176+
notes: [await guardNote((d) => d.create('sys_metadata', { id: 'probe' }))],
177+
unmanaged: NONE_UNMANAGED,
178+
}),
179+
line: (notes) => notes[0]!,
180+
claim: 'Refused 1 write(s) during the declaration boot — a plan writes no row or table: create() on sys_metadata.',
181+
},
182+
{
183+
name: 'the write guard\'s note on a forwarded raw command',
184+
arrange: async () => ({
185+
notes: [await guardNote((d) => d.execute('SELECT count(*) FROM sys_metadata'))],
186+
unmanaged: NONE_UNMANAGED,
187+
}),
188+
line: (notes) => notes[0]!,
189+
claim: 'If those commands only read, no row or table was written; if one wrote, it landed.',
190+
},
191+
];
192+
193+
describe('os migrate plan — the human face names no write it cannot vouch for', () => {
194+
for (const text of TEXTS) {
195+
it(`${text.name}: on a SQLite file it claims no row or table and is followed by the journal-mode note`, async () => {
196+
const { notes, unmanaged } = await text.arrange();
197+
const expected = text.line(notes);
198+
expect(expected).toContain(text.claim);
199+
expect(expected).not.toContain('nothing was written');
200+
expect(expected).not.toContain('writes nothing');
201+
202+
wireBoot(SQLITE_CONFIG, notes, unmanaged);
203+
const lines = await runHuman();
204+
const printed = lines.filter((l) => l.includes(text.claim));
205+
expect(printed, lines.join('\n')).toEqual([` ${expected} ${JOURNAL_MODE_SWITCH_NOTE}`]);
206+
// Once, after the claim — no other line of this plan carries it.
207+
expect(lines.join('\n').split(JOURNAL_MODE_SWITCH_NOTE)).toHaveLength(2);
208+
}, RUN_TIMEOUT);
209+
210+
it(`${text.name}: on a target with no file header (PostgreSQL) it claims no row or table and names no switch`, async () => {
211+
const { notes, unmanaged } = await text.arrange();
212+
const expected = text.line(notes);
213+
214+
wireBoot(POSTGRES_CONFIG, notes, unmanaged);
215+
const lines = await runHuman();
216+
const printed = lines.filter((l) => l.includes(text.claim));
217+
expect(printed, lines.join('\n')).toEqual([` ${expected}`]);
218+
expect(lines.join('\n')).not.toContain(JOURNAL_MODE_SWITCH_NOTE);
219+
}, RUN_TIMEOUT);
220+
}
221+
222+
it('--json carries the guard\'s note as it is, with no journal-mode note, on a SQLite file', async () => {
223+
const note = await guardNote((d) => d.create('sys_metadata', { id: 'probe' }));
224+
wireBoot(SQLITE_CONFIG, [note], ONE_UNMANAGED);
225+
const payload = await runJson();
226+
expect(payload.composition.notes).toEqual([note]);
227+
expect(payload.unmanagedTables).toEqual(ONE_UNMANAGED);
228+
expect(JSON.stringify(payload)).not.toContain(JOURNAL_MODE_SWITCH_NOTE);
229+
}, RUN_TIMEOUT);
230+
});

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
type UnmanagedTablesReport,
3636
} from '../../utils/unmanaged-tables.js';
3737
import { describeOccupancy, JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js';
38+
import { describeDriverSqliteFile } from '../../utils/connection-display.js';
3839
import {
3940
resolveTenancyPosture,
4041
collectGlobalUniques,
@@ -311,14 +312,30 @@ export default class MigratePlan extends Command {
311312
console.log('');
312313
}
313314

315+
// [#23899] Two kinds of line below claim that this plan wrote no row or
316+
// table: the declaration boot's write-guard note (one of the composition
317+
// notes, when a plugin's write was refused or a raw command forwarded)
318+
// and the unmanaged-table section's closing line. The read-only boot's
319+
// connect may still have switched a SQLite file's journal mode
320+
// (`SqlDriver.applySqliteJournalMode`, its rule for every connect), which
321+
// rewrites the file header and no row, so on a SQLite file each line that
322+
// makes the claim is followed by the note naming the switch. A target
323+
// that is no SQLite file (PostgreSQL, MySQL) has no header to switch. The
324+
// `--json` document above carries the same lines and no note.
325+
const journalNote = describeDriverSqliteFile(stack.driver?.config) !== undefined
326+
? ` ${JOURNAL_MODE_SWITCH_NOTE}`
327+
: '';
328+
const withJournalNote = (line: string): string =>
329+
(line.includes('no row or table') ? `${line}${journalNote}` : line);
330+
314331
printInfo(`Database: ${chalk.white(stack.dbLabel)} ${chalk.dim(`(${describeDatabaseSource(stack.dbSource)})`)}`);
315332
if (stack.telemetryDatabase !== null) printInfo(describeTelemetryDatabase(stack.telemetryDatabase));
316333
if (schemaSyncSkipped) printWarning(SKIPPED_SCHEMA_SYNC_NOTE);
317334
printInfo(`Examined ${chalk.white(String(stack.managedTableCount))} managed table(s).`);
318335
// What the object set was composed from (#12938) — never silent about a
319336
// host config it could not load, and empty (so this block prints nothing)
320337
// when there was no deployment to compose.
321-
for (const note of stack.composition.notes) console.log(chalk.dim(` ${note}`));
338+
for (const note of stack.composition.notes) console.log(chalk.dim(` ${withJournalNote(note)}`));
322339
console.log('');
323340

324341
// [#13204] Its own block, above the drift verdict and never folded into
@@ -329,7 +346,7 @@ export default class MigratePlan extends Command {
329346
const unmanagedLines = renderUnmanagedTables(unmanagedTables);
330347
if (unmanagedLines.length > 0) {
331348
printInfo(unmanagedLines[0]!);
332-
for (const line of unmanagedLines.slice(1)) console.log(chalk.dim(` ${line}`));
349+
for (const line of unmanagedLines.slice(1)) console.log(chalk.dim(` ${withJournalNote(line)}`));
333350
console.log('');
334351
}
335352

0 commit comments

Comments
 (0)