Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/21391-one-shot-boot-read-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/cli': minor
'@objectstack/runtime': minor
---

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.

Clause-②: yes (narrowing)

<!-- 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). -->

**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.

**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.

**What changes for an operator.**

- 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.
- 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`.
- 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.
- 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"`.
- `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"}`.

**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.
16 changes: 16 additions & 0 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,11 @@ Reports the `sys_secret` rows no producer references any more, and — only behi
**Report-only by default: without `--delete` it writes nothing and deletes nothing.**
It is never run for you; nothing on any boot or upgrade path invokes it.

The report boots your app read-only: no schema change, no seed rows, and a SQLite file
that does not exist is not created. Pointed at a database that lacks `sys_secret` (one
that was never booted, or the wrong `--database-url`), it refuses and exits 1 (under
`--json`: `"error": "scan_failed"`) instead of creating the table and reporting nothing.

```bash
os secret orphans # report (writes nothing)
os secret orphans --json # the same report, machine-readable
Expand Down Expand Up @@ -994,6 +999,17 @@ where the data lives.
| `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 |
| `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 |

**The boot itself writes nothing you did not ask for.** Each of these commands boots
your app to read its metadata. Without `--apply`, that boot is read-only, the same boot
`os migrate plan` takes: the schema sync is held back, the app's inline seed data is not
loaded, and a SQLite file that does not exist is not created. With `--apply`, the boot
creates missing tables and columns so the migration has somewhere to write, but it
still loads no seed data: the only rows that change are the migration's own.
One edge follows from the read-only boot. A dry run pointed at a database that lacks a
table it reads (a never-booted database, or the wrong `--database-url`) can fail and exit
1, naming the table, where it used to create the table and report nothing to do. Point
`--database-url` at the deployment's database, or boot the deployment once first.

```bash
os migrate files-to-references # Dry run: full report, writes nothing
os migrate files-to-references --apply # Convert, verify, record the flag (prompts)
Expand Down
14 changes: 13 additions & 1 deletion packages/cli/src/commands/meta/resync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,21 @@ export default class MetaResync extends Command {
printStep('Booting runtime stack…');
}

// [#21391] A run that can never reach the write — no `--yes`, and nobody
// at a terminal to confirm (`--json`, or stdin not a TTY) — answers
// `confirmation_required` and writes nothing, so it boots READ-ONLY, the
// boot `os migrate plan` takes. A run that may write keeps the plain boot:
// `sys_permission_set` must exist before the resync writes into it, and
// the interactive prompt comes after the boot.
const mayWrite = flags.yes || (!flags.json && process.stdin.isTTY === true);

let stack;
try {
stack = await bootSchemaStack({ jsonOutput: flags.json, databaseUrl: flags['database-url'] });
stack = await bootSchemaStack({
jsonOutput: flags.json,
databaseUrl: flags['database-url'],
...(mayWrite ? {} : { deferSchemaDdl: true, readOnlyProbe: true }),
});
} catch (error: any) {
if (flags.json) await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
else printError(error.message || String(error));
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/commands/migrate/files-to-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,15 @@ export default class MigrateFilesToReferences extends Command {

let stack;
try {
// [#21391] The dry run boots READ-ONLY, the boot `os migrate plan`
// takes: `deferSchemaDdl` holds schema DDL back on every SQL datasource,
// and `readOnlyProbe` keeps a missing sqlite file from being created.
// `--apply` keeps the plain boot: the tables must exist before it writes.
stack = await bootSchemaStack({
jsonOutput: flags.json,
databaseUrl: flags['database-url'],
extraPlugins: await buildDataMigrationPlugins({ storage: true }),
...(apply ? {} : { deferSchemaDdl: true, readOnlyProbe: true }),
});
} catch (error: any) {
if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,14 @@ describe('nothing on the boot / reconcile path invokes this command (#11733)', (
];
// Only this command's own suites. `commands/migrate/index.ts` deliberately
// does NOT default to it either — the bare `os migrate` is the plan.
//
// [#21391] Plus one reader, named: the one-shot boot family's enumeration
// pin, which RUNS every `bootSchemaStack` caller's modes against a
// database and asserts the run wrote nothing it was not asked to. A test,
// never a boot path; any other reader still has to say why.
const FAMILY_PIN = 'utils/schema-migrate.one-shot-family.integration.test.ts';
expect(importers.filter((p) => !isTestFile(p))).toEqual([]);
expect(importers.every((p) => p.startsWith('commands/migrate/multi-value-columns.'))).toBe(true);
expect(importers.every((p) => p.startsWith('commands/migrate/multi-value-columns.') || p === FAMILY_PIN)).toBe(true);
});

it('the command never routes the remedy through the reconciler', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
* report still names the work `--apply` would do — so the identity is not
* the vacuous one of a walk that never read anything;
* 2. the control: `--apply` still applies that work;
* 3. (SQLite) a preview pointed at a file that does not exist creates no file.
* 3. (SQLite) a preview pointed at a file that does not exist creates no file,
* and exits 1 with the refusal its changeset declared (#21391).
*
* ## The driver axis
*
Expand Down Expand Up @@ -54,10 +55,14 @@ import { SqlDriver } from '@objectstack/driver-sql';
import type { IObjectQLEngine } from '@objectstack/spec/contracts';
import MigrateMeta from './meta.js';
import MigrateAuditMetadataBodies from './audit-metadata-bodies.js';
import { bootSchemaStack } from '../../utils/schema-migrate.js';
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
import { isExitSignal } from '../../utils/format.js';

// [#10126] Pay the first transform of this dist-resolved workspace dep at
// MODULE LOAD: the fixture's served boot reaches it through a dynamic
// `import()` inside a clocked hook (`scripts/check-test-source-alias.mjs`).
import '@objectstack/runtime';

const HERE = dirname(fileURLToPath(import.meta.url));
const CLI_ROOT = resolve(HERE, '..', '..', '..');

Expand Down Expand Up @@ -236,16 +241,20 @@ async function createFixture(cell: DialectCell): Promise<Fixture> {
// The deployment as a served boot left it: every table either command reads,
// the artifact's seed written, then an operator's edit to the seeded row, a
// legacy flow row, and an audit copy of a datasource body in cleartext.
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl,
projectRoot: dir,
extraPlugins: await buildDataMigrationPlugins({ automation: true, audit: true }),
});
//
// [#21391] A SERVED boot, not `bootSchemaStack`: no one-shot boot runs the
// seed loader any more, so the funnel cannot stand in for `os dev` here.
const { createStandaloneStack, Runtime } = await import('@objectstack/runtime');
const served = await createStandaloneStack({ projectRoot: dir, databaseUrl });
const runtime = new Runtime({ cluster: false });
const kernel = runtime.getKernel();
for (const plugin of served.plugins) await kernel.use(plugin as any);
for (const plugin of await buildDataMigrationPlugins({ automation: true, audit: true })) await kernel.use(plugin as any);
await runtime.start();
try {
const ql = stack.kernel.getService('objectql') as IObjectQLEngine;
const ql = kernel.getService('objectql') as IObjectQLEngine;
const [acme] = await ql.find('rp_lead', { where: { name: 'Acme' } }, SYSTEM);
expect(acme?.status, 'the plain boot did not write the artifact seed — nothing to protect').toBe('open');
expect(acme?.status, 'the served boot did not write the artifact seed — nothing to protect').toBe('open');
await ql.update('rp_lead', { id: acme.id, status: 'won' }, SYSTEM);
await ql.insert('sys_metadata', {
type: 'flow',
Expand All @@ -254,7 +263,7 @@ async function createFixture(cell: DialectCell): Promise<Fixture> {
metadata: JSON.stringify(LEGACY_FLOW),
}, SYSTEM);
} finally {
await stack.shutdown();
await kernel.shutdown();
}
const raw = probe();
try {
Expand Down Expand Up @@ -469,6 +478,32 @@ for (const cell of DIALECT_CELLS) {
expect(existsSync(path), `${path} was created by a preview`).toBe(false);
}
}, cell.timeout);

// [#21391] The edge #21349's changeset declared BREAKING: a preview whose
// database lacks the table it reads used to create the table and answer
// "nothing to examine" with exit 0. It now refuses with exit 1 and names
// what it could not read. Both halves are asserted: the exit code a
// script reads, and the refusal the payload carries.
it('meta --stored without --apply on a database that does not exist exits 1 with the driver\'s refusal for sys_metadata', async () => {
const absent = join(fixture!.dir, 'data', 'never-started.db');
const { payload, exitCode } = await runJson(meta, ['--stored', '--database-url', `file:${absent}`]);

expect(exitCode).toBe(1);
expect(payload.code).toBe('DATABASE_ERROR');
expect(payload.error).toContain("'sys_metadata'");
}, cell.timeout);

it('audit-metadata-bodies without --apply on a database that does not exist exits 1 with both tables counted unread', async () => {
const absent = join(fixture!.dir, 'data', 'never-started.db');
const { payload, exitCode } = await runJson(auditBodies, ['--database-url', `file:${absent}`]);

expect(exitCode).toBe(1);
expect(payload.apply).toBe(false);
// `failures` counts the tables whose rows were NOT examined.
expect(payload.report.failures).toBe(2);
expect(payload.report.scanned).toBe(0);
expect(Object.keys(payload.report.byObject).sort()).toEqual(['sys_activity', 'sys_audit_log']);
}, cell.timeout);
}
});
}
5 changes: 5 additions & 0 deletions packages/cli/src/commands/migrate/recorded-by.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,15 @@ export default class MigrateRecordedBy extends Command {

let stack;
try {
// [#21391] The dry run boots READ-ONLY, the boot `os migrate plan`
// takes: `deferSchemaDdl` holds schema DDL back on every SQL datasource,
// and `readOnlyProbe` keeps a missing sqlite file from being created.
// `--apply` keeps the plain boot: the tables must exist before it writes.
stack = await bootSchemaStack({
jsonOutput: flags.json,
databaseUrl: flags['database-url'],
extraPlugins: await buildDataMigrationPlugins(),
...(flags.apply ? {} : { deferSchemaDdl: true, readOnlyProbe: true }),
});
} catch (error: any) {
if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); }
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/commands/migrate/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,15 @@ export default class MigrateResume extends Command {

let stack;
try {
// [#21391] The list mode boots READ-ONLY, the boot `os migrate plan`
// takes: `deferSchemaDdl` holds schema DDL back on every SQL datasource,
// and `readOnlyProbe` keeps a missing sqlite file from being created.
// `--run` keeps the plain boot: the tables must exist before it writes.
stack = await bootSchemaStack({
jsonOutput: flags.json,
databaseUrl: flags['database-url'],
extraPlugins: await buildDataMigrationPlugins(),
...(flags.run ? {} : { deferSchemaDdl: true, readOnlyProbe: true }),
});
} catch (error: any) {
if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); }
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/commands/migrate/summary-nulls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,15 @@ export default class MigrateSummaryNulls extends Command {

let stack;
try {
// [#21391] The dry run boots READ-ONLY, the boot `os migrate plan`
// takes: `deferSchemaDdl` holds schema DDL back on every SQL datasource,
// and `readOnlyProbe` keeps a missing sqlite file from being created.
// `--apply` keeps the plain boot: the tables must exist before it writes.
stack = await bootSchemaStack({
jsonOutput: flags.json,
databaseUrl: flags['database-url'],
extraPlugins: await buildDataMigrationPlugins(),
...(apply ? {} : { deferSchemaDdl: true, readOnlyProbe: true }),
});
} catch (error: any) {
if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); }
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/commands/migrate/value-shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
createTimer,
emitJson,
errorCodeFields,
isExitSignal,
} from '../../utils/format.js';
import { bootSchemaStack } from '../../utils/schema-migrate.js';
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
Expand Down Expand Up @@ -134,10 +135,15 @@ export default class MigrateValueShapes extends Command {

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