diff --git a/.changeset/23400-ownership-occupancy-gate.md b/.changeset/23400-ownership-occupancy-gate.md index fd66f73a2c3..3367c3719d9 100644 --- a/.changeset/23400-ownership-occupancy-gate.md +++ b/.changeset/23400-ownership-occupancy-gate.md @@ -12,7 +12,7 @@ Clause-②: no (narrowing) **FROM.** `os migrate organization-ownership --apply` opened a SQLite database that another process held open (a running `os dev` or `os serve`, or a runtime's pooled connection) without checking for it, and ran the ceremony beside that process. `os migrate apply` and the other data migrations probe the database first; this command did not. One measured run of the plan and then `--apply` beside a runtime holding a connection left that runtime writing to a deleted `-wal` file, and a row it wrote afterwards never reached the database. -**TO.** Before it boots the stack, the command probes the database the way its siblings do. Beside a holder, `--apply` is refused with exit 1: the ceremony boots nothing and writes nothing (the probe itself opens the file only briefly, to check). The check runs first, so a busy `--apply` that also lacks its plan file or its backup acknowledgement answers reason `database-busy` rather than `plan-file-required` or `backup-unacknowledged`; it is refused either way. The text output names the file and, when the probe can tell, the process holding it, then the remedy: stop the process using it and re-run. With `--json` the refusal uses this command's own envelope: `{ "error": "apply_refused", "reason": "database-busy", "database", "signal", "detail", "hint" }`. There is no `--force`: the ceremony has no flag that skips its reviewed plan or its backup either, and its refusal never names one. The plan (no `--apply`) writes nothing, so it still runs. Its text output warns that `--apply` will refuse until the database is free, and its `--json` document is unchanged. +**TO.** Before it boots the stack, the command probes the database the way its siblings do. Beside a holder, `--apply` is refused with exit 1: the ceremony boots nothing and writes nothing (the probe itself opens the file only briefly, to check). The check runs first, so a busy `--apply` that also lacks its plan file or its backup acknowledgement answers reason `database-busy` rather than `plan-file-required` or `backup-unacknowledged`; it is refused either way. The text output names the file and, when the probe can tell, the process holding it, then the remedy: stop the process using it and re-run. With `--json` the refusal uses this command's own envelope: `{ "error": "apply_refused", "reason": "database-busy", "database", "signal", "detail", "hint" }`. There is no `--force`: the ceremony has no flag that skips its reviewed plan or its backup either, and its refusal never names one. The plan (no `--apply`) writes no row or table, so it still runs. Its text output warns that `--apply` will refuse until the database is free, and its `--json` document is unchanged. **Unchanged.** A PostgreSQL, MySQL or remote libSQL target is not probed. A probe that cannot tell (for example, the database file cannot be read) warns in the text output and proceeds; under `--json` it is silent. The probe reads the project's `.env` files, as the run itself does. diff --git a/.changeset/23562-read-only-arms-journal-mode-note.md b/.changeset/23562-read-only-arms-journal-mode-note.md new file mode 100644 index 00000000000..26671672350 --- /dev/null +++ b/.changeset/23562-read-only-arms-journal-mode-note.md @@ -0,0 +1,19 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): the read-only `os migrate` arms 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 + +**What was wrong.** These arms boot read-only: no DDL, no seed, SELECT statements only. But the boot still opens the database through the SQL driver, 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` (then a WAL file is switched back to a rollback journal). On a file still on a rollback journal, a plan or a dry run therefore rewrote the file's header (the journal-mode bytes and the change counter) while saying "writes nothing" or "nothing in the database was changed". No row or table changed, and nothing was lost. + +**What changed.** Each sentence below now claims no row or table, and each warning is followed by one shared note: "Opening a SQLite file can still switch its journal mode (to WAL by default), which changes its header only." + +- `os migrate organization-ownership` (the plan), beside a process holding the file. FROM: "The plan below writes nothing, but --apply will refuse until the database is free." This sentence has not been published: it arrives in this same release, with the occupancy gate the ceremony's `--apply` now takes. TO: "The plan below writes no row or table, but --apply will refuse until the database is free." and the note. +- Its closing line. FROM: "Plan written to FILE — nothing in the database was changed." TO: "Plan written to FILE — no row or table in the database was changed." On a SQLite target the note follows; on PostgreSQL or MySQL the line ends there. +- `os migrate plan`, beside a holder. FROM: "The plan below is still accurate — nothing is written — but "os migrate apply" will refuse until it is free (or you pass --force)." TO: "The plan below is still accurate — it writes no row or table — but …", the rest unchanged, and the note. +- The dry runs of `os migrate audit-metadata-bodies`, `comment-reactions`, `files-to-references` and `summary-nulls`, beside a holder. FROM: "The dry run below writes nothing, but its counts may shift while that process is running." TO: "The dry run below writes no row or table, but its counts may shift while that process is running." and the note. +- The dry run of `os migrate multi-value-columns`, beside a holder. FROM: "The dry run below writes nothing." TO: "The dry run below writes no row or table." and the note. +- The help description of `os migrate multi-value-columns` (`os migrate multi-value-columns --help`). FROM: "… Dry-run by default: prints the exact statements and the database they would run against, and writes nothing." TO: "… and writes no row or table." The help text carries no note. +- The preview of `os migrate meta --stored`, beside a holder. FROM: "The preview below writes nothing, but a live process may be saving metadata while it runs." TO: "The preview below writes no row or table, but a live process may be saving metadata while it runs." and the note. + +**Unchanged.** No exit code, `--json` document or refusal changes; the warnings are still 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, a read-only run leaves it byte-identical. diff --git a/packages/cli/src/commands/migrate/audit-metadata-bodies.ts b/packages/cli/src/commands/migrate/audit-metadata-bodies.ts index dd9e4901c69..a8abaa8c98f 100644 --- a/packages/cli/src/commands/migrate/audit-metadata-bodies.ts +++ b/packages/cli/src/commands/migrate/audit-metadata-bodies.ts @@ -18,7 +18,7 @@ import { } from '../../utils/format.js'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js'; -import { describeOccupancy } from '../../utils/sqlite-occupancy.js'; +import { describeOccupancy, JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js'; import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js'; import { absentTableReads } from '../../utils/absent-table-reads.js'; @@ -54,7 +54,7 @@ async function confirm(question: string): Promise { * withholds the hashes in those notes, in the same pass. Operators run it once * after upgrading, dry run first. * - * Dry run by default (writes nothing), `--apply` to rewrite. Idempotent: a + * Dry run by default (writes no row), `--apply` to rewrite. Idempotent: a * second run finds nothing — a redacted copy has no credential and no hash * left — so re-running and reading a clean report is the verification. No * `sys_migration` flag is recorded: nothing gates irreversible behaviour on this @@ -115,7 +115,7 @@ export default class MigrateAuditMetadataBodies extends Command { if (occupancy.status === 'busy' && !flags.json) { printWarning(apply ? `--force: ${describeOccupancy(occupancy)} Rewriting anyway — the live process may write rows mid-walk.` - : `${describeOccupancy(occupancy)} The dry run below writes nothing, but its counts may shift while that process is running.`); + : `${describeOccupancy(occupancy)} The dry run below writes no row or table, but its counts may shift while that process is running. ${JOURNAL_MODE_SWITCH_NOTE}`); } if (occupancy.status === 'unknown' && !flags.json) { printWarning(`Could not check whether the database is in use — ${occupancy.detail}`); diff --git a/packages/cli/src/commands/migrate/comment-reactions.ts b/packages/cli/src/commands/migrate/comment-reactions.ts index 479edd027b3..f0a3bba75d7 100644 --- a/packages/cli/src/commands/migrate/comment-reactions.ts +++ b/packages/cli/src/commands/migrate/comment-reactions.ts @@ -17,7 +17,7 @@ import { } from '../../utils/format.js'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js'; -import { describeOccupancy } from '../../utils/sqlite-occupancy.js'; +import { describeOccupancy, JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js'; import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js'; import { absentTableReads } from '../../utils/absent-table-reads.js'; import type { MigrationFlagEngine } from '@objectstack/platform-objects/system'; @@ -136,7 +136,7 @@ export default class MigrateCommentReactions extends Command { if (occupancy.status === 'busy' && !flags.json) { printWarning(apply ? `--force: ${describeOccupancy(occupancy)} Converting anyway — the live process may write rows mid-walk.` - : `${describeOccupancy(occupancy)} The dry run below writes nothing, but its counts may shift while that process is running.`); + : `${describeOccupancy(occupancy)} The dry run below writes no row or table, but its counts may shift while that process is running. ${JOURNAL_MODE_SWITCH_NOTE}`); } if (occupancy.status === 'unknown' && !flags.json) { printWarning(`Could not check whether the database is in use — ${occupancy.detail}`); diff --git a/packages/cli/src/commands/migrate/files-to-references.ts b/packages/cli/src/commands/migrate/files-to-references.ts index 292da957a99..46f6458afb5 100644 --- a/packages/cli/src/commands/migrate/files-to-references.ts +++ b/packages/cli/src/commands/migrate/files-to-references.ts @@ -19,7 +19,7 @@ import { import { StandardErrorCode } from '@objectstack/spec/api'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js'; -import { describeOccupancy } from '../../utils/sqlite-occupancy.js'; +import { describeOccupancy, JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js'; import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js'; import { isNarrowedRun, narrowedFlagNote, refuseUndeclaredObjects } from '../../utils/migrate-object-scope.js'; import { @@ -95,7 +95,7 @@ async function confirm(question: string): Promise { * platform version) is what later opens released-file collection (#3459) and * strict media value-shape enforcement (#3438) on this deployment. * - * Dry run by default, and a dry run writes NOTHING — not conversions, not the + * Dry run by default, and a dry run writes NO ROW — not conversions, not the * flag. Not run / not passed → files keep being retained forever: storage * cost, zero data loss. * @@ -188,11 +188,11 @@ export default class MigrateFilesToReferences extends Command { return; } if (occupancy.status === 'busy' && !flags.json) { - // A dry run writes nothing, so it only ever warns — but it warns, because + // A dry run writes no row, so it only ever warns — but it warns, because // the numbers it reports are a moving target while another process writes. printWarning(apply ? `--force: ${describeOccupancy(occupancy)} Converting anyway — the live process may write records mid-scan.` - : `${describeOccupancy(occupancy)} The dry run below writes nothing, but its counts may shift while that process is running.`); + : `${describeOccupancy(occupancy)} The dry run below writes no row or table, but its counts may shift while that process is running. ${JOURNAL_MODE_SWITCH_NOTE}`); } if (occupancy.status === 'unknown' && !flags.json) { printWarning(`Could not check whether the database is in use — ${occupancy.detail}`); diff --git a/packages/cli/src/commands/migrate/meta.ts b/packages/cli/src/commands/migrate/meta.ts index cde81ca1bf7..cadd08dc0e5 100644 --- a/packages/cli/src/commands/migrate/meta.ts +++ b/packages/cli/src/commands/migrate/meta.ts @@ -40,7 +40,7 @@ import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js import { absentTableReads } from '../../utils/absent-table-reads.js'; import type { StoredMigrationReport } from '@objectstack/metadata-protocol'; import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js'; -import { describeOccupancy } from '../../utils/sqlite-occupancy.js'; +import { describeOccupancy, JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js'; import { checkProtocolCompat, type ProtocolHandshakeManifest } from '@objectstack/metadata-core'; import { planAuthoredSourceWrite, @@ -1439,8 +1439,8 @@ export default class MigrateMeta extends Command { if (occupancy.status === 'busy' && !flags.json) { printWarning(apply ? `--force: ${describeOccupancy(occupancy)} Rewriting anyway — the live process may save metadata mid-run.` - : `${describeOccupancy(occupancy)} The preview below writes nothing, but a live process may ` - + 'be saving metadata while it runs.'); + : `${describeOccupancy(occupancy)} The preview below writes no row or table, but a live process may ` + + `be saving metadata while it runs. ${JOURNAL_MODE_SWITCH_NOTE}`); } if (occupancy.status === 'unknown' && !flags.json) { printWarning(`Could not check whether the database is in use — ${occupancy.detail}`); @@ -1486,7 +1486,7 @@ export default class MigrateMeta extends Command { // takes. A plain boot runs schema sync and the artifact's inline seed // loader, and the seed upserts every seeded row of the app's tables // (`updated_at` bumped, `organization_id` stamped, relative-date values - // re-evaluated) before the report says "writes nothing". `deferSchemaDdl` + // re-evaluated) before the report says "writes no row". `deferSchemaDdl` // holds the DDL back and suppresses the seed; `readOnlyProbe` keeps a // missing sqlite file from being created. `--apply` keeps the plain boot: // it is the writing mode, and its behaviour is unchanged. diff --git a/packages/cli/src/commands/migrate/multi-value-columns.ts b/packages/cli/src/commands/migrate/multi-value-columns.ts index ed276cd598b..dfc9c58a9d5 100644 --- a/packages/cli/src/commands/migrate/multi-value-columns.ts +++ b/packages/cli/src/commands/migrate/multi-value-columns.ts @@ -17,7 +17,7 @@ import { } from '../../utils/format.js'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js'; -import { describeOccupancy } from '../../utils/sqlite-occupancy.js'; +import { describeOccupancy, JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js'; import type { ManagedDriftEntry } from '@objectstack/driver-sql'; import type { IObjectQLEngine } from '@objectstack/spec/contracts'; @@ -331,7 +331,7 @@ export default class MigrateMultiValueColumns extends Command { static override description = 'Migrate a stale varchar/text column to json where the field declares multiple: true; boot and ' + 'os migrate plan only report such a column and never alter it unattended. ' + - 'Dry-run by default: prints the exact statements and the database they would run against, and writes nothing.'; + 'Dry-run by default: prints the exact statements and the database they would run against, and writes no row or table.'; static override examples = [ '$ os migrate multi-value-columns', @@ -394,7 +394,7 @@ export default class MigrateMultiValueColumns extends Command { if (occupancy.status === 'busy' && !flags.json) { printWarning(apply ? `--force: ${describeOccupancy(occupancy)} Altering the column anyway — the live process may write rows mid-migration.` - : `${describeOccupancy(occupancy)} The dry run below writes nothing.`); + : `${describeOccupancy(occupancy)} The dry run below writes no row or table. ${JOURNAL_MODE_SWITCH_NOTE}`); } if (apply && !flags.yes) { diff --git a/packages/cli/src/commands/migrate/organization-ownership.journal-mode.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.journal-mode.integration.test.ts new file mode 100644 index 00000000000..e09e732bb5f --- /dev/null +++ b/packages/cli/src/commands/migrate/organization-ownership.journal-mode.integration.test.ts @@ -0,0 +1,142 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os migrate organization-ownership` (the plan) against a real SQLite file + * still on a rollback journal, through the real read-only boot: what the plan + * arm says about the database stays true for that file. + * + * The boot is read-only (no DDL, no seed, SELECT statements only), but the SQL + * driver's connect switches a file still on a rollback journal to WAL — its + * rule for every connect, read-only boots included — which rewrites the file + * header and no row. So the arm says that no row or table was changed and names + * the journal-mode switch (`JOURNAL_MODE_SWITCH_NOTE`); it never says that + * nothing in the database was changed. + * + * The header is read before and after, so the case cannot pass over a file + * that was never switched. If a read-only boot ever stops switching it, the + * header assertion goes red: the note is then no longer needed, and this pin + * goes with it. + */ + +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isExitSignal } from '../../utils/format.js'; +import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js'; + +// Pay the first transform of the dist-resolved workspace deps the boot and the +// plan reach through dynamic `import()`s at MODULE LOAD, not inside a case. +import '@objectstack/runtime'; +import '@objectstack/metadata-protocol'; + +type Command = (typeof import('./organization-ownership.js'))['default']; +let MigrateOrganizationOwnership: Command; + +// The command's module graph is large, and oclif reads the package's config on +// the first run: both are paid outside the case timeout. +beforeAll(async () => { + ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); +}, 60_000); + +const CASE_TIMEOUT_MS = 60_000; +const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url)); + +/** The driver's journal-mode opt-out: unset, so the driver asks for its default, WAL. */ +const JOURNAL_MODE_ENV = 'OS_DATABASE_SQLITE_JOURNAL_MODE'; + +let dir: string; +let file: string; +let savedCwd: string; +let savedJournalMode: string | undefined; + +beforeEach(() => { + savedCwd = process.cwd(); + savedJournalMode = process.env[JOURNAL_MODE_ENV]; + delete process.env[JOURNAL_MODE_ENV]; + dir = mkdtempSync(join(tmpdir(), 'os-ownership-journal-mode-')); + file = join(dir, 'fixture.db'); + const seed = new Database(file); + seed.pragma('journal_mode = delete'); + seed.exec(` + CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT); + INSERT INTO sys_organization VALUES ('org_default', 'default'); + `); + seed.close(); + // A project directory with no config and no `.env`: the boot reads the fixture alone. + process.chdir(dir); +}); + +afterEach(() => { + process.chdir(savedCwd); + if (savedJournalMode === undefined) delete process.env[JOURNAL_MODE_ENV]; + else process.env[JOURNAL_MODE_ENV] = savedJournalMode; + rmSync(dir, { recursive: true, force: true }); +}); + +/** The file format's read and write version bytes: 1 and 1 on a rollback journal, 2 and 2 in WAL. */ +function journalBytes(): [number, number] { + const header = readFileSync(file).subarray(0, 100); + return [header[18], header[19]]; +} + +function rows(): unknown { + const db = new Database(file, { readonly: true }); + try { + return db.prepare('SELECT * FROM sys_organization ORDER BY id').all(); + } finally { + db.close(); + } +} + +/** Run the command with its output captured and its exit trapped. */ +async function run(argv: string[]): Promise<{ lines: string; exitCode: number }> { + const savedExit = process.exitCode; + vi.spyOn(process.stdout, 'write').mockImplementation(((_chunk: unknown, ...rest: unknown[]) => { + const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; + if (cb) cb(); + return true; + }) as typeof process.stdout.write); + vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + let thrownExit: number | undefined; + try { + try { + await MigrateOrganizationOwnership.run(argv, { root: CLI_ROOT }); + } catch (error) { + if (!isExitSignal(error)) throw error; + thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit; + } + return { + lines: log.mock.calls.map((c) => c.map(String).join(' ')).join('\n'), + exitCode: thrownExit ?? (process.exitCode as number | undefined) ?? 0, + }; + } finally { + process.exitCode = savedExit; + vi.restoreAllMocks(); + } +} + +describe('os migrate organization-ownership (plan) on a SQLite file still on a rollback journal', () => { + it('the boot switches the header to WAL and no row; the plan says no row or table changed and names the switch', async () => { + expect(journalBytes(), 'the fixture is not on a rollback journal: nothing here could be switched').toEqual([1, 1]); + const before = rows(); + const out = join(dir, 'plan.json'); + + const { lines, exitCode } = await run(['--out', out, '--database-url', `file:${file}`]); + + expect(exitCode, lines).toBe(0); + expect(existsSync(out)).toBe(true); + expect(journalBytes()).toEqual([2, 2]); + expect(rows()).toEqual(before); + const success = lines.split('\n').find((line) => line.includes(`Plan written to ${out}`)); + expect(success, lines).toBeDefined(); + expect(success).toContain('no row or table in the database was changed.'); + expect(success).toContain(JOURNAL_MODE_SWITCH_NOTE); + expect(lines).not.toContain('nothing in the database was changed'); + }, CASE_TIMEOUT_MS); +}); diff --git a/packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts index b082b7c1f46..179a65dfaa8 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts @@ -6,8 +6,9 @@ * holding the SQLite file open, `--apply` is refused before the boot, inside * the command's own refusal envelope, and the refusal names the stop remedy * alone: the ceremony takes no `--force`, so a hint prescribing one would send - * the operator to a flag the command rejects. The plan writes nothing, so it - * warns and runs, as every sibling's read-only arm does. + * the operator to a flag the command rejects. The plan writes no row or table, + * so it warns and runs, as every sibling's read-only arm does, and the warning + * names what its connect can still change on a SQLite file: the journal mode. * * The holder is the one `plan.reads-env-files.integration.test.ts` uses: a * better-sqlite3 connection on a WAL database, attached and idle, which is @@ -24,6 +25,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isExitSignal } from '../../utils/format.js'; import { OCCUPANCY_STOP_HINT } from '../../utils/migrate-occupancy-gate.js'; +import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js'; // Pay the first transform of the dist-resolved workspace deps the probe and // 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', () = expect(boot).toHaveBeenCalledTimes(1); expect(lines).toContain(file); expect(existsSync(out)).toBe(true); + // The warning claims no row or table, and names the journal-mode switch the + // boot's connect can still make; it never says the plan writes nothing. + const warning = lines.split('\n').find((line) => line.includes(`${file} is in use`)); + expect(warning, lines).toBeDefined(); + expect(warning).toContain('The plan below writes no row or table'); + expect(warning).toContain(JOURNAL_MODE_SWITCH_NOTE); + expect(lines).not.toContain('writes nothing'); }, CASE_TIMEOUT_MS); it('beside a holder, --json: the plan document is unchanged — no warning, no occupancy field', async () => { diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts index 1863bc0eb70..ff26a46beb2 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.ts @@ -21,6 +21,7 @@ import { import { bootSchemaStack } from '../../utils/schema-migrate.js'; import { OCCUPANCY_STOP_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js'; import { describeOccupancy } from '../../utils/sqlite-occupancy.js'; +import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js'; import { resolvePlannedDriverExec } from '../../utils/unmanaged-tables.js'; import { buildOrganizationOwnershipPlan, @@ -71,9 +72,9 @@ async function confirm(question: string): Promise { * The boot is the family's read-only boot (`deferSchemaDdl` + `readOnlyProbe`: * no DDL, no seed, no database file brought into existence), and the plan * itself issues SELECT statements only (`../../utils/organization-ownership-plan.ts`). - * The one thing it writes is the plan FILE, on the operator's disk, which the - * ADR asks for: "the plan is written to a file the operator keeps". It never - * overwrites one — a kept plan is evidence. + * It writes no row or table; the one thing it writes is the plan FILE, on the + * operator's disk, which the ADR asks for: "the plan is written to a file the + * operator keeps". It never overwrites one — a kept plan is evidence. * * ## Refusal * @@ -269,8 +270,9 @@ export default class MigrateOrganizationOwnership extends Command { // command's own envelope, with one difference: the ceremony takes no // `--force` (no flag skips its reviewed plan or its backup either), so // `--apply` beside a holder is refused outright and the refusal names the - // stop remedy alone. The plan writes nothing and warns, as every sibling's - // read-only arm does. + // stop remedy alone. The plan writes no row or table and warns, as every + // sibling's read-only arm does, saying what its connect can still change + // on a SQLite file (`JOURNAL_MODE_SWITCH_NOTE`). const occupancy = await probeMigrationTarget(flags['database-url']); if (occupancy.status === 'busy' && flags.apply) { await refuse( @@ -286,7 +288,7 @@ export default class MigrateOrganizationOwnership extends Command { return; } if (occupancy.status === 'busy' && !json) { - printWarning(`${describeOccupancy(occupancy)} The plan below writes nothing, but --apply will refuse until the database is free.`); + printWarning(`${describeOccupancy(occupancy)} The plan below writes no row or table, but --apply will refuse until the database is free. ${JOURNAL_MODE_SWITCH_NOTE}`); } if (occupancy.status === 'unknown' && !json) { printWarning(`Could not check whether the database is in use — ${occupancy.detail}`); @@ -423,7 +425,10 @@ export default class MigrateOrganizationOwnership extends Command { if (summary.unattributableRows > 0) { printWarning(`${summary.unattributableRows} row(s) whose owner cannot be derived are listed by id in the plan file.`); } - printSuccess(`Plan written to ${target} — nothing in the database was changed.`); + // The occupancy probe answered `not_applicable` for a target that is no + // SQLite file (PostgreSQL, MySQL, `:memory:`): no file header to switch. + const sqliteFile = occupancy.status !== 'not_applicable'; + printSuccess(`Plan written to ${target} — no row or table in the database was changed.${sqliteFile ? ` ${JOURNAL_MODE_SWITCH_NOTE}` : ''}`); console.log(chalk.dim(` ${timer.display()}`)); } diff --git a/packages/cli/src/commands/migrate/plan.ts b/packages/cli/src/commands/migrate/plan.ts index ec15e82ab54..4b11b830714 100644 --- a/packages/cli/src/commands/migrate/plan.ts +++ b/packages/cli/src/commands/migrate/plan.ts @@ -33,7 +33,7 @@ import { renderUnmanagedTables, type UnmanagedTablesReport, } from '../../utils/unmanaged-tables.js'; -import { describeOccupancy } from '../../utils/sqlite-occupancy.js'; +import { describeOccupancy, JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js'; import { resolveTenancyPosture, collectGlobalUniques, @@ -52,7 +52,7 @@ import { * boot-time create-table / add-column sync that used to run before this command * printed a single line is now REPORTED as pending work instead of performed. * A database another process is using is reported too — as a warning, not a - * refusal, since a plan writes nothing either way. + * refusal, since a plan writes no row either way. * * Since #6743 the dry run also stops short of CREATING the database. The * deferral #3917 introduced covered the DDL and the seed but not the open @@ -131,7 +131,7 @@ export default class MigratePlan extends Command { // not our own pool. const occupancy = await probeMigrationTarget(flags['database-url']); if (occupancy.status === 'busy' && !flags.json) { - printWarning(`${describeOccupancy(occupancy)} The plan below is still accurate — nothing is written — but "os migrate apply" will refuse until it is free (or you pass --force).`); + printWarning(`${describeOccupancy(occupancy)} The plan below is still accurate — it writes no row or table — but "os migrate apply" will refuse until it is free (or you pass --force). ${JOURNAL_MODE_SWITCH_NOTE}`); } let stack; @@ -140,7 +140,7 @@ export default class MigratePlan extends Command { jsonOutput: flags.json, databaseUrl: flags['database-url'], deferSchemaDdl: true, - // A plan writes nothing — so it must not bring a database file into + // A plan writes no row — so it must not bring a database file into // existence either (#6743). On a never-started project the target is // opened as an empty in-memory database: the plan below is unchanged // (an empty database is an empty database), and no file, `-wal` or diff --git a/packages/cli/src/commands/migrate/summary-nulls.ts b/packages/cli/src/commands/migrate/summary-nulls.ts index 2c1c856d2c3..455c5b36996 100644 --- a/packages/cli/src/commands/migrate/summary-nulls.ts +++ b/packages/cli/src/commands/migrate/summary-nulls.ts @@ -17,7 +17,7 @@ import { } from '../../utils/format.js'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js'; -import { describeOccupancy } from '../../utils/sqlite-occupancy.js'; +import { describeOccupancy, JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js'; import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js'; import { refuseUndeclaredObjects } from '../../utils/migrate-object-scope.js'; // Type-only, so the heavy engine package is still loaded lazily below: this is @@ -51,7 +51,7 @@ async function confirm(question: string): Promise { * A freshly seeded database has no such rows; this command is for the other * kind, and is needed exactly once per deployment. * - * Dry run by default (writes nothing at all), `--apply` to backfill. Each + * Dry run by default (writes no row at all), `--apply` to backfill. Each * `NULL` row is RECOMPUTED — a pre-upgrade parent that has children is `NULL` * too and its correct value is the real aggregate, so writing 0 everywhere * would swap a missing value for a wrong one. Idempotent: a second run finds @@ -156,7 +156,7 @@ export default class MigrateSummaryNulls extends Command { if (occupancy.status === 'busy' && !flags.json) { printWarning(apply ? `--force: ${describeOccupancy(occupancy)} Backfilling anyway — the live process may write rows mid-walk.` - : `${describeOccupancy(occupancy)} The dry run below writes nothing, but its counts may shift while that process is running.`); + : `${describeOccupancy(occupancy)} The dry run below writes no row or table, but its counts may shift while that process is running. ${JOURNAL_MODE_SWITCH_NOTE}`); } if (occupancy.status === 'unknown' && !flags.json) { printWarning(`Could not check whether the database is in use — ${occupancy.detail}`); diff --git a/packages/cli/src/utils/sqlite-occupancy.ts b/packages/cli/src/utils/sqlite-occupancy.ts index 8a9be473e62..48fbe94e936 100644 --- a/packages/cli/src/utils/sqlite-occupancy.ts +++ b/packages/cli/src/utils/sqlite-occupancy.ts @@ -322,3 +322,22 @@ export function describeOccupancy(occupancy: SqliteOccupancy): string { : ''; return `${occupancy.filename} is in use — ${occupancy.detail}${sidecars}.`; } + +/** + * What a read-only run still changes on a SQLite file, said beside every claim + * that such a run writes no row: a dry run, a preview, `os migrate plan` and + * the ownership ceremony's plan. + * + * Their boot is read-only (no DDL, no seed, no file brought into existence), + * but the SQL driver's connect still reads the file's journal mode and + * switches it when the file answers differently from the mode the driver asks + * for — WAL, unless the datasource or `OS_DATABASE_SQLITE_JOURNAL_MODE` in + * this process's environment asks for `delete` (`SqlDriver.applySqliteJournalMode`, + * one rule for every connect, read-only boots included). Measured on a file + * on a rollback journal, after a clean close: header bytes 18 and 19 (the + * journal mode), 24-27 (the change counter) and 92-95 change, and nothing + * else in the file does. So "writes nothing" is false for such a file, and + * "writes no row or table" plus this note is true for every journal mode. + */ +export const JOURNAL_MODE_SWITCH_NOTE = + 'Opening a SQLite file can still switch its journal mode (to WAL by default), which changes its header only.';