Skip to content

Commit 3b4efa7

Browse files
fix(cli): os migrate meta --stored and audit-metadata-bodies preview on a read-only boot (#21389)
Fixes #21349 Clause-②: yes (narrowing) ## What changed Without `--apply`, `os migrate meta --stored` and `os migrate audit-metadata-bodies` now boot through the read-only boot `os migrate plan` already uses: `bootSchemaStack({ deferSchemaDdl: true, readOnlyProbe: true })`. Schema DDL is held back, the artifact's inline seed loader is suppressed (`deferSchemaDdl` implies `skipSeedData`), and a SQLite file that does not exist is opened as an empty in-memory stand-in instead of being created. `--apply` keeps the plain boot, unchanged. The code change is one spread per command (`meta.ts`, `audit-metadata-bodies.ts`). There is no new mechanism, no table skip list and no driver change. ## Measured before the fix (the card's steps, on `examples/app-crm`) Setup: `os build`, then `os dev -d file:base.db` on `examples/app-crm`. The seed load ran (28 rows) and the first admin was seeded. The server was stopped and the file copied. The copy has 82 tables and 150 rows. Each command ran on its own copy; the row hashes were read on a separate read-only connection. | run | crm_account | crm_activity | crm_contact | crm_lead | crm_opportunity | schema | |:--|:--|:--|:--|:--|:--|:--| | `meta --stored`, BASE `3937ad2f32` | 3 of 3 rows changed | 5 of 5 | 3 of 3 | 5 of 5 | 12 of 12 | unchanged | | `audit-metadata-bodies`, BASE | 3 of 3 | 5 of 5 | 3 of 3 | 5 of 5 | 12 of 12 | unchanged | | `meta --stored`, this PR | identical | identical | identical | identical | identical | identical | | `audit-metadata-bodies`, this PR | identical | identical | identical | identical | identical | identical | The changed columns were `updated_at` (bumped) and `organization_id` (stamped with the admin's organization on seeded rows that had none). The log line was `[Seeder] Seed loading complete {"inserted":0,"updated":28,...}`. After the fix it is `[Seeder] skipSeedData — inline seed suppressed`. The report each preview prints is the same as before, apart from one paged-read notice the seed loader itself caused. ### The write paths found, and what closes each 1. **The app's inline seed** (`AppPlugin.start`): an upsert of every seeded row. In the pin below it also puts an operator's edit to a seeded row back to the seed's value (`status: 'won'` back to `'open'`). This is closed by `skipSeedData`. 2. **Boot schema sync**: on a database behind the app's schema, the plain boot adds missing columns and creates missing tables. Measured with the plain boot `--apply` takes, which is the boot both previews took before this PR: a dropped `crm_lead.phone` column was added back, and `sys_audit_log` was created with five named indexes. This is closed by `deferSchemaDdl`. On the same two edge databases, the fixed previews left the schema and every row identical. 3. **Creating the target**: a preview at a missing SQLite path created the file. This is closed by `readOnlyProbe`. These are not write paths on this boot: the platform repair migrations (already off: `runPlatformMigrations: false`), a host `onEnable` (these commands compose no host config, and a compiled artifact cannot carry one), and the lifecycle sweep (its first run comes after the one-shot command has exited). ## Pins `packages/cli/src/commands/migrate/preview-read-only.integration.test.ts` runs the real commands through oclif: parse, occupancy probe, boot, walk and shutdown. Each case runs against a database that a plain boot seeded and an operator then edited, and that carries one legacy flow row and one cleartext audit copy of a datasource body. Per dialect cell: - `meta --stored` without `--apply`: the schema and every row are byte-identical, and the report still names the one pending row. - `meta --stored --apply --yes`: the legacy row is rewritten (the control). - `audit-metadata-bodies` without `--apply`: byte-identical, and the report still counts the one copy to rewrite. - `audit-metadata-bodies --apply --yes`: the copy is redacted (the control). - SQLite only: either preview at a missing file creates no file, `-wal`, `-shm` or `-journal`. Cells: SQLite always runs. Live PostgreSQL runs with `OS_TEST_POSTGRES_URL`, in a database derived from the file's path (`os_lv_` + slug + 12 hex characters of sha256; `check-live-db-isolation` PASS with this file in its 43 scanned files). Without the URL the cell is a named skip, and a failure under `OS_EXPECT_LIVE_DIALECT_MATRIX=1`. ## Ablations The fix was committed first (`88e37aa343`). Each leg ran through `scripts/ablation-replace.mjs` in WRAP mode: the anchor hit once, the mutation was confirmed on disk (anchor 1 to 0, marker 0 to 1, blob changed), and the restore was proven (blob == HEAD, `git diff HEAD` empty). The pin imports the commands by relative path, so it resolves to `src`, not `dist`. No rebuild was needed between legs. Runs used the live PostgreSQL cell. | leg | mutation | red | green | |:--|:--|:--|:--| | L1 | `meta.ts`: plain boot (spread removed) | SQLite preview, SQLite missing file, PG preview (3) | 7 | | L2 | `audit-metadata-bodies.ts`: plain boot | SQLite preview, SQLite missing file, PG preview (3) | 7 | | L3 | `meta.ts`: `deferSchemaDdl` only | SQLite missing file (1) | 9 | | L4 | `meta.ts`: `readOnlyProbe` only | SQLite preview, PG preview (2) | 8 | | L5 | `audit-metadata-bodies.ts`: `deferSchemaDdl` only | SQLite missing file (1) | 9 | | L6 | `audit-metadata-bodies.ts`: `readOnlyProbe` only | SQLite preview, PG preview (2) | 8 | The L1 diff shows the operator's edit reverted on both dialects: `"status": "won"` became `"open"` with a new `updated_at`. ## The 17.5.0 comparison This did not reproduce on `examples/app-crm`. The 17.5.0 CLI (tag `@objectstack/cli@17.5.0`, built in a comparison worktree) ran on a database it had seeded itself, and `meta --stored` rewrote the same 28 rows. That was measured twice, and both runs logged `"updated":28`. `meta --stored` has booted without the read-only options since it landed in `83cf2d3082` (#4464). `audit-metadata-bodies` has done the same since it landed in `336e191441`, which is in 17.6.0 and not in 17.5.0 (`git merge-base --is-ancestor` exit 1; control leg, the root commit, exit 0; full clone). Why the HotCRM 17.5.0 run left its hashes identical cannot be measured from this repository. ## One edge behaves differently A preview pointed at a database that lacks the table it reads now fails and exits 1. Before, it created the table and reported nothing to examine. Measured: `meta --stored` on a missing file, or on a database without `sys_metadata`, exits 1 with the driver's refusal for `sys_metadata`, and no file is created. `audit-metadata-bodies` on a database without the audit tables exits 1 with `could not read sys_audit_log — its rows were NOT examined`. Before the fix, the same runs created the file or the tables and answered `No stored metadata to examine` or `Nothing to rewrite` with exit 0. The changeset states this. ## Verification at `a67e290cc7` (this branch merged with `origin/main` `11905a4f8b`) - `pnpm --filter @objectstack/cli build && pnpm --filter @objectstack/cli typecheck`: exit 0, and `check:test-typecheck` OK. The new test is under `src`, which `tsconfig.json` includes. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: 246 files, 3489 tests passed. - Integration: the new file plus `meta.stored-flow-resolution`, `schema-migrate.deferred-ddl` and `platform-migrations-arming` gave 4 files and 23 tests passed, with the live PostgreSQL cell (local PostgreSQL 16 on a private port). Without the URL: 6 passed and 1 named skip. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 63 families, each run one by one with its exit code recorded before any pipe. `--ran` reported 63 run, 0 NOT-MEASURED, 0 UNRUN. `check:dual-build-cjs-loads` and `check:i18n-coverage` first answered exit 3 (PREREQUISITE NOT MET: nine packages outside the CLI closure had no `dist`). After a turbo build of those nine (42 of 42 cache hits), both exited 0. - Lint, as a proven narrowing: eslint with the repo config and `--no-inline-config` over the 3 changed TS files (`--format json`) reported 3 files linted, 0 errors and 0 warnings, and no file was reported as ignored. The config enables no type-aware linting (`eslint.config.mjs` says so: no `parserOptions.project`, no typed rules), so this diff cannot move the verdict on any untouched file. The full `pnpm lint` is left to CI. ## Acceptance notes 1. **The same defect in seven sibling commands, not changed here because they are outside this claim's surface.** Measured at `a67e290cc7` on the same app-crm database: `os migrate value-shapes`, `summary-nulls`, `recorded-by`, `resume`, `files-to-references`, `os secret orphans` and `os storage orphans` each ran the seed loader and rewrote the same 28 rows. All seven are dry-run by default or report-only. `os migrate account-issuer` and `multi-value-columns` already boot read-only and left the database identical. This is reported to the seat as one family. 2. `--apply` on these two commands still runs the boot's seed loader and schema sync, as before. The direction keeps `--apply` unchanged. 3. Two comments now say more than is true: the `runPlatformMigrations` block in `schema-migrate.ts` and the non-deferred case in `platform-migrations-arming.integration.test.ts` list `os migrate meta` among the boots without `deferSchemaDdl`. After this PR that is true only with `--apply`. They are not edited here because both files are outside this claim's surface. 4. The live PostgreSQL cell has no CI leg. No step supplies `OS_TEST_POSTGRES_URL` to `@objectstack/cli`, so in CI the cell is a named skip. Wiring it takes one build step and one run step in the Temporal Conformance job of `.github/workflows/ci.yml`, which is also outside this claim's surface. 5. Docs: `content/docs/deployment/cli.mdx` already says the `meta --stored` preview "writes nothing", and this PR makes that true, so the docs are not edited. `cli.mdx` has no entry for `audit-metadata-bodies`. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 56238d8 commit 3b4efa7

4 files changed

Lines changed: 505 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
`os migrate meta --stored` and `os migrate audit-metadata-bodies` without `--apply` no longer write to the database they preview. Both now boot the stack the way `os migrate plan` does: schema DDL is held back, the app's inline seed loader does not run, and a SQLite file that does not exist is not created.
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a CLI command's verdict on one edge, not a declaration: a preview of os migrate meta --stored or os migrate audit-metadata-bodies at a database that lacks the table it reads now exits 1 instead of creating the table and reporting nothing to examine. No authorable key, spelling, export or stored shape moves: every stack parses and loads exactly as before, the --apply runs boot and write exactly as before, and no stored row is read differently or rewritten. What an operator does about the refusal is point --database-url at the deployment's database or boot the deployment once, so there is no rewrite a ledger entry could carry. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a command's verdict, and this diff adds none (not registered / already-registered); and the change is CLI behaviour, not a TypeScript declaration (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING** — a preview of either command at a database that lacks the table it reads now exits 1, where it used to exit 0. It ships as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
**What was wrong.** Both previews booted the full data stack before reading, and that boot ran schema sync and the app's seed loader. The seed loader upserts every seeded row, so a preview bumped `updated_at`, stamped `organization_id` on seeded rows that had none, put an operator's edit to a seeded row back to the seed's value, and re-evaluated relative-date seed values. On a database that was behind the app's schema, the boot also added the missing columns and created the missing tables. The 17.6.0 upgrade checklist runs both previews before their `--apply` runs, so the safety step changed the data.
14+
15+
**What changes for an operator.** A preview leaves the schema and every row byte-identical, and its report is the same as before. `--apply` boots and writes exactly as before. One edge changes: a preview pointed at a database that lacks the table it reads (a SQLite file that does not exist, an unbooted database, or the wrong `--database-url`) now fails and exits 1 instead of creating the table and reporting nothing to examine. Point `--database-url` at the deployment's database, or boot the deployment once first.

‎packages/cli/src/commands/migrate/audit-metadata-bodies.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,16 @@ export default class MigrateAuditMetadataBodies extends Command {
136136

137137
let stack;
138138
try {
139+
// [#21349] The dry run boots READ-ONLY — the same boot `os migrate plan`
140+
// takes: `deferSchemaDdl` holds schema DDL back and suppresses the
141+
// artifact's inline seed loader (whose upserts rewrite every seeded row
142+
// of the app's tables), and `readOnlyProbe` keeps a missing sqlite file
143+
// from being created. `--apply` keeps the plain boot, unchanged.
139144
stack = await bootSchemaStack({
140145
jsonOutput: flags.json,
141146
databaseUrl: flags['database-url'],
142147
extraPlugins: await buildDataMigrationPlugins({ audit: true }),
148+
...(apply ? {} : { deferSchemaDdl: true, readOnlyProbe: true }),
143149
});
144150
} catch (error: any) {
145151
if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); }

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,10 +830,20 @@ export default class MigrateMeta extends Command {
830830
// for the conflict guard, and `armRuntime: false` means taking it arms
831831
// nothing. No storage adapter: unlike the file migration, nothing here
832832
// reads bytes.
833+
//
834+
// [#21349] The preview boots READ-ONLY — the same boot `os migrate plan`
835+
// takes. A plain boot runs schema sync and the artifact's inline seed
836+
// loader, and the seed upserts every seeded row of the app's tables
837+
// (`updated_at` bumped, `organization_id` stamped, relative-date values
838+
// re-evaluated) before the report says "writes nothing". `deferSchemaDdl`
839+
// holds the DDL back and suppresses the seed; `readOnlyProbe` keeps a
840+
// missing sqlite file from being created. `--apply` keeps the plain boot:
841+
// it is the writing mode, and its behaviour is unchanged.
833842
stack = await bootSchemaStack({
834843
jsonOutput: flags.json,
835844
...(flags['database-url'] ? { databaseUrl: flags['database-url'] } : {}),
836845
extraPlugins: await buildDataMigrationPlugins({ automation: true }),
846+
...(apply ? {} : { deferSchemaDdl: true, readOnlyProbe: true }),
837847
});
838848
} catch (error: any) {
839849
if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); return; }

0 commit comments

Comments
 (0)