Skip to content

Commit 6a65958

Browse files
committed
test(cli): pin the ownership plan arm's sentences on a rollback-journal SQLite file
A real-boot case switches the fixture's header to WAL and leaves its rows, and the success line names the switch instead of "nothing in the database was changed"; the occupancy case pins the busy warning's new sentence. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9474bc6 commit 6a65958

2 files changed

Lines changed: 153 additions & 2 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate organization-ownership` (the plan) against a real SQLite file
5+
* still on a rollback journal, through the real read-only boot: what the plan
6+
* arm says about the database stays true for that file.
7+
*
8+
* The boot is read-only (no DDL, no seed, SELECT statements only), but the SQL
9+
* driver's connect switches a file still on a rollback journal to WAL — its
10+
* rule for every connect, read-only boots included — which rewrites the file
11+
* header and no row. So the arm says that no row or table was changed and names
12+
* the journal-mode switch (`JOURNAL_MODE_SWITCH_NOTE`); it never says that
13+
* nothing in the database was changed.
14+
*
15+
* The header is read before and after, so the case cannot pass over a file
16+
* that was never switched. If a read-only boot ever stops switching it, the
17+
* header assertion goes red: the note is then no longer needed, and this pin
18+
* goes with it.
19+
*/
20+
21+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
22+
import Database from 'better-sqlite3';
23+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
24+
import { tmpdir } from 'node:os';
25+
import { join } from 'node:path';
26+
import { fileURLToPath } from 'node:url';
27+
import { isExitSignal } from '../../utils/format.js';
28+
import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js';
29+
30+
// Pay the first transform of the dist-resolved workspace deps the boot and the
31+
// plan reach through dynamic `import()`s at MODULE LOAD, not inside a case.
32+
import '@objectstack/runtime';
33+
import '@objectstack/metadata-protocol';
34+
35+
type Command = (typeof import('./organization-ownership.js'))['default'];
36+
let MigrateOrganizationOwnership: Command;
37+
38+
// The command's module graph is large, and oclif reads the package's config on
39+
// the first run: both are paid outside the case timeout.
40+
beforeAll(async () => {
41+
({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
42+
}, 60_000);
43+
44+
const CASE_TIMEOUT_MS = 60_000;
45+
const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
46+
47+
/** The driver's journal-mode opt-out: unset, so the driver asks for its default, WAL. */
48+
const JOURNAL_MODE_ENV = 'OS_DATABASE_SQLITE_JOURNAL_MODE';
49+
50+
let dir: string;
51+
let file: string;
52+
let savedCwd: string;
53+
let savedJournalMode: string | undefined;
54+
55+
beforeEach(() => {
56+
savedCwd = process.cwd();
57+
savedJournalMode = process.env[JOURNAL_MODE_ENV];
58+
delete process.env[JOURNAL_MODE_ENV];
59+
dir = mkdtempSync(join(tmpdir(), 'os-ownership-journal-mode-'));
60+
file = join(dir, 'fixture.db');
61+
const seed = new Database(file);
62+
seed.pragma('journal_mode = delete');
63+
seed.exec(`
64+
CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT);
65+
INSERT INTO sys_organization VALUES ('org_default', 'default');
66+
`);
67+
seed.close();
68+
// A project directory with no config and no `.env`: the boot reads the fixture alone.
69+
process.chdir(dir);
70+
});
71+
72+
afterEach(() => {
73+
process.chdir(savedCwd);
74+
if (savedJournalMode === undefined) delete process.env[JOURNAL_MODE_ENV];
75+
else process.env[JOURNAL_MODE_ENV] = savedJournalMode;
76+
rmSync(dir, { recursive: true, force: true });
77+
});
78+
79+
/** The file format's read and write version bytes: 1 and 1 on a rollback journal, 2 and 2 in WAL. */
80+
function journalBytes(): [number, number] {
81+
const header = readFileSync(file).subarray(0, 100);
82+
return [header[18], header[19]];
83+
}
84+
85+
function rows(): unknown {
86+
const db = new Database(file, { readonly: true });
87+
try {
88+
return db.prepare('SELECT * FROM sys_organization ORDER BY id').all();
89+
} finally {
90+
db.close();
91+
}
92+
}
93+
94+
/** Run the command with its output captured and its exit trapped. */
95+
async function run(argv: string[]): Promise<{ lines: string; exitCode: number }> {
96+
const savedExit = process.exitCode;
97+
vi.spyOn(process.stdout, 'write').mockImplementation(((_chunk: unknown, ...rest: unknown[]) => {
98+
const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
99+
if (cb) cb();
100+
return true;
101+
}) as typeof process.stdout.write);
102+
vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write);
103+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
104+
vi.spyOn(console, 'warn').mockImplementation(() => {});
105+
vi.spyOn(console, 'error').mockImplementation(() => {});
106+
let thrownExit: number | undefined;
107+
try {
108+
try {
109+
await MigrateOrganizationOwnership.run(argv, { root: CLI_ROOT });
110+
} catch (error) {
111+
if (!isExitSignal(error)) throw error;
112+
thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit;
113+
}
114+
return {
115+
lines: log.mock.calls.map((c) => c.map(String).join(' ')).join('\n'),
116+
exitCode: thrownExit ?? (process.exitCode as number | undefined) ?? 0,
117+
};
118+
} finally {
119+
process.exitCode = savedExit;
120+
vi.restoreAllMocks();
121+
}
122+
}
123+
124+
describe('os migrate organization-ownership (plan) on a SQLite file still on a rollback journal', () => {
125+
it('the boot switches the header to WAL and no row; the plan says no row or table changed and names the switch', async () => {
126+
expect(journalBytes(), 'the fixture is not on a rollback journal: nothing here could be switched').toEqual([1, 1]);
127+
const before = rows();
128+
const out = join(dir, 'plan.json');
129+
130+
const { lines, exitCode } = await run(['--out', out, '--database-url', `file:${file}`]);
131+
132+
expect(exitCode, lines).toBe(0);
133+
expect(existsSync(out)).toBe(true);
134+
expect(journalBytes()).toEqual([2, 2]);
135+
expect(rows()).toEqual(before);
136+
const success = lines.split('\n').find((line) => line.includes(`Plan written to ${out}`));
137+
expect(success, lines).toBeDefined();
138+
expect(success).toContain('no row or table in the database was changed.');
139+
expect(success).toContain(JOURNAL_MODE_SWITCH_NOTE);
140+
expect(lines).not.toContain('nothing in the database was changed');
141+
}, CASE_TIMEOUT_MS);
142+
});

‎packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
* holding the SQLite file open, `--apply` is refused before the boot, inside
77
* the command's own refusal envelope, and the refusal names the stop remedy
88
* alone: the ceremony takes no `--force`, so a hint prescribing one would send
9-
* the operator to a flag the command rejects. The plan writes nothing, so it
10-
* warns and runs, as every sibling's read-only arm does.
9+
* the operator to a flag the command rejects. The plan writes no row or table,
10+
* so it warns and runs, as every sibling's read-only arm does, and the warning
11+
* names what its connect can still change on a SQLite file: the journal mode.
1112
*
1213
* The holder is the one `plan.reads-env-files.integration.test.ts` uses: a
1314
* better-sqlite3 connection on a WAL database, attached and idle, which is
@@ -24,6 +25,7 @@ import { join } from 'node:path';
2425
import { fileURLToPath } from 'node:url';
2526
import { isExitSignal } from '../../utils/format.js';
2627
import { OCCUPANCY_STOP_HINT } from '../../utils/migrate-occupancy-gate.js';
28+
import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js';
2729

2830
// Pay the first transform of the dist-resolved workspace deps the probe and
2931
// the plan reach through dynamic `import()`s at MODULE LOAD, not inside a case.
@@ -189,6 +191,13 @@ describe('os migrate organization-ownership (plan) — the occupancy gate', () =
189191
expect(boot).toHaveBeenCalledTimes(1);
190192
expect(lines).toContain(file);
191193
expect(existsSync(out)).toBe(true);
194+
// The warning claims no row or table, and names the journal-mode switch the
195+
// boot's connect can still make; it never says the plan writes nothing.
196+
const warning = lines.split('\n').find((line) => line.includes(`${file} is in use`));
197+
expect(warning, lines).toBeDefined();
198+
expect(warning).toContain('The plan below writes no row or table');
199+
expect(warning).toContain(JOURNAL_MODE_SWITCH_NOTE);
200+
expect(lines).not.toContain('writes nothing');
192201
}, CASE_TIMEOUT_MS);
193202

194203
it('beside a holder, --json: the plan document is unchanged — no warning, no occupancy field', async () => {

0 commit comments

Comments
 (0)