Skip to content

Commit a33f3d5

Browse files
fix(cli): four more read-only sentences name what they vouch for, and the journal-mode switch on a SQLite file (#24053)
Fixes #23899 Clause-②: no The remainder of #23726, by the pattern PR #23608, PR #23725 and PR #23895 set. Four sentences, printed by three commands after their boot, said that nothing was written. On a SQLite file still on a rollback journal, that boot's connect switches the journal mode, which rewrites the file header and no row. Each sentence now names what it vouches for, and on a SQLite target the line that carries 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 the claim's: operator text only. No flag, exit code, prompt, refusal, accepted input or export signature moves, no `--json` document gains or loses a key, and there is no `packages/spec` edit. ## The premise, measured at the door, before and after The real CLI through `packages/cli/bin/run-dev.js` (tsx), with `@objectstack/objectql` rebuilt at each sha. Before: `bb14934cd` (base). After: `c4adb868c` (this head). Every run got its own copy of the fixture and a fresh `OS_HOME`, with the database env vars unset. Header bytes 18 and 19 are the journal mode (1,1 rollback journal; 2,2 WAL); the counter is bytes 24 to 27. "Content" is an md5 over the schema plus every row of every table, read on a connection of my own. - CRM fixture: `examples/app-crm` compiled with `os build`, provisioned by `os migrate apply --yes`, plus one `sys_legacy_widget` table with one row, then put on `journal_mode = delete` (72 tables, 1 row). - Row 2 runs from a directory whose `objectstack.config.ts` carries one plugin: its `kernel:ready` hook calls `create()` on the driver (2a) or `execute('SELECT count(*) FROM sys_metadata')` (2b). - Row 4: an empty directory, a file provisioned by one `os secret rewrap --apply --yes`, then put on `journal_mode = delete` (10 tables, 2 rows). | # | Run | Header 18,19 · counter | Content | Printed before | Printed after | |---|---|---|---|---|---| | 1 | `os migrate plan` (CRM) | 1,1 · 5 to 2,2 · 6 (both shas) | unchanged | "They are reported for information only — nothing here drops them, and a plan writes nothing." | "… and a plan writes no row or table." plus the note | | 2a | `os migrate plan`, a plugin write refused | 1,1 · 5 to 2,2 · 6 (both) | unchanged | "Refused 1 write(s) during the declaration boot — a plan writes nothing: create() on sys_metadata. …" | "… — a plan writes no row or table: create() on sys_metadata. …" plus the note at the end of that note's line | | 2b | `os migrate plan`, a raw read forwarded | 1,1 · 5 to 2,2 · 6 (both) | unchanged | "… If those commands only read, nothing was written; if one wrote, it landed. …" | "… If those commands only read, no row or table was written; if one wrote, it landed. …" plus the note at the end of that note's line | | 3 | `os migrate summary-nulls --recompute-undefined-on-empty crm_account.nope`, dry run and `--apply --yes` (CRM) | 1,1 · 5 to 2,2 · 6 (both modes, both shas) | unchanged | "… Refused before any row was read; nothing was written." | "… Refused before the walk read or wrote any row." plus the note | | 4 | `os secret rewrap --apply --yes --no-declared-datasources`, a finished re-run (0 re-wrapped) | 1,1 · 4 to 2,2 · 5 (both) | unchanged | "… and a finished run writes nothing." | "… and a finished run writes no sys_secret row." plus the note | | 4 | the same, dry run | 1,1 · 4 to 2,2 · 5 (both) | unchanged | as above | "… writes no sys_secret row." The closing line already carries the note (PR #23725) | All four rows print after the connect, so none of them was left as it was. Rows 2 and 3 were read, not measured, on the card; they are measured here for the first time. **The `--json` doors.** `os migrate plan --json` carries the guard note in `composition.notes` (row 2a measured: the new sentence, no note; key set identical to base, 22 keys). The unmanaged-table section is the `unmanagedTables` report object and carries no sentence. `os migrate summary-nulls --json` carries the engine's refusal in `error` (the new sentence, no note; `code: INVALID_FIELD`, `httpStatus: 400`). `os secret rewrap --json` carries `report.notes[1]` (the new sentence, no note), in both modes; the key set and the notes' count and order are identical to base. **A non-SQLite target, at the door:** `os migrate plan --database-url :memory:` from the 2a directory prints the 2a sentence with no note (0 occurrences). The pins below add the PostgreSQL case for every text. ## Why two sentences name a narrower noun than "no row or table" Two runs boot plain, not read-only, and that boot writes on a database that has never been started. Measured at base on a fresh file (one placeholder table, rollback journal): - `os migrate summary-nulls --apply --yes --recompute-undefined-on-empty crm_account.nope`: 1 table to 14 tables, rows unchanged, then the refusal. - `os secret rewrap --apply --yes --no-declared-datasources` with nothing to re-wrap (a finished run, exit 0): 1 table to 11 tables and 1 row to 3 (two `sys_migration` rows), and the "Re-running is safe" note printed. So "no row or table" would be false there. Row 3 now vouches for the walk ("Refused before the walk read or wrote any row."), which is what `backfillSummaryNulls` controls, and row 4 for the rows the re-wrap writes ("a finished run writes no sys_secret row"). Both are true in either mode. Rows 1 and 2 are printed only by `os migrate plan`, whose boot defers DDL and opens no missing file, so they claim no row or table. ## How the note reaches each line The test is the one PR #23725 uses: `describeDriverSqliteFile(stack.driver?.config) !== undefined` (`utils/connection-display.ts`, read and not edited). The note is appended by the command that prints the line, in the text output only. No parameter was added anywhere, so no export signature moves. - **Rows 1 and 2, `migrate/plan.ts`.** The composition notes and the unmanaged-table section are printed through one helper: a line that claims "no row or table" is followed by the note on a SQLite file. That is how the command finds the guard's note among the composition notes without a new export from the guard; the pin feeds it the REAL guard's text, so a guard rewording that drops the claim turns the pin red. The `--json` document is unchanged in shape. - **Row 3, `migrate/summary-nulls.ts`.** The command's catch appends the note to an `INVALID_FIELD` error on a SQLite file. Inside that `try`, `INVALID_FIELD` has one producer: `backfillSummaryNulls`'s scope refusal (its only `throw`; the walk records failures rather than throwing, and the other refusals there are `OBJECT_NOT_FOUND` or plain errors). Both modes. The `--json` envelope carries the engine's message as it is. - **Row 4, `secret/rewrap.ts`.** `renderReport` takes a map from a note's text to what follows it; the "Re-running is safe" note gets the note under `--apply` on a SQLite file. A dry run's closing line already carries it, so there it carries none (one copy per run, pinned). `REWRAP_NOTES` stays loaded at the point of use, as before. **`report.notes` readers, by `git grep` over the repo:** the resumable note's text had no reader outside its own definition and the docs mirror. `sys-secret-rewrap.test.ts`'s RESUMABLE case title names the executor's own writes (it asserts the store's write count), not this text, and is unchanged. ## The texts | # | Where | FROM | TO | |---|---|---|---| | 1 | `cli/src/utils/unmanaged-tables.ts:508` | "… and a plan writes nothing." | "… and a plan writes no row or table." | | 2a | `cli/src/utils/schema-migration-plugins.ts:1148` | "— a plan writes nothing" | "— a plan writes no row or table" | | 2b | `cli/src/utils/schema-migration-plugins.ts:1163` | "If those commands only read, nothing was written;" | "If those commands only read, no row or table was written;" | | 3 | `objectql/src/summary-backfill.ts:300` | "Refused before any row was read; nothing was written." | "Refused before the walk read or wrote any row." | | 4 | `cli/src/utils/sys-secret-rewrap.ts:507` (`REWRAP_NOTES.resumable`) and `content/docs/deployment/cli.mdx:652` | "… and a finished run writes nothing." | "… and a finished run writes no sys_secret row." The docs mirror adds the SQLite note in one sentence. | ## Pins (one per text, a SQLite and a PostgreSQL case where the note is conditional) - **Row 1:** `cli/src/utils/unmanaged-tables.test.ts` gains one case pinning the closing line byte for byte. `cli/src/commands/migrate/plan.journal-mode-note.test.ts` (new) runs the real command with the boot, the occupancy probe and the sweep's READ replaced (the renderer is real): SQLite gets exactly "line + note", PostgreSQL exactly the line and no note anywhere. - **Row 2:** the same new file, for 2a and 2b, with each guard note produced by a real `createDeclarationBootWriteGuard()` armed over a fake driver, made to refuse one `create()` or forward one `execute()`, and disarmed. One more case pins `--json`: `composition.notes` equals the guard's note, and the document carries no note. In `schema-migration-plugins.declaration-boot-write-guard.test.ts`, every assertion on the old phrase moved to the new one, the negative ones included, so none of them went vacuous (11 occurrences); the raw-execute case now pins the 2b sentence itself. `schema-migrate.host-composition.integration.test.ts` and `schema-migrate.deferred-ddl.integration.test.ts` moved the same way. - **Row 3:** `objectql/src/summary-backfill.test.ts`'s REFUSES case pins the closing sentence for all six refusal shapes. `cli/src/commands/migrate/summary-nulls.test.ts` gains five cases with the engine's REAL refusal (`vi.importActual`): dry run and `--apply`, each SQLite and PostgreSQL, and `--json` (the envelope's `error` is the engine's message, no note). - **Row 4:** `cli/src/commands/secret/rewrap.guards.test.ts` gains four cases: `--apply` SQLite (the note follows the line), `--apply` PostgreSQL (no note), a dry run (the line carries none; the run carries the note once), and `--json` in both modes (`report.notes` holds the new text, no note). - **Tier:** the new plan file value-imports `bootSchemaStack` to mock it, so `vitest-tiers.ts`'s KERNEL signal puts it in `integration`, beside `summary-nulls.test.ts` and `rewrap.guards.test.ts`. ## Ablation (one per wording) At `c4adb868c`, one run under the verify lock: a baseline, then each new wording put back to its old text through `scripts/ablation-replace.mjs` (WRAP mode) with an EXIT/INT/TERM trap restoring from `HEAD`, every pin file re-run in each leg (`objectql/src/summary-backfill.test.ts` plus the seven CLI pin files). Each leg: the anchor hit once and fell 1 to 0, the blob changed, and the restore was proven by blob equal to `HEAD`, `git diff HEAD` empty and `git status --porcelain` empty. | Leg | Blob | Red (only these) | Green | |---|---|---|---| | baseline | | none | objectql 20 of 20; CLI 13 files, 150 of 150 | | 1 unmanaged-tables | `6add5c0c8d26` to `c647aec174bc` | 3: the render pin and both plan cases for the line | objectql 20; CLI 115 of 118 | | 2a guard refusal | `498e46872d08` to `0fa153eef1a4` | 7: both plan cases for 2a, four guard cases, host-composition THE FIX | objectql 20; CLI 111 of 118 | | 2b guard raw | `498e46872d08` to `e2786928219d` | 3: both plan cases for 2b, the guard's raw-execute case | objectql 20; CLI 115 of 118 | | 3 summary-backfill | `2fb88f18451d` to `f83de26a8bc2` | 1: objectql REFUSES | CLI 118 of 118 | | 4 rewrap resumable | `3db476f2ebce` to `ec49817fb26f` | 4: the four new rewrap cases | objectql 20; CLI 114 of 118 | Leg 3's CLI cases stayed green, as expected: they read the engine's refusal through `@objectstack/objectql`'s `exports`, so `dist/`, which this source mutation did not rebuild. The text's pin is the objectql one, which resolves `src/`. The CLI cases pin the note's follow-through, not the text. No other leg needed a rebuild: every other subject is the CLI's own `src`, reached by relative import. ## Verification (head `c4adb868c`) - **Build:** `turbo run build --filter='@objectstack/cli^...' --concurrency=2`: 60 tasks, VERDICT command-exit 0; then `@objectstack/objectql` rebuilt after the edit (14 tasks, VERDICT 0). The new sentence is in `objectql/dist/index.js` and `index.mjs` (1 hit each; the old one: 0). - **Tests:** the touched CLI files by path (8 files, 132 tests passed, VERDICT 0); the baseline leg above (objectql `summary-backfill.test.ts` 20 of 20; CLI 13 files including the four `plan.*.integration.test.ts`, `rewrap.driver-contract.test.ts` and `sys-secret-rewrap.test.ts`, 150 of 150). These are integration-tier files, run by path; the rest of that tier is declared to CI. The CLI unit tier (`vitest run --project unit --maxWorkers=2`): Test Files 307 passed (307), Tests 4574 passed (4574), exit 0. `@objectstack/objectql` (`vitest run --project local --maxWorkers=2`): Test Files 436 passed (436), Tests 8229 passed (8229), exit 0. - **Typecheck:** `@objectstack/cli` exit 0 (`check:test-typecheck` held at 3 files, 28 errors, 6 signatures); `@objectstack/objectql` exit 0 (held at 39 files, 233 errors, 64 signatures). `tsc --listFilesOnly -p tsconfig.json` puts all seven touched CLI test files in its 1607-file program. - **Lint, by the narrowing:** `pnpm exec eslint --no-inline-config` over the 15 changed `.ts` files exited 0. The repo-wide `pnpm lint` is CI's `Lint & Repo Gates`. - **Gate families:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 99 commands from merge base `bb14934cd` (17 paths, 506 changed lines). All 99 ran at `c4adb868c`, each exit captured before any pipe. 94 exited 0 on the first run; five refused on a prerequisite (exit 3: no built `dist/` for the CLI, some plugins or `client-react`) and exited 0 when re-run after `turbo run build --filter='!@objectstack/docs' --concurrency=2` (75 tasks, 74 cached, VERDICT 0): `check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`. `check-engine-split-ratio.mjs --days 90` ran after `git fetch --shallow-since=2026-07-10` (exit 0). `--ran` printed "99 derived, 99 run, 0 NOT-MEASURED, 0 UNRUN". The derivation flagged one input changed upstream since the base (`scripts/engine-double-contract.pinned.json`); this diff adds no engine double. ## Acceptance notes - **No merge of `origin/main` before opening.** 6 commits landed since the base (`spec`, `rest`, `runtime`, the hono adapters, `cloud-connection`, a dogfood test, `engine-double-contract.pinned.json`); none touches `packages/cli`, `packages/objectql` or a file this diff reads. `git merge-tree --write-tree HEAD origin/main` exits 0. A declared narrowing: CI's merge-ref run covers the joint tree. - **Left as they are, by the claim's "each sentence only":** the developer comments in `schema-migration-plugins.ts` that quote the old phrase "a plan writes nothing" (lines 130, 187, 201, 776, 783, 937, 1680), the `describe` title of the host-composition case (`a plan writes nothing even when the host writes from init()`), and `sys-secret-rewrap.ts`'s module docblock ("Re-running a finished run writes nothing", about the executor's own writes). - **`os migrate apply` prints the same guard note** (its composition notes, `apply.ts:261`), now with the new wording and without the journal-mode note: an apply's boot is followed by its own writes, and the claim's file surface names `os migrate plan`. Not measured. - **The neighbouring class stays as it is** (`migrate/apply.ts:551`, `organization-ownership-apply.ts`), per the hold-off. Built by `session_018t6a5rvqPRvEDMCVVW9kYT`, the dev for `domain:cli` seat 2's claim `6223113446`. --- _Generated by [Claude Code](https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent b1d5569 commit a33f3d5

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
@@ -37,6 +37,7 @@ import {
3737
type UnmanagedTablesReport,
3838
} from '../../utils/unmanaged-tables.js';
3939
import { describeOccupancy, JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js';
40+
import { describeDriverSqliteFile } from '../../utils/connection-display.js';
4041
import {
4142
resolveTenancyPosture,
4243
collectGlobalUniques,
@@ -323,14 +324,30 @@ export default class MigratePlan extends Command {
323324
console.log('');
324325
}
325326

327+
// [#23899] Two kinds of line below claim that this plan wrote no row or
328+
// table: the declaration boot's write-guard note (one of the composition
329+
// notes, when a plugin's write was refused or a raw command forwarded)
330+
// and the unmanaged-table section's closing line. The read-only boot's
331+
// connect may still have switched a SQLite file's journal mode
332+
// (`SqlDriver.applySqliteJournalMode`, its rule for every connect), which
333+
// rewrites the file header and no row, so on a SQLite file each line that
334+
// makes the claim is followed by the note naming the switch. A target
335+
// that is no SQLite file (PostgreSQL, MySQL) has no header to switch. The
336+
// `--json` document above carries the same lines and no note.
337+
const journalNote = describeDriverSqliteFile(stack.driver?.config) !== undefined
338+
? ` ${JOURNAL_MODE_SWITCH_NOTE}`
339+
: '';
340+
const withJournalNote = (line: string): string =>
341+
(line.includes('no row or table') ? `${line}${journalNote}` : line);
342+
326343
printInfo(`Database: ${chalk.white(stack.dbLabel)} ${chalk.dim(`(${describeDatabaseSource(stack.dbSource)})`)}`);
327344
if (stack.telemetryDatabase !== null) printInfo(describeTelemetryDatabase(stack.telemetryDatabase));
328345
if (schemaSyncSkipped) printWarning(SKIPPED_SCHEMA_SYNC_NOTE);
329346
printInfo(`Examined ${chalk.white(String(stack.managedTableCount))} managed table(s).`);
330347
// What the object set was composed from (#12938) — never silent about a
331348
// host config it could not load, and empty (so this block prints nothing)
332349
// when there was no deployment to compose.
333-
for (const note of stack.composition.notes) console.log(chalk.dim(` ${note}`));
350+
for (const note of stack.composition.notes) console.log(chalk.dim(` ${withJournalNote(note)}`));
334351
console.log('');
335352

336353
// [#13204] Its own block, above the drift verdict and never folded into
@@ -341,7 +358,7 @@ export default class MigratePlan extends Command {
341358
const unmanagedLines = renderUnmanagedTables(unmanagedTables);
342359
if (unmanagedLines.length > 0) {
343360
printInfo(unmanagedLines[0]!);
344-
for (const line of unmanagedLines.slice(1)) console.log(chalk.dim(` ${line}`));
361+
for (const line of unmanagedLines.slice(1)) console.log(chalk.dim(` ${withJournalNote(line)}`));
345362
console.log('');
346363
}
347364

0 commit comments

Comments
 (0)