Skip to content
Merged
29 changes: 29 additions & 0 deletions .changeset/21552-absent-database-family-closeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@objectstack/cli": patch
---

`os migrate account-issuer`, `os migrate audit-metadata-bodies`, `os migrate meta --stored`, `os secret orphans`, `os secret rewrap` and `os storage orphans` answer a project whose database does not exist yet with empty work and exit 0, instead of exiting 1 on a refused read (#21552)

Clause-②: no

Each of these commands boots read-only by default: the schema sync is held back, and a missing SQLite file is opened as an empty in-memory stand-in. That boot already measures which tables the database lacks, because the held-back sync lists each one as a table to create. Each command then read the very tables it had just found missing, and the database refused the read. On a never-booted database (or a `--database-url` that points at one) every default run exited 1:

- `os migrate account-issuer` refused, naming `sys_account`;
- `os migrate audit-metadata-bodies` counted `failures: 3` for `sys_audit_log`, `sys_activity` and `sys_metadata_audit`;
- `os migrate meta --stored` refused, naming `sys_metadata`;
- `os secret orphans` and `os secret rewrap` answered `"error": "scan_failed"`, naming `sys_secret`;
- `os storage orphans` refused, naming `sys_file`.

Each command now reads only the tables its boot found present. A table that does not exist holds nothing, so:

- `os migrate account-issuer` reports no account and no collision (`ok: true`), exit 0;
- `os migrate audit-metadata-bodies` reports nothing to rewrite, with `failures: 0`, exit 0;
- `os migrate meta --stored` reports no stored metadata to examine (`scanned: 0`, `clean: true`), exit 0;
- `os secret orphans` and `os secret rewrap` report no secret to act on, with every holder family enumerated rather than a gap, exit 0;
- `os storage orphans` reports no stranded file, exit 0.

Each names the tables it did not read: on stdout in human mode, on stderr under `--json`, where stdout stays one document. `os migrate account-issuer` is the one that recognises the refusal instead of asking the boot: its boot composes no auth plugin, so `sys_account` is never listed as a table to create. It recognises only the missing-table refusal for `sys_account`, with the shared `isMissingTableError` predicate.

A table that exists but lacks a column, and any other read that is refused, is still read and still refuses with exit 1. The write modes (`--apply`, `--delete`) are unchanged: they boot with the schema sync, so their tables exist before they read.

There is nothing to migrate.
22 changes: 14 additions & 8 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -532,8 +532,10 @@ 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.
that was never booted, or the wrong `--database-url`), it does not read the table, since
a table that does not exist holds no row: it reports nothing to act on, names the
tables it did not read, and exits 0. Any other read it cannot make still refuses and
exits 1 (under `--json`: `"error": "scan_failed"`).

```bash
os secret orphans # report (writes nothing)
Expand Down Expand Up @@ -1055,12 +1057,16 @@ creates missing tables and columns so the migration has somewhere to write, but
still loads no seed data: the only rows that change are the migration's own.
One edge follows from the read-only boot: it finds out which tables the database lacks
(a never-booted database, or the wrong `--database-url`) instead of creating them.
`os migrate value-shapes` does not read a table it found missing, since that table
holds nothing: the scan is clean over zero records, exits 0, and names the objects it
did not read. `os migrate recorded-by` and `os migrate resume` answer the same way
(nothing to convert, no interrupted runs). Another dry run that reads a missing table
can still fail and exit 1, naming the table. Point `--database-url` at the
deployment's database, or boot the deployment once first.
A read-only data command does not read a table it found missing, since that table
holds nothing, and answers with empty work and exit 0. `os migrate value-shapes` is
clean over zero records and names the objects it did not read. `os migrate
recorded-by` has nothing to convert and `os migrate resume` no interrupted runs.
`os migrate meta --stored` has no stored metadata to examine, `os migrate
audit-metadata-bodies` no audit copy to rewrite, and `os migrate account-issuer` no
account to collide. `os secret orphans`, `os secret rewrap` and `os storage orphans`
report no secret and no file. A read the command cannot avoid and that fails for any
other reason still refuses and exits 1. Point `--database-url` at the deployment's
database, or boot the deployment once first, to see what it holds.

```bash
os migrate files-to-references # Dry run: full report, writes nothing
Expand Down
30 changes: 29 additions & 1 deletion packages/cli/src/commands/migrate/account-issuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { Command, Flags } from '@oclif/core';
import chalk from 'chalk';
import { isMissingTableError } from '@objectstack/types';
import {
printHeader,
printSuccess,
Expand Down Expand Up @@ -128,17 +129,44 @@ export default class MigrateAccountIssuer extends Command {
);

if (!flags.json) printStep('Scanning sys_account…');
const report = await probeAccountIdentityCollisions(engine as never, {

// [#21552] A database with no `sys_account` table holds no account, so no
// two rows collide: the probe reads it as no rows. The read is not
// avoided, measured: this boot composes no `AuthPlugin`, so `sys_account`
// is not a registered object and the held-back sync never lists it, and
// `stack.tableAbsent('sys_account')` answers false on every database.
// The refusal is therefore recognised, with the shared predicate and for
// this command's own table only. ⛔ No other refused read is softened: it
// still throws the probe's refusal below and is never read as clean.
let noAccountTable = false;
const readEngine = engine as Parameters<typeof probeAccountIdentityCollisions>[0];
const readView: typeof readEngine = {
find: async (object, query, options) => {
try {
return await readEngine.find(object, query, options);
} catch (error) {
if (object !== 'sys_account' || !isMissingTableError(error, object)) throw error;
noAccountTable = true;
return [];
}
},
};
const report = await probeAccountIdentityCollisions(readView, {
...(flags['max-records'] != null ? { max: flags['max-records'] } : {}),
});
const noAccountTableLine = noAccountTable
? 'sys_account has no table in this database yet, so no account is stored in it and it was read as no rows.'
: null;

if (flags.json) {
if (noAccountTableLine) console.error(noAccountTableLine);
await emitJson({ database: stack.dbLabel, ...report, duration: timer.elapsed() });
if (!report.ok) this.exit(1);
return;
}

printInfo(`Database: ${chalk.white(stack.dbLabel)}`);
if (noAccountTableLine) printInfo(noAccountTableLine);
console.log('');
console.log(formatAccountIdentityPreflightReport(report));
console.log('');
Expand Down
29 changes: 28 additions & 1 deletion packages/cli/src/commands/migrate/audit-metadata-bodies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ 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 { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
import { absentTableReads } from '../../utils/absent-table-reads.js';

async function confirm(question: string): Promise<boolean> {
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
Expand Down Expand Up @@ -179,15 +180,41 @@ export default class MigrateAuditMetadataBodies extends Command {
? { info: (m: string) => console.error(m), warn: (m: string) => console.error(m) }
: { info: (m: string) => printInfo(m), warn: (m: string) => printWarning(m) };

const report = await migrateStoredMetadataBodyCopies(engine, logger, { apply });
// [#21552] Not asked: the dry run's read-only boot measured which tables
// exist, and a table that does not exist holds no copy to rewrite. The
// rewrite reads through this view, which answers such a table with its
// true contents (no rows) without issuing the read. Read anyway, each
// audited table of a project whose database does not exist yet counted as
// a failed read and the dry run exited 1 over rows that do not exist.
// `--apply` booted plain, so there every table exists and every read is
// real; its writes go to the engine itself.
// ⚠️ `sys_activity` is the exception the boot cannot measure: it is
// rotation-managed, its base name a view over time-sharded tables, and the
// deferred sync lists a view as a table to create. It is read, and only
// the refusal of a table that is not there reads as no rows
// (`absentTableReads`). Believing the measurement would skip the very rows
// this command exists to reach.
// ⛔ Only a table that is MEASURED absent: any other refused read is
// still counted in `failures` and still exits non-zero.
const reads = absentTableReads(stack, (object) => engine.getObject(object));
const readView: Pick<IObjectQLEngine, 'find' | 'findOne' | 'update'> = {
find: (object, query, options) => reads.rows(object, () => engine.find(object, query, options)),
findOne: (object, query, options) =>
reads.absent(object) ? Promise.resolve(null) : engine.findOne(object, query, options),
update: (object, data, options) => engine.update(object, data, options),
};

const report = await migrateStoredMetadataBodyCopies(readView, logger, { apply });

if (flags.json) {
reads.notice(true);
await emitJson({ database: stack.dbLabel, apply, report, duration: timer.elapsed() });
if (report.failures > 0) this.exit(1);
return;
}

printInfo(`Database: ${chalk.white(stack.dbLabel)}`);
reads.notice(false);
console.log('');
for (const [object, stats] of Object.entries(report.byObject)) {
console.log(` ${chalk.white(object)}: ${stats.scanned} scanned, ${stats.rewritten} ${apply ? 'rewritten' : 'to rewrite'}`);
Expand Down
Loading
Loading