Skip to content

Commit b206403

Browse files
fix(cli,runtime): one-shot CLI boots run no seed loader and arm no lifecycle sweep; every no-write mode boots read-only (#21432)
Fixes #21391 Clause-②: yes (narrowing) ## What changed The family ruling on #21391 (triage `5950851232`), built in the `bootSchemaStack` funnel and its callers: - **No one-shot CLI boot runs the seed loader.** `bootSchemaStack` passes `skipSeedData: true` on every boot, keyed like `runPlatformMigrations: false` (unconditional, not on `deferSchemaDdl`). That covers the `--apply`, `--delete`, `--run` and `--yes` paths. Seeding stays with `os dev` and `os serve`. - **Every no-write mode boots read-only** (`deferSchemaDdl: true, readOnlyProbe: true`, the boot `os migrate plan` takes): `os migrate value-shapes` (scan), `summary-nulls`, `files-to-references` and `recorded-by` (dry run), `os migrate resume` (list), `os secret orphans` (report), `os storage orphans` (its only mode), and `os meta resync` when it can never reach its write (no `--yes`, and `--json` or no TTY). The enumeration pin found `os meta resync` as an eighth member. Write modes keep the plain boot, minus the seed. - **No lifecycle sweep is armed on a one-shot boot.** New runtime key `createStandaloneStack({ armLifecycleSweep })`, default `true`. With `false`, `ObjectQLPlugin` gets `lifecycle: { enabled: false }` and the ADR-0057 timers are never created. `bootSchemaStack` passes `false`. - **DDL deferral covers every SQL datasource the boot connects.** `DeferSchemaDdlPlugin` used to arm the first `driver.*` SQL service only. It now arms every `driver.*` service, every driver the engine already holds (the default by name, and the driver each registered object resolves to), and every driver registered later. The last is done by a shadow on the engine instance's `registerDriver` that arms the driver before forwarding it, the seam the declaration-boot write guard already uses. `pendingSchemaWork` and `flushSchemaDdl` cover every armed driver. No driver change. - The two comments that listed `os migrate meta` among the non-deferred boots are corrected: the `runPlatformMigrations` block in `schema-migrate.ts`, and `platform-migrations-arming.integration.test.ts`. - Two `--json` faces the read-only boot newly reaches on a database without the app's tables. `os secret orphans` had no catch for a scan error, so `--json` printed nothing; it now answers `{"error":"scan_failed","message":…,"code":…}` with exit 1. `os migrate value-shapes` re-reported its own `this.exit(1)` as a second document, `{"error":"EEXIT: 1"}`; its catch now rethrows exit signals, as `summary-nulls` and `files-to-references` already do. The fenced files are untouched: `migrate/audit-metadata-bodies.ts` and plugin-audit's `stored-metadata-body-migration.ts`. The family keys reach that command through `bootSchemaStack`. ## Measured: the #21349 repro on `examples/app-crm` Setup at base `1d0600bf66`: `os build`, then `os dev --seed-admin -d file:base.db`. The seed loaded 28 rows across 5 app tables and the dev admin was seeded (82 tables). The server was stopped. Each command ran on its own copy with `--json`, and the state was read on a separate read-only connection. "28/28" means 28 of the 28 seeded rows changed (`updated_at` bumped, `organization_id` stamped). The PR readings use the CLI built at `3f61ebcdb6`. | no-write mode | base | this PR | |:--|:--|:--| | `migrate value-shapes` / `summary-nulls` / `files-to-references` / `recorded-by` / `resume`, `secret orphans`, `storage orphans`, `meta resync` (no `--yes`) | each 28/28, schema identical, seeder `"updated":28` | each 0/28, schema and every row identical, `[Seeder] skipSeedData` | | controls: `account-issuer`, `multi-value-columns`, plus `plan`, `duplicates`, `meta --stored`, `audit-metadata-bodies` | each 0/28, identical | each 0/28, identical | | write mode | base | this PR | |:--|:--|:--| | `--apply` of `value-shapes` / `summary-nulls` / `files-to-references` / `recorded-by` / `meta --stored` / `audit-metadata-bodies`, `resume --run`, `secret orphans --delete`, `meta resync --yes` | each 28/28 (the seed ran alongside) | each 0/28; the only other tables that changed are the command's own (`sys_migration` for the value-shapes and files-to-references applies, `sys_permission_set` for `meta resync --yes`) | | controls: `multi-value-columns --apply`, `apply --yes` (deferred boots) | 0/28 | 0/28 | A no-write mode pointed at a SQLite file that does not exist: | command | base | this PR | |:--|:--|:--| | `value-shapes` | exit 0, file created | exit 1 (gate fails on unreadable objects), no file | | `summary-nulls`, `files-to-references` | exit 0, file created | exit 0, no file | | `recorded-by`, `resume`, `storage orphans` | exit 0, file created | exit 1, the driver's refusal names the table, no file | | `secret orphans` | exit 0, file created | exit 1, `scan_failed`, no file | | `meta resync` (no `--yes`) | exit 0, file created | exit 0 (`confirmation_required`), no file | | `meta --stored`, `audit-metadata-bodies`, `account-issuer` | exit 1, no file | exit 1, no file | The exit-0-to-exit-1 rows are the declared narrowing: `Clause-②: yes (narrowing)`, a `minor` changeset with the BREAKING banner and the ADR-0087 disposition `not-required (no-migration-prescription)`. ## Pins (each measured red before the fix, at `5e7fd69bc3`) - `packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts` (new, 44 cases). The family is derived from source: every module under `src/` that value-imports `bootSchemaStack`, held equal to a table of each caller's no-write and write modes. A new caller fails by file name until it is declared. Against a database a served boot seeded (and an operator then edited), with the artifact one release ahead: - every no-write mode leaves the schema and every row identical (14); - every no-write mode at a missing file creates no file and still answers with one JSON document (14); - every write mode runs no seed write (11); - neither the read-only nor the plain one-shot boot arms the sweep, and the served boot does (3); - the enumeration itself (2). - Before the fix: 27 failed, 17 passed (the passes are the controls and the enumeration). - `schema-migrate.deferred-ddl.integration.test.ts`: two cases with a second SQL datasource an artifact declares, connected through the real `DatasourceAdminServicePlugin` and driver factory. The deferred boot creates nothing there and reports its `create_table`; `flushSchemaDdl` creates it. Before the fix: 2 failed (the boot created `defer_remote` on the second database). - `packages/runtime/src/standalone-stack-lifecycle-sweep.test.ts` (new): the key's declaration on `ObjectQLPlugin`, and its effect on a started kernel's timers. Before the fix: 2 failed. - `preview-read-only.integration.test.ts`: the #21349 exit-1 edge is pinned for both commands, with the exit code and the refusal (`DATABASE_ERROR` naming `sys_metadata`; `failures: 2, scanned: 0` over `sys_audit_log` and `sys_activity`). Its fixture now seeds through a served boot, since the funnel no longer seeds. - `schema-migrate.teardown.integration.test.ts`: a first case pins that a one-shot stack arms no sweep and that its teardown still closes the kernel and the pool. The #4747 pair (audits while live, reads nothing once down) now runs on the standalone stack booted without the one-shot policy (see acceptance note 1). - `multi-value-columns.no-auto-run.test.ts` names the family pin as the one other reader of that module. ## Ablations The fix was committed first. Each leg ran through `scripts/ablation-replace.mjs` in WRAP mode: the anchor hit once, the mutation landed (anchor count 1 to 0, blob changed), and the restore was proven (blob == HEAD, `git diff HEAD` empty), at `0758b2330d`. Every pin imports its subject by relative path, so it resolves to `src`; no rebuild was needed between legs. | leg | mutation | red | |:--|:--|:--| | A1 | `schema-migrate.ts`: `skipSeedData: false` | 23 (every no-write identity case and every write case except `plan` and `apply --yes`, whose composed boots refuse row writes through the declaration-boot guard) | | A2 | `schema-migrate.ts`: `armLifecycleSweep` line removed | 3 (both family lifecycle cases, the teardown first case) | | B1-B8 | each command's read-only spread removed, one leg per command | 2 each (that command's identity and missing-file cases) | | C1 | `registerDriver` shadow not installed | 2 (both second-datasource cases) | | C2 | flush over the first armed driver only | 1 (the flush case) | | C3 | preview over the first armed driver only | 1 (the deferred-boot case) | | D1 | runtime: the `lifecycle: { enabled: false }` passthrough removed | 2 (both runtime cases) | | E1 | `schema-migrate.ts`: `readOnlyProbe` mapping removed | 2 (both no-file cases; the exit-1 pins stay green because the refusal comes from the deferral) | | E2 | `schema-migrate.ts`: deferral never armed | 2 (both exit-1 edge pins) | | F1 | `secret orphans`: the `scan_failed` emit removed | 1 (its missing-file JSON case) | | F2 | `value-shapes`: the exit-signal rethrow removed | 1 (its missing-file JSON case) | ## Verification - `@objectstack/cli` unit tier at `11d48f07f7`: 248 files, 3552 passed. - `@objectstack/cli` integration tier, in 4 shards: shards 1-3 at `5cfaffbc87` (19 + 19 + 19 files; one failure, the #4747 teardown pin, reworked in `0758b2330d`), shard 4 and the teardown file at `0758b2330d` (19 files, 190 passed; 2 passed). After merging `origin/main` (`39a912ea73`), at `77a89b7b53`: the six touched suites, 72 passed and 1 named skip (the live PostgreSQL cell). - `@objectstack/runtime` at `0758b2330d`: 309 files, 5094 passed, 11 skipped. - `test/json-stdout-purity.e2e.test.ts` (nightly tier, `OS_TEST_TIERS=nightly`) at `0758b2330d`: 44 passed. - `pnpm --filter @objectstack/runtime typecheck` and `pnpm --filter @objectstack/cli typecheck` at `77a89b7b53`: exit 0, `check:test-typecheck` OK for both. Commit `11d48f07f7` adds one module-top side-effect import to a test file. (Seat correction at landing: the final commit is now `753bec1955`. It gives the one-shot family pin its own `OS_SECRET_KEY`, 32 random bytes saved and restored around the file, so the pin runs on a clean CI runner. The integration-tier readings above predate it; CI's Test Core shard 4/6 on `753bec1955` is the reading for that file.) - Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `11d48f07f7` derived 95 families; all 95 ran with exit 0 recorded before any pipe. `--ran` reports 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero). Three of them first answered exit 3 (PREREQUISITE NOT MET: eight packages outside the CLI closure had no `dist`); after a turbo build of those eight they exited 0. - Lint, as a proven narrowing, at `11d48f07f7`: eslint with the repo config and `--no-inline-config` over the 18 changed TypeScript files (`--format json`) reported 18 files, 0 errors, 0 warnings, and no file 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 lifecycle key also makes an explicit `sweep()` inert on a one-shot stack.** `lifecycle.enabled` is `LifecycleService`'s master switch: with it off, `start()` arms nothing and `sweep()` returns an empty report. #4747's triage declined "one-shot commands skip the audit" (its option C) as the fix for shutdown pollution, and its pin asserted that an explicit `sweep()` on a `bootSchemaStack` stack audits. Under this card's ruling the scheduled sweep, and the audit riding its clock, is off every one-shot boot; no code in this repo calls `sweep()` on a one-shot stack. The #4747 pair still runs, on the composition that still sweeps. Keeping an explicit `sweep()` alive on a one-shot stack while its schedule stays unarmed needs an `ObjectQLPlugin` option that separates "arm the schedule" from the master switch, which is a `packages/objectql` change outside this claim. 2. `ObjectQLPlugin`'s `lifecycle` option doc says that with `enabled: false` "the `lifecycle` service stays registered so tooling can still run `sweep()` explicitly". `LifecycleService.sweep()` returns an empty report when the service is not enabled. No caller in this repo depends on the sentence. 3. `os migrate recorded-by --apply --yes --json` on a database with one sentinel row converts the row, prints its result document, then prints `{"error":"EEXIT: 0"}` and exits 1. Its catch re-reports the `this.exit(0)` that follows a completed run (measured with the CLI built at `77a89b7b53`, on a copy of the app-crm database). `os migrate resume --run` has the same shape by reading (not measured). This is a write path this change does not reach, so it is reported, not fixed here. 4. The read-only boot keeps a missing SQLite file of the **default** datasource from being created. A second SQLite datasource's file is still opened by its connect; the ruling asks for DDL deferral on every datasource, and that holds. 5. The family pin is SQLite-only. The read-only boot is one code path for every dialect, and the live PostgreSQL CI leg is not this card (triage). 6. `content/docs/deployment/cli.mdx` gains a paragraph under Data migrations and one in the `os secret orphans` entry. `os storage orphans`, `os meta resync`, `os migrate recorded-by` and `os migrate resume` have no entry in that page. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d70353f commit b206403

20 files changed

Lines changed: 1202 additions & 77 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/cli': minor
3+
'@objectstack/runtime': minor
4+
---
5+
6+
The CLI's one-shot commands no longer write to the database as a side effect of booting. No `os migrate *`, `os meta resync`, `os secret orphans` or `os storage orphans` run loads the app's inline seed data, apply and delete modes included, and every mode that writes nothing now boots read-only.
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) a CLI command's verdict on one edge, not a declaration: a no-write mode of os migrate value-shapes, os migrate recorded-by, os migrate resume, os secret orphans or os storage orphans pointed at a database that lacks a table it reads now exits 1 instead of creating the table and reporting nothing. No authorable key, spelling, export or stored shape moves: every stack parses and loads exactly as before, the write modes write exactly what they wrote before minus the seed loader's rows, 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: both packages publish (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 plus one new optional runtime config key, not a TypeScript declaration change to an existing surface (not runtime-interface-only / type-surface-only). -->
11+
12+
**BREAKING** — a no-write run of `os migrate value-shapes`, `os migrate recorded-by`, `os migrate resume`, `os secret orphans` or `os storage orphans` at a database that lacks a 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.
13+
14+
**What was wrong.** Eight commands booted the full data stack in a mode their documentation says writes nothing: `os migrate value-shapes` (scan), `summary-nulls`, `files-to-references` and `recorded-by` (dry run), `os migrate resume` (list), `os secret orphans` and `os storage orphans` (report), and `os meta resync` without `--yes`. That boot ran schema sync and the app's inline seed loader. The seed loader upserts every seeded row, so each run bumped `updated_at`, stamped `organization_id` on seeded rows that had none, and put an operator's edit to a seeded row back to the seed's value. On `examples/app-crm` that was all 28 seeded rows on every run. On a database behind the app's schema, the boot also added columns and created tables. The apply and delete modes ran the same seed loader alongside the write the operator confirmed.
15+
16+
**What changes for an operator.**
17+
18+
- Every mode that writes nothing boots the way `os migrate plan` does: the schema sync is held back, no seed rows are written, and a SQLite file that does not exist is not created. The database is left byte-identical, and the report is the same as before.
19+
- No one-shot CLI boot loads the app's inline seed data. `--apply`, `--delete`, `os migrate resume --run` and `os meta resync --yes` write what they report and nothing else. Seeding stays with `os dev` and `os serve`.
20+
- The deferred schema sync now covers every SQL datasource the boot connects, not only the default one. `os migrate plan` lists a second datasource's pending tables, and `os migrate apply` creates them after you confirm.
21+
- One edge changes: a no-write run pointed at a database that lacks a table it reads (a SQLite file that does not exist, a database that was never booted, or the wrong `--database-url`) refuses and exits 1 instead of creating the table and reporting nothing. Point `--database-url` at the deployment's database, or boot the deployment once first. `os secret orphans --json` answers that refusal with `"error": "scan_failed"`.
22+
- `os migrate value-shapes --json` prints one JSON document when the scan fails its gate. It used to print a second one, `{"error":"EEXIT: 1"}`.
23+
24+
**For embedders of `@objectstack/runtime`.** `createStandaloneStack` accepts `armLifecycleSweep` (default `true`). With `false`, the ADR-0057 lifecycle sweep (rotation, retention reaping, archiving and the dangling-reference audit that rides its clock) is never armed on that boot, and an explicit `sweep()` call on it returns an empty report. The CLI passes `false` on every one-shot boot.

‎content/docs/deployment/cli.mdx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,11 @@ Reports the `sys_secret` rows no producer references any more, and — only behi
530530
**Report-only by default: without `--delete` it writes nothing and deletes nothing.**
531531
It is never run for you; nothing on any boot or upgrade path invokes it.
532532
533+
The report boots your app read-only: no schema change, no seed rows, and a SQLite file
534+
that does not exist is not created. Pointed at a database that lacks `sys_secret` (one
535+
that was never booted, or the wrong `--database-url`), it refuses and exits 1 (under
536+
`--json`: `"error": "scan_failed"`) instead of creating the table and reporting nothing.
537+
533538
```bash
534539
os secret orphans # report (writes nothing)
535540
os secret orphans --json # the same report, machine-readable
@@ -994,6 +999,17 @@ where the data lives.
994999
| `os migrate meta --stored` | Replay the metadata conversion chain over this deployment's `sys_metadata` rows and rewrite the ones still carrying a pre-protocol shape. Hygiene, not a gate — nothing depends on it having run |
9951000
| `os migrate duplicates` | Report business identifiers already minted twice across the organization partitions, and the rows blocking the boot-time NULL-safe index tightenings — a read-only inventory as JSON on stdout. Renumbers nothing and writes nothing at all; run it before the boot-time tenancy repair, which overwrites part of the evidence |
9961001
1002+
**The boot itself writes nothing you did not ask for.** Each of these commands boots
1003+
your app to read its metadata. Without `--apply`, that boot is read-only, the same boot
1004+
`os migrate plan` takes: the schema sync is held back, the app's inline seed data is not
1005+
loaded, and a SQLite file that does not exist is not created. With `--apply`, the boot
1006+
creates missing tables and columns so the migration has somewhere to write, but it
1007+
still loads no seed data: the only rows that change are the migration's own.
1008+
One edge follows from the read-only boot. A dry run pointed at a database that lacks a
1009+
table it reads (a never-booted database, or the wrong `--database-url`) can fail and exit
1010+
1, naming the table, where it used to create the table and report nothing to do. Point
1011+
`--database-url` at the deployment's database, or boot the deployment once first.
1012+
9971013
```bash
9981014
os migrate files-to-references # Dry run: full report, writes nothing
9991015
os migrate files-to-references --apply # Convert, verify, record the flag (prompts)

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,21 @@ export default class MetaResync extends Command {
128128
printStep('Booting runtime stack…');
129129
}
130130

131+
// [#21391] A run that can never reach the write — no `--yes`, and nobody
132+
// at a terminal to confirm (`--json`, or stdin not a TTY) — answers
133+
// `confirmation_required` and writes nothing, so it boots READ-ONLY, the
134+
// boot `os migrate plan` takes. A run that may write keeps the plain boot:
135+
// `sys_permission_set` must exist before the resync writes into it, and
136+
// the interactive prompt comes after the boot.
137+
const mayWrite = flags.yes || (!flags.json && process.stdin.isTTY === true);
138+
131139
let stack;
132140
try {
133-
stack = await bootSchemaStack({ jsonOutput: flags.json, databaseUrl: flags['database-url'] });
141+
stack = await bootSchemaStack({
142+
jsonOutput: flags.json,
143+
databaseUrl: flags['database-url'],
144+
...(mayWrite ? {} : { deferSchemaDdl: true, readOnlyProbe: true }),
145+
});
134146
} catch (error: any) {
135147
if (flags.json) await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
136148
else printError(error.message || String(error));

‎packages/cli/src/commands/migrate/files-to-references.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,10 +211,15 @@ export default class MigrateFilesToReferences extends Command {
211211

212212
let stack;
213213
try {
214+
// [#21391] The dry run boots READ-ONLY, the boot `os migrate plan`
215+
// takes: `deferSchemaDdl` holds schema DDL back on every SQL datasource,
216+
// and `readOnlyProbe` keeps a missing sqlite file from being created.
217+
// `--apply` keeps the plain boot: the tables must exist before it writes.
214218
stack = await bootSchemaStack({
215219
jsonOutput: flags.json,
216220
databaseUrl: flags['database-url'],
217221
extraPlugins: await buildDataMigrationPlugins({ storage: true }),
222+
...(apply ? {} : { deferSchemaDdl: true, readOnlyProbe: true }),
218223
});
219224
} catch (error: any) {
220225
if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); }

‎packages/cli/src/commands/migrate/multi-value-columns.no-auto-run.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,14 @@ describe('nothing on the boot / reconcile path invokes this command (#11733)', (
9090
];
9191
// Only this command's own suites. `commands/migrate/index.ts` deliberately
9292
// does NOT default to it either — the bare `os migrate` is the plan.
93+
//
94+
// [#21391] Plus one reader, named: the one-shot boot family's enumeration
95+
// pin, which RUNS every `bootSchemaStack` caller's modes against a
96+
// database and asserts the run wrote nothing it was not asked to. A test,
97+
// never a boot path; any other reader still has to say why.
98+
const FAMILY_PIN = 'utils/schema-migrate.one-shot-family.integration.test.ts';
9399
expect(importers.filter((p) => !isTestFile(p))).toEqual([]);
94-
expect(importers.every((p) => p.startsWith('commands/migrate/multi-value-columns.'))).toBe(true);
100+
expect(importers.every((p) => p.startsWith('commands/migrate/multi-value-columns.') || p === FAMILY_PIN)).toBe(true);
95101
});
96102

97103
it('the command never routes the remedy through the reconciler', () => {

‎packages/cli/src/commands/migrate/preview-read-only.integration.test.ts‎

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
* report still names the work `--apply` would do — so the identity is not
2424
* the vacuous one of a walk that never read anything;
2525
* 2. the control: `--apply` still applies that work;
26-
* 3. (SQLite) a preview pointed at a file that does not exist creates no file.
26+
* 3. (SQLite) a preview pointed at a file that does not exist creates no file,
27+
* and exits 1 with the refusal its changeset declared (#21391).
2728
*
2829
* ## The driver axis
2930
*
@@ -54,10 +55,14 @@ import { SqlDriver } from '@objectstack/driver-sql';
5455
import type { IObjectQLEngine } from '@objectstack/spec/contracts';
5556
import MigrateMeta from './meta.js';
5657
import MigrateAuditMetadataBodies from './audit-metadata-bodies.js';
57-
import { bootSchemaStack } from '../../utils/schema-migrate.js';
5858
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
5959
import { isExitSignal } from '../../utils/format.js';
6060

61+
// [#10126] Pay the first transform of this dist-resolved workspace dep at
62+
// MODULE LOAD: the fixture's served boot reaches it through a dynamic
63+
// `import()` inside a clocked hook (`scripts/check-test-source-alias.mjs`).
64+
import '@objectstack/runtime';
65+
6166
const HERE = dirname(fileURLToPath(import.meta.url));
6267
const CLI_ROOT = resolve(HERE, '..', '..', '..');
6368

@@ -236,16 +241,20 @@ async function createFixture(cell: DialectCell): Promise<Fixture> {
236241
// The deployment as a served boot left it: every table either command reads,
237242
// the artifact's seed written, then an operator's edit to the seeded row, a
238243
// legacy flow row, and an audit copy of a datasource body in cleartext.
239-
const stack = await bootSchemaStack({
240-
jsonOutput: false,
241-
databaseUrl,
242-
projectRoot: dir,
243-
extraPlugins: await buildDataMigrationPlugins({ automation: true, audit: true }),
244-
});
244+
//
245+
// [#21391] A SERVED boot, not `bootSchemaStack`: no one-shot boot runs the
246+
// seed loader any more, so the funnel cannot stand in for `os dev` here.
247+
const { createStandaloneStack, Runtime } = await import('@objectstack/runtime');
248+
const served = await createStandaloneStack({ projectRoot: dir, databaseUrl });
249+
const runtime = new Runtime({ cluster: false });
250+
const kernel = runtime.getKernel();
251+
for (const plugin of served.plugins) await kernel.use(plugin as any);
252+
for (const plugin of await buildDataMigrationPlugins({ automation: true, audit: true })) await kernel.use(plugin as any);
253+
await runtime.start();
245254
try {
246-
const ql = stack.kernel.getService('objectql') as IObjectQLEngine;
255+
const ql = kernel.getService('objectql') as IObjectQLEngine;
247256
const [acme] = await ql.find('rp_lead', { where: { name: 'Acme' } }, SYSTEM);
248-
expect(acme?.status, 'the plain boot did not write the artifact seed — nothing to protect').toBe('open');
257+
expect(acme?.status, 'the served boot did not write the artifact seed — nothing to protect').toBe('open');
249258
await ql.update('rp_lead', { id: acme.id, status: 'won' }, SYSTEM);
250259
await ql.insert('sys_metadata', {
251260
type: 'flow',
@@ -254,7 +263,7 @@ async function createFixture(cell: DialectCell): Promise<Fixture> {
254263
metadata: JSON.stringify(LEGACY_FLOW),
255264
}, SYSTEM);
256265
} finally {
257-
await stack.shutdown();
266+
await kernel.shutdown();
258267
}
259268
const raw = probe();
260269
try {
@@ -469,6 +478,32 @@ for (const cell of DIALECT_CELLS) {
469478
expect(existsSync(path), `${path} was created by a preview`).toBe(false);
470479
}
471480
}, cell.timeout);
481+
482+
// [#21391] The edge #21349's changeset declared BREAKING: a preview whose
483+
// database lacks the table it reads used to create the table and answer
484+
// "nothing to examine" with exit 0. It now refuses with exit 1 and names
485+
// what it could not read. Both halves are asserted: the exit code a
486+
// script reads, and the refusal the payload carries.
487+
it('meta --stored without --apply on a database that does not exist exits 1 with the driver\'s refusal for sys_metadata', async () => {
488+
const absent = join(fixture!.dir, 'data', 'never-started.db');
489+
const { payload, exitCode } = await runJson(meta, ['--stored', '--database-url', `file:${absent}`]);
490+
491+
expect(exitCode).toBe(1);
492+
expect(payload.code).toBe('DATABASE_ERROR');
493+
expect(payload.error).toContain("'sys_metadata'");
494+
}, cell.timeout);
495+
496+
it('audit-metadata-bodies without --apply on a database that does not exist exits 1 with both tables counted unread', async () => {
497+
const absent = join(fixture!.dir, 'data', 'never-started.db');
498+
const { payload, exitCode } = await runJson(auditBodies, ['--database-url', `file:${absent}`]);
499+
500+
expect(exitCode).toBe(1);
501+
expect(payload.apply).toBe(false);
502+
// `failures` counts the tables whose rows were NOT examined.
503+
expect(payload.report.failures).toBe(2);
504+
expect(payload.report.scanned).toBe(0);
505+
expect(Object.keys(payload.report.byObject).sort()).toEqual(['sys_activity', 'sys_audit_log']);
506+
}, cell.timeout);
472507
}
473508
});
474509
}

‎packages/cli/src/commands/migrate/recorded-by.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,15 @@ export default class MigrateRecordedBy extends Command {
9696

9797
let stack;
9898
try {
99+
// [#21391] The dry run boots READ-ONLY, the boot `os migrate plan`
100+
// takes: `deferSchemaDdl` holds schema DDL back on every SQL datasource,
101+
// and `readOnlyProbe` keeps a missing sqlite file from being created.
102+
// `--apply` keeps the plain boot: the tables must exist before it writes.
99103
stack = await bootSchemaStack({
100104
jsonOutput: flags.json,
101105
databaseUrl: flags['database-url'],
102106
extraPlugins: await buildDataMigrationPlugins(),
107+
...(flags.apply ? {} : { deferSchemaDdl: true, readOnlyProbe: true }),
103108
});
104109
} catch (error: any) {
105110
if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); }

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,15 @@ export default class MigrateResume extends Command {
105105

106106
let stack;
107107
try {
108+
// [#21391] The list mode boots READ-ONLY, the boot `os migrate plan`
109+
// takes: `deferSchemaDdl` holds schema DDL back on every SQL datasource,
110+
// and `readOnlyProbe` keeps a missing sqlite file from being created.
111+
// `--run` keeps the plain boot: the tables must exist before it writes.
108112
stack = await bootSchemaStack({
109113
jsonOutput: flags.json,
110114
databaseUrl: flags['database-url'],
111115
extraPlugins: await buildDataMigrationPlugins(),
116+
...(flags.run ? {} : { deferSchemaDdl: true, readOnlyProbe: true }),
112117
});
113118
} catch (error: any) {
114119
if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); }

‎packages/cli/src/commands/migrate/summary-nulls.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,10 +190,15 @@ export default class MigrateSummaryNulls extends Command {
190190

191191
let stack;
192192
try {
193+
// [#21391] The dry run boots READ-ONLY, the boot `os migrate plan`
194+
// takes: `deferSchemaDdl` holds schema DDL back on every SQL datasource,
195+
// and `readOnlyProbe` keeps a missing sqlite file from being created.
196+
// `--apply` keeps the plain boot: the tables must exist before it writes.
193197
stack = await bootSchemaStack({
194198
jsonOutput: flags.json,
195199
databaseUrl: flags['database-url'],
196200
extraPlugins: await buildDataMigrationPlugins(),
201+
...(apply ? {} : { deferSchemaDdl: true, readOnlyProbe: true }),
197202
});
198203
} catch (error: any) {
199204
if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); }

‎packages/cli/src/commands/migrate/value-shapes.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
createTimer,
1414
emitJson,
1515
errorCodeFields,
16+
isExitSignal,
1617
} from '../../utils/format.js';
1718
import { bootSchemaStack } from '../../utils/schema-migrate.js';
1819
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
@@ -134,10 +135,15 @@ export default class MigrateValueShapes extends Command {
134135

135136
let stack;
136137
try {
138+
// [#21391] The scan boots READ-ONLY, the boot `os migrate plan`
139+
// takes: `deferSchemaDdl` holds schema DDL back on every SQL datasource,
140+
// and `readOnlyProbe` keeps a missing sqlite file from being created.
141+
// `--apply` keeps the plain boot: the tables must exist before it writes.
137142
stack = await bootSchemaStack({
138143
jsonOutput: flags.json,
139144
databaseUrl: flags['database-url'],
140145
extraPlugins: await buildDataMigrationPlugins(),
146+
...(apply ? {} : { deferSchemaDdl: true, readOnlyProbe: true }),
141147
});
142148
} catch (error: any) {
143149
if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); }
@@ -240,6 +246,14 @@ export default class MigrateValueShapes extends Command {
240246
this.exit(1);
241247
}
242248
} catch (error: any) {
249+
// [#21391] `this.exit(1)` above is how a failed gate leaves, and it
250+
// throws oclif's ExitError: rethrown, never re-reported. Caught here, it
251+
// printed a second `--json` document (`{"error":"EEXIT: 1"}`) after the
252+
// scan's own — the shape `summary-nulls` and `files-to-references`
253+
// already guard against. A scan with unreadable objects fails the gate,
254+
// and the read-only scan of a database without the app's tables has
255+
// nothing else.
256+
if (isExitSignal(error)) throw error;
243257
if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); }
244258
printError(error.message || String(error));
245259
this.exit(1);

0 commit comments

Comments
 (0)