From 271c14e1e65202fe3037a784657c316cedd3f598 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:56:37 +0000 Subject: [PATCH 1/7] wip(cli): six read-only data doors ask tableAbsent before their first read (WIP) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .../src/commands/migrate/account-issuer.ts | 20 +++- .../commands/migrate/audit-metadata-bodies.ts | 24 ++++- packages/cli/src/commands/migrate/meta.ts | 37 +++++++- packages/cli/src/commands/secret/orphans.ts | 33 +++++-- packages/cli/src/commands/secret/rewrap.ts | 28 +++++- packages/cli/src/commands/storage/orphans.ts | 17 +++- packages/cli/src/utils/absent-table-reads.ts | 91 +++++++++++++++++++ 7 files changed, 232 insertions(+), 18 deletions(-) create mode 100644 packages/cli/src/utils/absent-table-reads.ts diff --git a/packages/cli/src/commands/migrate/account-issuer.ts b/packages/cli/src/commands/migrate/account-issuer.ts index b66fea940d3..8a02824c05e 100644 --- a/packages/cli/src/commands/migrate/account-issuer.ts +++ b/packages/cli/src/commands/migrate/account-issuer.ts @@ -15,6 +15,7 @@ import { isExitSignal, } from '../../utils/format.js'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; +import { absentTableReads } from '../../utils/absent-table-reads.js'; /** * `os migrate account-issuer` — the PLAN leg of the `sys_account.issuer` @@ -128,17 +129,34 @@ export default class MigrateAccountIssuer extends Command { ); if (!flags.json) printStep('Scanning sys_account…'); - const report = await probeAccountIdentityCollisions(engine as never, { + + // [#21552] Not asked: the read-only boot above measured whether + // `sys_account` exists, and a table that does not exist holds no + // account, so no two rows collide. The probe reads through this view, + // which answers such a table with its true contents (no rows) without + // issuing the read. Read anyway, a project whose database does not exist + // yet was refused here with exit 1. + // ⛔ Only a table the boot MEASURED absent: any other refused read still + // throws the probe's refusal below and is never read as a clean table. + const reads = absentTableReads(stack); + const readEngine = engine as Parameters[0]; + const readView: typeof readEngine = { + find: (object, query, options) => + reads.absent(object) ? Promise.resolve([]) : readEngine.find(object, query, options), + }; + const report = await probeAccountIdentityCollisions(readView, { ...(flags['max-records'] != null ? { max: flags['max-records'] } : {}), }); if (flags.json) { + reads.notice(true); await emitJson({ database: stack.dbLabel, ...report, duration: timer.elapsed() }); if (!report.ok) this.exit(1); return; } printInfo(`Database: ${chalk.white(stack.dbLabel)}`); + reads.notice(false); console.log(''); console.log(formatAccountIdentityPreflightReport(report)); console.log(''); diff --git a/packages/cli/src/commands/migrate/audit-metadata-bodies.ts b/packages/cli/src/commands/migrate/audit-metadata-bodies.ts index 40b264029f4..eff3eb08da3 100644 --- a/packages/cli/src/commands/migrate/audit-metadata-bodies.ts +++ b/packages/cli/src/commands/migrate/audit-metadata-bodies.ts @@ -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 { if (!process.stdin.isTTY) return false; // non-interactive → require --yes @@ -179,15 +180,36 @@ 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. + // ⛔ Only a table the boot MEASURED absent: any other refused read is + // still counted in `failures` and still exits non-zero. + const reads = absentTableReads(stack); + const readView: Pick = { + find: (object, query, options) => + reads.absent(object) ? Promise.resolve([]) : 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'}`); diff --git a/packages/cli/src/commands/migrate/meta.ts b/packages/cli/src/commands/migrate/meta.ts index 86a8700b5dd..d762ae032c4 100644 --- a/packages/cli/src/commands/migrate/meta.ts +++ b/packages/cli/src/commands/migrate/meta.ts @@ -33,6 +33,8 @@ import { } from '../../utils/format.js'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; 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'; @@ -876,18 +878,45 @@ export default class MigrateMeta extends Command { // be a second route to one capability, and the two would drift. // Absent (an older stack, or a boot that skipped it), flow rows keep // reporting `skipped` with the reason rather than being counted done. - const report = await protocol.migrateStoredMetadata({ + // [#21552] Not asked: the preview's read-only boot measured whether + // `sys_metadata` exists, and a table that does not exist stores no row to + // canonicalize. The protocol reads that table through an engine this + // command cannot wrap, so the preview answers the true contents of an + // absent table itself: a walk over zero rows, the report the protocol + // returns for a booted database that holds none. Read anyway, a project + // whose database does not exist yet was refused here with exit 1. + // `--apply` booted plain, so there the table exists and the read is real. + // ⛔ Only a table the boot MEASURED absent: any other refused read still + // lands in the catch below and still exits 1. + const reads = absentTableReads(stack); + const noStoredRows: StoredMigrationReport = { apply, - ...(flags.type && flags.type.length > 0 ? { types: flags.type } : {}), - actor: 'os migrate meta --stored', - }); + protocol: PROTOCOL_VERSION, + scanned: 0, + canonical: 0, + pending: 0, + rewritten: 0, + skipped: 0, + failed: 0, + rows: [], + decisionModeReview: [], + }; + const report: StoredMigrationReport = reads.absent('sys_metadata') + ? noStoredRows + : await protocol.migrateStoredMetadata({ + apply, + ...(flags.type && flags.type.length > 0 ? { types: flags.type } : {}), + actor: 'os migrate meta --stored', + }); const clean = storedMigrationClean(report); if (!clean) exitCode = 1; if (flags.json) { + reads.notice(true); await emitJson({ database: stack.dbLabel, ...report, clean, duration: timer.elapsed() }); } else { printInfo(`Database: ${chalk.white(stack.dbLabel)}`); + reads.notice(false); console.log(''); console.log(formatStoredMigrationReport(report).join('\n')); console.log(''); diff --git a/packages/cli/src/commands/secret/orphans.ts b/packages/cli/src/commands/secret/orphans.ts index ec38310555c..a2a42a49d95 100644 --- a/packages/cli/src/commands/secret/orphans.ts +++ b/packages/cli/src/commands/secret/orphans.ts @@ -19,6 +19,7 @@ import { } from '../../utils/format.js'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; import { oneShotSettingsPlugin } from '../../utils/one-shot-settings.js'; +import { absentTableReads, secretUnionReadView } from '../../utils/absent-table-reads.js'; import type { DatasourceArtefactLike, SecretReferenceEngineLike, @@ -268,7 +269,20 @@ export default class SecretOrphans extends Command { // report, and this command's whole safety property is that no row goes // unmentioned. A driver that ever answered something else is a contract // violation to fix at that driver, not to absorb here. - const rawSecrets = await secretDriver.find('sys_secret', {}); + // + // [#21552] Not asked: the report's read-only boot measured which tables + // exist, and a table that does not exist holds no row, so there is + // nothing to report on it. Every read below, the union's included, asks + // `reads` first and takes an absent table as no rows. Read anyway, a + // project whose database does not exist yet was refused with exit 1. + // `--delete` booted plain, so there nothing is absent and every read is + // real; its one write goes through the unwrapped driver. + // ⛔ Only a table the boot MEASURED absent: any other refused read still + // lands in the catch below, and an empty answer is never invented for it. + const reads = absentTableReads(stack); + const rawSecrets: Record[] = reads.absent('sys_secret') + ? [] + : await secretDriver.find('sys_secret', {}); const rawById = new Map(rawSecrets.map((r) => [String(r.id), r])); // ⛔ `ciphertext` is dropped here and not carried into the plan: the plan // is printed and serialised, and cipher material must not be reachable @@ -285,7 +299,7 @@ export default class SecretOrphans extends Command { const settingDriver = engine.getDriverForObject('sys_setting'); const settingRows: SettingRowSnapshot[] = settingDriver - ? (await settingDriver.find('sys_setting', {})).map((r) => ({ + ? (reads.absent('sys_setting') ? [] : await settingDriver.find('sys_setting', {})).map((r) => ({ namespace: String(r.namespace ?? ''), key: String(r.key ?? ''), scope: (r.scope as string | null | undefined) ?? null, @@ -300,13 +314,17 @@ export default class SecretOrphans extends Command { typeof collectEncryptedSpecifierRefs >[0]; - const union = await collectSecretReferenceUnion({ engine, declaredDatasources }); + const union = await collectSecretReferenceUnion({ + engine: secretUnionReadView(engine, reads), + declaredDatasources, + }); const plan = planSysSecretOrphanSweep({ secrets, union, attributableTo: collectEncryptedSpecifierRefs(manifests), settingRows, }); + reads.notice(json); if (!flags.delete) { if (json) { await emitJson({ mode: 'report', plan }, 0, { compact: true }); return; } @@ -440,10 +458,11 @@ export default class SecretOrphans extends Command { if (failed.length > 0) this.exit(1); } catch (error) { // [#21391] A read the scan could not make is a refusal, and under - // `--json` a refusal is still one JSON document. The report boots - // read-only, so a database that lacks a table it reads (`sys_secret` on - // one never booted with the platform objects) is refused here, where - // the plain boot used to create the table and report nothing. + // `--json` a refusal is still one JSON document. [#21552] A table the + // report's read-only boot measured absent (`sys_secret` on a database + // never booted with the platform objects) is not one of them: it is + // answered above with no rows and never read. Any other refused read + // lands here. if (isExitSignal(error)) throw error; const message = error instanceof Error ? error.message : String(error); if (json) { await emitJson({ error: 'scan_failed', message, ...errorCodeFields(error) }, 1, { compact: true }); return; } diff --git a/packages/cli/src/commands/secret/rewrap.ts b/packages/cli/src/commands/secret/rewrap.ts index 5644e3d4eda..83a62b16a93 100644 --- a/packages/cli/src/commands/secret/rewrap.ts +++ b/packages/cli/src/commands/secret/rewrap.ts @@ -17,6 +17,7 @@ import { } from '../../utils/format.js'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; import { oneShotSettingsPlugin, resolveExistingDataKey } from '../../utils/one-shot-settings.js'; +import { absentTableReads, secretUnionReadView } from '../../utils/absent-table-reads.js'; import type { DatasourceArtefactLike, SecretReferenceEngineLike, @@ -203,7 +204,21 @@ export default class SecretRewrap extends Command { // Read at DRIVER level and UNSCOPED, as the union reads its holders: a // row missing from this read is a row the run never mentions. - const secrets: RewrapSecretRow[] = (await secretDriver.find('sys_secret', {})).map((r) => ({ + // + // [#21552] Not asked: the dry run's read-only boot measured which tables + // exist, and a table that does not exist holds no row, so there is + // nothing to re-wrap on it. Every read below, the union's included, asks + // `reads` first and takes an absent table as no rows. Read anyway, a + // project whose database does not exist yet was refused with exit 1. + // `--apply` booted plain, so there nothing is absent and every read is + // real; its write goes through the unwrapped driver. + // ⛔ Only a table the boot MEASURED absent: any other refused read still + // lands in the catch below, and an empty answer is never invented for it. + const reads = absentTableReads(stack); + const secretRows: Record[] = reads.absent('sys_secret') + ? [] + : await secretDriver.find('sys_secret', {}); + const secrets: RewrapSecretRow[] = secretRows.map((r) => ({ id: String(r.id), namespace: String(r.namespace ?? ''), key: String(r.key ?? ''), @@ -213,8 +228,12 @@ export default class SecretRewrap extends Command { ciphertext: r.ciphertext, })); - const union = await collectSecretReferenceUnion({ engine, declaredDatasources }); + const union = await collectSecretReferenceUnion({ + engine: secretUnionReadView(engine, reads), + declaredDatasources, + }); const plan = planSysSecretRewrap({ secrets, union, derivationOf: ciphertextDerivationStatus }); + reads.notice(json); // ── --apply: refusals that come before any row is opened ───────────── // An incomplete union settles every version-1 row as left at planning @@ -299,8 +318,9 @@ export default class SecretRewrap extends Command { if (exitCode !== 0) this.exit(exitCode); } catch (error) { // A read the run could not make is a refusal, and under `--json` a - // refusal is still one JSON document. The dry run boots read-only, so a - // database that lacks a table it reads is refused here. + // refusal is still one JSON document. [#21552] A table the dry run's + // read-only boot measured absent is not one of them: it is answered + // above with no rows and never read. Any other refused read lands here. if (isExitSignal(error)) throw error; const message = error instanceof Error ? error.message : String(error); if (json) { await emitJson({ error: 'scan_failed', message, ...errorCodeFields(error) }, 1, { compact: true }); return; } diff --git a/packages/cli/src/commands/storage/orphans.ts b/packages/cli/src/commands/storage/orphans.ts index 49e80550490..8deac1bec13 100644 --- a/packages/cli/src/commands/storage/orphans.ts +++ b/packages/cli/src/commands/storage/orphans.ts @@ -16,6 +16,7 @@ import { } from '../../utils/format.js'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js'; +import { absentTableReads } from '../../utils/absent-table-reads.js'; import type { IDataEngine } from '@objectstack/spec/contracts'; import type { StrandedOrphanInventoryEngine } from '@objectstack/service-storage'; @@ -149,12 +150,25 @@ export default class StorageOrphans extends Command { '@objectstack/service-storage' ); - const report = await inventoryStrandedFileOrphans(engine, { + // [#21552] Not asked: the report's read-only boot measured which tables + // exist, and a table that does not exist holds no file, so none is + // stranded. The inventory reads through this view, which answers such a + // table with its true contents (no rows) without issuing the read. Read + // anyway, a project whose database does not exist yet was refused here + // with exit 1. This command has no writing mode. + // ⛔ Only a table the boot MEASURED absent: any other refused read still + // throws into the catch below and still exits 1. + const reads = absentTableReads(stack); + const readView: StrandedOrphanInventoryEngine = { + find: (object, query) => (reads.absent(object) ? Promise.resolve([]) : engine.find(object, query)), + }; + const report = await inventoryStrandedFileOrphans(readView, { maxCandidates: flags['max-candidates'], sampleLimit: flags.samples, }); if (flags.json) { + reads.notice(true); await emitJson({ database: stack.dbLabel, readOnly: true, @@ -165,6 +179,7 @@ export default class StorageOrphans extends Command { } printInfo(`Database: ${chalk.white(stack.dbLabel)}`); + reads.notice(false); console.log(''); console.log(formatStrandedOrphanInventory(report)); console.log(''); diff --git a/packages/cli/src/utils/absent-table-reads.ts b/packages/cli/src/utils/absent-table-reads.ts new file mode 100644 index 00000000000..f68dbcf2fad --- /dev/null +++ b/packages/cli/src/utils/absent-table-reads.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { printInfo } from './format.js'; +import type { SchemaStack } from './schema-migrate.js'; +import type { SecretReferenceEngineLike } from './secret-reference-union.js'; + +/** + * "Not asked": a read-only command does not read a table its own boot measured + * absent. + * + * The read-only boot (`deferSchemaDdl`) holds the schema sync back, and the + * held-back sync lists every table the database lacks as `create_table`. A + * command that then read such a table asked the database a question whose + * answer the boot had already measured, and turned the refusal into a query + * fault and exit 1 on a project whose database does not exist yet. A table that + * does not exist stores nothing, so the true answer to a read of it is no rows. + * + * This is the one place a door asks. It wraps {@link SchemaStack.tableAbsent} + * and nothing else: ⛔ it is not a second mechanism and it recognises no refused + * read, so a table that exists but lacks a column (`add_columns`), or any other + * refused read, is still issued and still reported as it always was. A write + * mode boots plain, so `tableAbsent` is `false` there and every read is real. + */ +export interface AbsentTableReads { + /** + * Did the boot measure `object`'s table absent? Ask BEFORE the read; a `true` + * answer is recorded, so {@link notice} can name the table that was not read. + */ + absent(object: string): boolean; + /** The line naming every table that was answered without a read, or `null` when none was. */ + line(): string | null; + /** + * Say so: on stdout in human mode, on stderr under `--json`, where stdout + * stays one parseable document. Silent when nothing was answered this way. + */ + notice(json: boolean): void; +} + +export function absentTableReads(stack: Pick): AbsentTableReads { + const notRead = new Set(); + const line = (): string | null => + notRead.size > 0 + ? `${notRead.size} object(s) have no table in this database yet, so nothing is stored in ` + + `them and they were not read: ${[...notRead].sort().join(', ')}.` + : null; + return { + absent: (object) => { + if (!stack.tableAbsent(object)) return false; + notRead.add(object); + return true; + }, + line, + notice: (json) => { + const text = line(); + if (!text) return; + if (json) console.error(text); + else printInfo(text); + }, + }; +} + +/** + * The engine slice the secret reference union reads through (`os secret + * orphans` and `os secret rewrap`): the same slice, with every driver read of + * an absent table answered with no rows. + * + * Read-only by construction, like the union's own port: only `find` is carried + * onto the driver, so the write verbs of `--delete` and `--apply` (which boot + * plain, where nothing is absent) are taken from the unwrapped driver. The + * optional `listDatasourceDefs` stays optional: its ABSENCE is a declared gap in + * the union, so it is carried over only when the engine has it. + */ +export function secretUnionReadView( + engine: SecretReferenceEngineLike, + reads: AbsentTableReads, +): SecretReferenceEngineLike { + return { + getConfigs: () => engine.getConfigs(), + getDriverForObject: (objectName) => { + const driver = engine.getDriverForObject(objectName); + if (!driver) return driver; + return { + find: (object, query, options) => + reads.absent(object) ? Promise.resolve([]) : driver.find(object, query, options), + }; + }, + ...(typeof engine.listDatasourceDefs === 'function' + ? { listDatasourceDefs: () => engine.listDatasourceDefs!() } + : {}), + }; +} From f6afdb7f2fc62de12c02d708740502bc8b28410e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 08:06:24 +0000 Subject: [PATCH 2/7] wip(cli): account-issuer reads a missing sys_account as no rows with isMissingTableError (WIP) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .../src/commands/migrate/account-issuer.ts | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/migrate/account-issuer.ts b/packages/cli/src/commands/migrate/account-issuer.ts index 8a02824c05e..7c4227e10a4 100644 --- a/packages/cli/src/commands/migrate/account-issuer.ts +++ b/packages/cli/src/commands/migrate/account-issuer.ts @@ -2,6 +2,7 @@ import { Command, Flags } from '@oclif/core'; import chalk from 'chalk'; +import { isMissingTableError } from '@objectstack/types'; import { printHeader, printSuccess, @@ -15,7 +16,6 @@ import { isExitSignal, } from '../../utils/format.js'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; -import { absentTableReads } from '../../utils/absent-table-reads.js'; /** * `os migrate account-issuer` — the PLAN leg of the `sys_account.issuer` @@ -130,33 +130,43 @@ export default class MigrateAccountIssuer extends Command { if (!flags.json) printStep('Scanning sys_account…'); - // [#21552] Not asked: the read-only boot above measured whether - // `sys_account` exists, and a table that does not exist holds no - // account, so no two rows collide. The probe reads through this view, - // which answers such a table with its true contents (no rows) without - // issuing the read. Read anyway, a project whose database does not exist - // yet was refused here with exit 1. - // ⛔ Only a table the boot MEASURED absent: any other refused read still - // throws the probe's refusal below and is never read as a clean table. - const reads = absentTableReads(stack); + // [#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[0]; const readView: typeof readEngine = { - find: (object, query, options) => - reads.absent(object) ? Promise.resolve([]) : readEngine.find(object, query, options), + 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) { - reads.notice(true); + 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)}`); - reads.notice(false); + if (noAccountTableLine) printInfo(noAccountTableLine); console.log(''); console.log(formatAccountIdentityPreflightReport(report)); console.log(''); From 854c5ceea0802d8a5e1a39a4b77c847906b7f608 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 08:07:57 +0000 Subject: [PATCH 3/7] wip(cli): family pins on the absent database; the two refusal pins flip to empty work (WIP) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- ...mmands.absent-database.integration.test.ts | 260 +++++++++++++++++- .../preview-read-only.integration.test.ts | 41 +-- 2 files changed, 281 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts b/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts index 7a2808dc225..94fc88f546b 100644 --- a/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts +++ b/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts @@ -42,6 +42,22 @@ * fix that answered empty work without looking would fail here. * * Every spawn runs in the hook; a case only reads what a run printed. + * + * ## [#21552] The rest of the family + * + * The same shape, closed out for the six read-only doors #21529's ruling did + * not name: `os migrate account-issuer`, `audit-metadata-bodies` and `meta + * --stored`, `os secret orphans` and `rewrap`, `os storage orphans`. Each used + * to exit 1 on a project whose database does not exist yet, from its own first + * read of a table its read-only boot had deferred. Five now ask + * `SchemaStack.tableAbsent` before the read; `account-issuer` cannot (its boot + * composes no auth plugin, so `sys_account` is never listed as a table to + * create) and recognises the missing-table refusal for that table only. + * + * Pinned in the second half of this file: the six on the absent database + * (`--json` and human), a booted database holding one row of work for each + * (the control: the door READS the table and reports the row), and the four + * doors that already exited 0 on the absent database, which still do. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -142,10 +158,15 @@ let absentDb: string; let bootedDb: string; function runCommand(command: string, extra: string[], dbFile: string): Promise { + return runCli(['migrate', command, ...extra], dbFile); +} + +/** One `os --database-url file:` run, in the fixture project. */ +function runCli(argv: string[], dbFile: string): Promise { return new Promise((resolveRun, rejectRun) => { const child = spawn( process.execPath, - [CLI, 'migrate', command, ...extra, '--database-url', `file:${dbFile}`], + [CLI, ...argv, '--database-url', `file:${dbFile}`], { cwd: dir, env: childEnv({ @@ -164,7 +185,7 @@ function runCommand(command: string, extra: string[], dbFile: string): Promise { stderr += String(c); }); const timer = setTimeout(() => { child.kill('SIGKILL'); - rejectRun(new Error(`os migrate ${command} did not finish within ${RUN_BUDGET_MS}ms\n${stderr}`)); + rejectRun(new Error(`os ${argv.join(' ')} did not finish within ${RUN_BUDGET_MS}ms\n${stderr}`)); }, RUN_BUDGET_MS); child.on('error', (err) => { clearTimeout(timer); rejectRun(err); }); child.on('close', (code) => { @@ -314,3 +335,238 @@ describe('[#21529] the control: a booted database is read, and its work is repor expect(journalScanWarnings(bootedJson[command])).toEqual([]); }); }); + +// ── [#21552] The rest of the family ────────────────────────────────────────── + +/** + * The control database for the six doors: a served-shape boot (DDL performed) + * of the plugin set those doors compose between them — platform objects, the + * audit objects, settings, storage — then one row of work for each: + * + * - `sys_account`: a two-row table in the legacy shape, with its `issuer` + * column. The door's boot composes no auth plugin, so the table is made + * here by hand, the way a deployment that ran the auth plugin left it; + * - `sys_audit_log`: an old audit copy of a datasource body, in cleartext; + * - `sys_metadata`: one stored `object` row, already on protocol; + * - `sys_secret`: one row no producer references; + * - `sys_file`: one committed attachments-scope file nothing holds. + */ +const SEED_FAMILY_CHILD = ` +const rt = await import('@objectstack/runtime'); +const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin'); +const { AuditPlugin } = await import('@objectstack/plugin-audit'); +const { StorageServicePlugin } = await import('@objectstack/service-storage'); +const { SettingsServicePlugin, LocalCryptoProvider } = await import('@objectstack/service-settings'); +const { SqlDriver } = await import('@objectstack/driver-sql'); +const stack = await rt.createStandaloneStack({ + projectRoot: process.env.FIXTURE_PROJECT, + databaseUrl: 'file:' + process.env.FIXTURE_DB, + skipSeedData: true, + armLifecycleSweep: false, +}); +const runtime = new rt.Runtime({ cluster: false }); +const kernel = runtime.getKernel(); +for (const plugin of stack.plugins) await kernel.use(plugin); +await kernel.use(new PlatformObjectsPlugin()); +await kernel.use(new AuditPlugin()); +await kernel.use(new SettingsServicePlugin({ + registerRoutes: false, + cryptoProvider: new LocalCryptoProvider({ mode: 'production', env: process.env }), +})); +await kernel.use(new StorageServicePlugin({ registerRoutes: false })); +await runtime.start(); +const ql = kernel.getService('objectql'); +const SYSTEM = { context: { isSystem: true } }; +await ql.insert('sys_metadata', { + type: 'object', name: 'os21552_note', state: 'active', + metadata: JSON.stringify({ name: 'os21552_note', fields: { title: { type: 'text' } } }), +}, SYSTEM); +await kernel.shutdown(); + +const raw = new SqlDriver({ client: 'better-sqlite3', connection: { filename: process.env.FIXTURE_DB }, useNullAsDefault: true }); +const k = raw.knex; +await k.raw('CREATE TABLE sys_account (id text primary key, provider_id text, account_id text, issuer text, user_id text)'); +await k('sys_account').insert([ + { id: 'acc_1', provider_id: 'github', account_id: 'g-1', issuer: 'github', user_id: 'u1' }, + { id: 'acc_2', provider_id: 'github', account_id: 'g-2', issuer: 'github', user_id: 'u2' }, +]); +await k('sys_audit_log').insert({ + id: 'aud_21552', object_name: 'sys_metadata', record_id: 'm_21552', action: 'create', + new_value: JSON.stringify({ + id: 'm_21552', name: 'ds', type: 'datasource', scope: 'platform', + metadata: JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://db.turso.io', encryptionKey: 'cleartext-21552' } }), + }), +}); +await k('sys_secret').insert({ + id: 'sec_21552', namespace: 'os21552', key: 'orphan', kms_key_id: 'local', alg: 'aes-256-gcm', version: 1, ciphertext: 'not-a-real-ciphertext', +}); +await k('sys_file').insert({ + id: 'file_21552', key: 'attachments/os21552.txt', name: 'os21552.txt', size: 12, scope: 'attachments', status: 'committed', +}); +await raw.disconnect(); +process.stderr.write('[fixture] seeded\\n'); +process.exit(0); +`; + +interface Door { + /** The name a case reads as. */ + name: string; + argv: string[]; + /** The `--json` document an empty answer carries, as a predicate over the parsed document. */ + empty: (doc: any) => void; + /** The sentence the human face ends on when there is nothing to report. */ + humanEmpty: RegExp; + /** The tables the door reads itself: a refused read of one must not appear. */ + tables: readonly string[]; + /** What the booted control holds for the door: read, and reported. */ + work: (doc: any) => void; +} + +const DOORS: readonly Door[] = [ + { + name: 'migrate account-issuer', + argv: ['migrate', 'account-issuer'], + empty: (doc) => expect(doc).toMatchObject({ scanned: 0, keys: 0, collisions: [], crossUser: 0, ok: true }), + humanEmpty: /Pre-flight clean/, + tables: ['sys_account'], + work: (doc) => expect(doc).toMatchObject({ scanned: 2, keys: 2, collisions: [], ok: true }), + }, + { + name: 'migrate audit-metadata-bodies', + argv: ['migrate', 'audit-metadata-bodies'], + empty: (doc) => { + expect(doc).toMatchObject({ apply: false, report: { scanned: 0, rewritten: 0, failures: 0 } }); + expect(Object.keys(doc.report.byObject).sort()).toEqual(['sys_activity', 'sys_audit_log', 'sys_metadata_audit']); + }, + humanEmpty: /Nothing to rewrite/, + tables: ['sys_audit_log', 'sys_activity', 'sys_metadata_audit'], + work: (doc) => { + expect(doc.report.failures).toBe(0); + expect(doc.report.byObject.sys_audit_log).toMatchObject({ scanned: 1, rewritten: 1 }); + }, + }, + { + name: 'migrate meta --stored', + argv: ['migrate', 'meta', '--stored'], + empty: (doc) => expect(doc).toMatchObject({ apply: false, scanned: 0, pending: 0, failed: 0, rows: [], clean: true }), + humanEmpty: /No stored metadata to examine/, + tables: ['sys_metadata'], + work: (doc) => { + expect(doc.scanned).toBeGreaterThanOrEqual(1); + expect(doc).toMatchObject({ pending: 0, failed: 0, clean: true }); + }, + }, + { + name: 'secret orphans', + argv: ['secret', 'orphans', '--no-declared-datasources'], + empty: (doc) => { + expect(doc).toMatchObject({ mode: 'report', plan: { refusal: null, counts: { total: 0, deletable: 0 } } }); + // The union was enumerated, not gapped: an absent table holds no reference. + for (const family of Object.values(doc.plan.families) as Array<{ status: string }>) { + expect(family.status).toBe('enumerated'); + } + }, + humanEmpty: /Report only — nothing was written or deleted/, + tables: ['sys_secret', 'sys_setting', 'sys_metadata'], + work: (doc) => expect(doc.plan.counts.total).toBe(1), + }, + { + name: 'secret rewrap', + argv: ['secret', 'rewrap', '--no-declared-datasources'], + empty: (doc) => { + expect(doc).toMatchObject({ mode: 'dry-run', report: { refusal: null, counts: { total: 0, rewrap: 0 } } }); + for (const family of Object.values(doc.report.families) as Array<{ status: string }>) { + expect(family.status).toBe('enumerated'); + } + }, + humanEmpty: /Dry run — nothing was written/, + tables: ['sys_secret', 'sys_setting', 'sys_metadata'], + work: (doc) => expect(doc.report.counts.total).toBe(1), + }, + { + name: 'storage orphans', + argv: ['storage', 'orphans'], + empty: (doc) => expect(doc).toMatchObject({ filesScanned: 0, stranded: 0, truncated: false }), + humanEmpty: /Nothing stranded on this deployment/, + tables: ['sys_file', 'sys_attachment'], + work: (doc) => expect(doc).toMatchObject({ filesScanned: 1, stranded: 1 }), + }, +]; + +/** The four doors that already answered the absent database with exit 0: they must still. */ +const ALREADY_EXIT_ZERO: ReadonlyArray<{ name: string; argv: string[] }> = [ + { name: 'migrate files-to-references', argv: ['migrate', 'files-to-references', '--json'] }, + { name: 'migrate summary-nulls', argv: ['migrate', 'summary-nulls', '--json'] }, + { name: 'migrate multi-value-columns', argv: ['migrate', 'multi-value-columns', '--json'] }, + // `duplicates` has no `--json`: it always prints its one JSON document. + { name: 'migrate duplicates', argv: ['migrate', 'duplicates'] }, +]; + +describe('[#21552] the rest of the family: a project with no database yet is empty work, exit 0', () => { + const familyAbsentJson: Record = {}; + const familyAbsentHuman: Record = {}; + const familyBootedJson: Record = {}; + const alreadyZero: Record = {}; + let familyDb: string; + + beforeAll(async () => { + familyDb = join(dir, 'data', 'family.db'); + const seed = spawnSync(process.execPath, ['--input-type=module', '-e', SEED_FAMILY_CHILD], { + cwd: CLI_ROOT, + env: childEnv({ + OS_ARTIFACT_PATH: join(dir, 'dist', 'objectstack.json'), + OS_SECRET_KEY: '0e2e'.repeat(16), + FIXTURE_PROJECT: dir, + FIXTURE_DB: familyDb, + }), + encoding: 'utf8', + timeout: RUN_BUDGET_MS, + }); + if (seed.status !== 0 || !String(seed.stderr).includes('[fixture] seeded')) { + throw new Error(`the family control database was not seeded (status ${seed.status})\n${seed.stdout}\n${seed.stderr}`); + } + + for (const door of DOORS) { + familyAbsentJson[door.name] = await runCli([...door.argv, '--json'], absentDb); + familyAbsentHuman[door.name] = await runCli(door.argv, absentDb); + familyBootedJson[door.name] = await runCli([...door.argv, '--json'], familyDb); + } + for (const control of ALREADY_EXIT_ZERO) { + alreadyZero[control.name] = await runCli(control.argv, absentDb); + } + }, HOOK_TIMEOUT_MS); + + it.each(DOORS.map((d) => [d.name, d] as const))('%s --json: empty work, exit 0, no refused read of its own tables', (name, door) => { + const run = familyAbsentJson[name]; + expect(run.code, run.stderr).toBe(0); + door.empty(JSON.parse(run.stdout)); + expect(refusedReads(run, door.tables), run.stderr).toEqual([]); + // Say so: the table that was answered without a read is named, on stderr. + expect(run.stderr).toMatch(/has no table in this database yet|have no table in this database yet/); + }); + + it.each(DOORS.map((d) => [d.name, d] as const))('%s (human): exit 0 on the empty-work sentence', (name, door) => { + const run = familyAbsentHuman[name]; + expect(run.code, run.stderr).toBe(0); + expect(run.stdout).toMatch(door.humanEmpty); + expect(run.stdout).toMatch(/has no table in this database yet|have no table in this database yet/); + }); + + it('no door brought a database file into existence', () => { + expect(existsSync(absentDb)).toBe(false); + }); + + it.each(DOORS.map((d) => [d.name, d] as const))('%s: the control, a booted database, is READ and its row reported', (name, door) => { + const run = familyBootedJson[name]; + expect(run.code, run.stderr).toBe(0); + door.work(JSON.parse(run.stdout)); + // Nothing was answered from the absence measurement: every table is there. + expect(run.stderr).not.toMatch(/have no table in this database yet|has no table in this database yet/); + }); + + it.each(ALREADY_EXIT_ZERO.map((c) => [c.name] as const))('%s: already exited 0 on the absent database, and still does', (name) => { + const run = alreadyZero[name]; + expect(run.code, run.stderr).toBe(0); + expect(() => JSON.parse(run.stdout)).not.toThrow(); + }); +}); diff --git a/packages/cli/src/commands/migrate/preview-read-only.integration.test.ts b/packages/cli/src/commands/migrate/preview-read-only.integration.test.ts index 8a0fd369d02..e96e99c6b44 100644 --- a/packages/cli/src/commands/migrate/preview-read-only.integration.test.ts +++ b/packages/cli/src/commands/migrate/preview-read-only.integration.test.ts @@ -24,7 +24,8 @@ * 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, - * and exits 1 with the refusal its changeset declared (#21391). + * and answers empty work with exit 0: the boot measured the table absent, + * so the preview does not read it (#21552; #21391 had it refuse with exit 1). * * ## The driver axis * @@ -479,34 +480,38 @@ for (const cell of DIALECT_CELLS) { } }, 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 () => { + // [#21552] The edge #21349's changeset declared BREAKING, and #21391 pinned + // as a refusal: a preview whose database lacks the table it reads used to + // create the table and answer "nothing to examine" with exit 0, then + // refused with exit 1 and a driver message. The read-only boot has + // already measured which tables the database lacks, so the preview does + // not read them: a table that does not exist holds nothing, and the + // preview says so with exit 0. Both halves are asserted: the exit code a + // script reads, and the empty-work document the payload carries. + it('meta --stored without --apply on a database that does not exist answers empty work with exit 0', 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'"); + expect(exitCode).toBe(0); + expect(payload).toMatchObject({ apply: false, scanned: 0, pending: 0, failed: 0, rows: [], clean: true }); + expect(payload.error).toBeUndefined(); }, cell.timeout); // [#21207] The audit reads a third table: the decision-audit trail, whose - // conflict notes named stored content hashes. The #21391 intent is - // unchanged — EVERY audited table is counted unread — so the expected set - // is the whole audited set, stated literally: a widening that is not - // carried here turns this case red instead of passing on a stale count. - it('audit-metadata-bodies without --apply on a database that does not exist exits 1 with every audited table counted unread', async () => { + // conflict notes named stored content hashes. EVERY audited table is in + // the report, so the expected set is the whole audited set, stated + // literally: a widening that is not carried here turns this case red + // instead of passing on a stale count. None of them counts as an unread + // table (`failures`): each was measured absent, and a table that does not + // exist holds no copy to rewrite. + it('audit-metadata-bodies without --apply on a database that does not exist answers empty work with exit 0 over every audited table', async () => { const absent = join(fixture!.dir, 'data', 'never-started.db'); const { payload, exitCode } = await runJson(auditBodies, ['--database-url', `file:${absent}`]); const audited = ['sys_activity', 'sys_audit_log', 'sys_metadata_audit']; - expect(exitCode).toBe(1); + expect(exitCode).toBe(0); expect(payload.apply).toBe(false); - // `failures` counts the tables whose rows were NOT examined. - expect(payload.report.failures).toBe(audited.length); + expect(payload.report.failures).toBe(0); expect(payload.report.scanned).toBe(0); expect(Object.keys(payload.report.byObject).sort()).toEqual(audited); }, cell.timeout); From 8277203599c0bb96e0e89823d374fae09203c238 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 08:23:49 +0000 Subject: [PATCH 4/7] fix(cli): the six read-only data doors answer a project with no database yet with empty work (WIP: docs, changeset, helper test) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .../21552-absent-database-family-closeout.md | 29 ++++ content/docs/deployment/cli.mdx | 22 ++- ...mmands.absent-database.integration.test.ts | 5 +- .../cli/src/utils/absent-table-reads.test.ts | 138 ++++++++++++++++++ 4 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 .changeset/21552-absent-database-family-closeout.md create mode 100644 packages/cli/src/utils/absent-table-reads.test.ts diff --git a/.changeset/21552-absent-database-family-closeout.md b/.changeset/21552-absent-database-family-closeout.md new file mode 100644 index 00000000000..482b2196980 --- /dev/null +++ b/.changeset/21552-absent-database-family-closeout.md @@ -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. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index f37d4c27649..05ff5d24564 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -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) @@ -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 diff --git a/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts b/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts index 94fc88f546b..569300ce00b 100644 --- a/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts +++ b/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts @@ -442,7 +442,10 @@ const DOORS: readonly Door[] = [ tables: ['sys_audit_log', 'sys_activity', 'sys_metadata_audit'], work: (doc) => { expect(doc.report.failures).toBe(0); - expect(doc.report.byObject.sys_audit_log).toMatchObject({ scanned: 1, rewritten: 1 }); + // The seeded cleartext copy is the one row to rewrite. The audit writer's own + // copy of the control's `sys_metadata` insert is read too, and is already clean. + expect(doc.report.byObject.sys_audit_log.scanned).toBeGreaterThanOrEqual(1); + expect(doc.report.byObject.sys_audit_log.rewritten).toBe(1); }, }, { diff --git a/packages/cli/src/utils/absent-table-reads.test.ts b/packages/cli/src/utils/absent-table-reads.test.ts new file mode 100644 index 00000000000..ba7314e6ca5 --- /dev/null +++ b/packages/cli/src/utils/absent-table-reads.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { absentTableReads, secretUnionReadView } from './absent-table-reads.js'; +import type { SecretReferenceEngineLike } from './secret-reference-union.js'; + +/** A stack that measured exactly `tables` absent. */ +const stackMissing = (...tables: string[]) => ({ tableAbsent: (name: string) => tables.includes(name) }); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('absentTableReads', () => { + it('answers from the stack and records only the tables it answered true for', () => { + const reads = absentTableReads(stackMissing('sys_secret', 'sys_file')); + + expect(reads.line()).toBeNull(); + expect(reads.absent('sys_secret')).toBe(true); + expect(reads.absent('sys_setting')).toBe(false); + expect(reads.absent('sys_file')).toBe(true); + expect(reads.absent('sys_secret')).toBe(true); + + // Each table is named once, sorted, and the table that was read is not named. + expect(reads.line()).toBe( + '2 object(s) have no table in this database yet, so nothing is stored in them and they were not read: sys_file, sys_secret.', + ); + }); + + it('says nothing when no table was answered without a read', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const reads = absentTableReads(stackMissing()); + + expect(reads.absent('sys_secret')).toBe(false); + reads.notice(true); + reads.notice(false); + + expect(log).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + it('under --json the line goes to stderr and stdout stays a single document', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const reads = absentTableReads(stackMissing('sys_file')); + reads.absent('sys_file'); + + reads.notice(true); + + expect(error).toHaveBeenCalledTimes(1); + expect(String(error.mock.calls[0]?.[0])).toContain('sys_file'); + expect(log).not.toHaveBeenCalled(); + }); + + it('in human mode the line goes to stdout', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const reads = absentTableReads(stackMissing('sys_file')); + reads.absent('sys_file'); + + reads.notice(false); + + expect(log).toHaveBeenCalledTimes(1); + expect(String(log.mock.calls[0]?.[0])).toContain('sys_file'); + expect(error).not.toHaveBeenCalled(); + }); +}); + +describe('secretUnionReadView', () => { + function engineWith(overrides: Partial = {}) { + const find = vi.fn(async (object: string) => [{ id: `${object}-row` }]); + const getDriverForObject = vi.fn((_name: string) => ({ find })); + const engine: SecretReferenceEngineLike = { + getConfigs: () => ({}), + getDriverForObject, + ...overrides, + }; + return { engine, find, getDriverForObject }; + } + + it('answers a read of an absent table with no rows, and never issues it', async () => { + const { engine, find } = engineWith(); + const reads = absentTableReads(stackMissing('sys_setting')); + const view = secretUnionReadView(engine, reads); + + await expect(view.getDriverForObject('sys_setting')!.find('sys_setting', {})).resolves.toEqual([]); + expect(find).not.toHaveBeenCalled(); + expect(reads.line()).toContain('sys_setting'); + }); + + it('still issues the read of a table the boot did not measure absent, and returns what it returned', async () => { + const { engine, find } = engineWith(); + const view = secretUnionReadView(engine, absentTableReads(stackMissing('sys_setting'))); + + await expect(view.getDriverForObject('sys_secret')!.find('sys_secret', { fields: ['id'] })).resolves.toEqual([ + { id: 'sys_secret-row' }, + ]); + expect(find).toHaveBeenCalledWith('sys_secret', { fields: ['id'] }, undefined); + }); + + it('lets a refused read of a present table through: nothing is demoted to an empty answer', async () => { + const refusal = new Error('The database refused to run this query'); + const { engine } = engineWith({ + getDriverForObject: () => ({ find: async () => { throw refusal; } }), + }); + const view = secretUnionReadView(engine, absentTableReads(stackMissing('sys_setting'))); + + await expect(view.getDriverForObject('sys_secret')!.find('sys_secret', {})).rejects.toBe(refusal); + }); + + it('keeps an object with no driver as no driver, so the union still gaps on it', () => { + const { engine } = engineWith({ getDriverForObject: () => undefined }); + const view = secretUnionReadView(engine, absentTableReads(stackMissing())); + + expect(view.getDriverForObject('sys_secret')).toBeUndefined(); + }); + + it('carries listDatasourceDefs only when the engine has it: its absence is a declared gap', () => { + const without = secretUnionReadView(engineWith().engine, absentTableReads(stackMissing())); + expect('listDatasourceDefs' in without).toBe(false); + + const defs = [{ name: 'ds' }]; + const withDefs = secretUnionReadView( + engineWith({ listDatasourceDefs: () => defs }).engine, + absentTableReads(stackMissing()), + ); + expect(withDefs.listDatasourceDefs?.()).toEqual(defs); + }); + + it('reads the registered objects from the engine on every call', () => { + const configs: ReturnType = {}; + const { engine } = engineWith({ getConfigs: () => configs }); + const view = secretUnionReadView(engine, absentTableReads(stackMissing())); + + expect(view.getConfigs()).toBe(configs); + }); +}); From 673e986ee4e83e47fd02dff04481c27c0be5de8d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 08:31:27 +0000 Subject: [PATCH 5/7] wip(cli): a rotation-managed table is read, not believed absent (WIP) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .../commands/migrate/audit-metadata-bodies.ts | 13 ++- ...mmands.absent-database.integration.test.ts | 20 ++++- packages/cli/src/commands/secret/orphans.ts | 2 +- packages/cli/src/commands/secret/rewrap.ts | 2 +- packages/cli/src/commands/storage/orphans.ts | 2 +- .../cli/src/utils/absent-table-reads.test.ts | 72 ++++++++++++++++- packages/cli/src/utils/absent-table-reads.ts | 81 +++++++++++++++---- 7 files changed, 167 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/commands/migrate/audit-metadata-bodies.ts b/packages/cli/src/commands/migrate/audit-metadata-bodies.ts index eff3eb08da3..dd9e4901c69 100644 --- a/packages/cli/src/commands/migrate/audit-metadata-bodies.ts +++ b/packages/cli/src/commands/migrate/audit-metadata-bodies.ts @@ -188,12 +188,17 @@ export default class MigrateAuditMetadataBodies extends Command { // 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. - // ⛔ Only a table the boot MEASURED absent: any other refused read is + // ⚠️ `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); + const reads = absentTableReads(stack, (object) => engine.getObject(object)); const readView: Pick = { - find: (object, query, options) => - reads.absent(object) ? Promise.resolve([]) : engine.find(object, query, options), + 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), diff --git a/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts b/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts index 569300ce00b..379f4ba7343 100644 --- a/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts +++ b/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts @@ -346,7 +346,10 @@ describe('[#21529] the control: a booted database is read, and its work is repor * - `sys_account`: a two-row table in the legacy shape, with its `issuer` * column. The door's boot composes no auth plugin, so the table is made * here by hand, the way a deployment that ran the auth plugin left it; - * - `sys_audit_log`: an old audit copy of a datasource body, in cleartext; + * - `sys_audit_log` and `sys_activity`: an old audit copy of a datasource + * body, in cleartext, in each. `sys_activity` is rotation-managed, so its + * base name is a VIEW over a `sys_activity__r` shard table, and the + * boot's deferred sync lists it as a table to create all the same; * - `sys_metadata`: one stored `object` row, already on protocol; * - `sys_secret`: one row no producer references; * - `sys_file`: one committed attachments-scope file nothing holds. @@ -397,6 +400,18 @@ await k('sys_audit_log').insert({ metadata: JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://db.turso.io', encryptionKey: 'cleartext-21552' } }), }), }); +const shard = (await k.raw("select name from sqlite_master where type = 'table' and name glob 'sys_activity__r*'"))[0]; +if (!shard) throw new Error('the control has no sys_activity shard: the rotation did not run'); +await k(shard.name).insert({ + id: 'act_21552', object_name: 'sys_metadata', record_id: 'm_21552', action: 'create', + metadata: JSON.stringify({ + old: null, + new: { + id: 'm_21552', name: 'ds', type: 'datasource', scope: 'platform', + metadata: JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://db.turso.io', encryptionKey: 'cleartext-21552-activity' } }), + }, + }), +}); await k('sys_secret').insert({ id: 'sec_21552', namespace: 'os21552', key: 'orphan', kms_key_id: 'local', alg: 'aes-256-gcm', version: 1, ciphertext: 'not-a-real-ciphertext', }); @@ -446,6 +461,9 @@ const DOORS: readonly Door[] = [ // copy of the control's `sys_metadata` insert is read too, and is already clean. expect(doc.report.byObject.sys_audit_log.scanned).toBeGreaterThanOrEqual(1); expect(doc.report.byObject.sys_audit_log.rewritten).toBe(1); + // The rotation-managed table's rows are READ, though the boot listed its base name as a + // table to create: skipping it would answer "nothing to rewrite" over this row. + expect(doc.report.byObject.sys_activity).toMatchObject({ scanned: 1, rewritten: 1 }); }, }, { diff --git a/packages/cli/src/commands/secret/orphans.ts b/packages/cli/src/commands/secret/orphans.ts index a2a42a49d95..1802837cf68 100644 --- a/packages/cli/src/commands/secret/orphans.ts +++ b/packages/cli/src/commands/secret/orphans.ts @@ -279,7 +279,7 @@ export default class SecretOrphans extends Command { // real; its one write goes through the unwrapped driver. // ⛔ Only a table the boot MEASURED absent: any other refused read still // lands in the catch below, and an empty answer is never invented for it. - const reads = absentTableReads(stack); + const reads = absentTableReads(stack, (object) => engine.getConfigs()[object]); const rawSecrets: Record[] = reads.absent('sys_secret') ? [] : await secretDriver.find('sys_secret', {}); diff --git a/packages/cli/src/commands/secret/rewrap.ts b/packages/cli/src/commands/secret/rewrap.ts index 83a62b16a93..94222576227 100644 --- a/packages/cli/src/commands/secret/rewrap.ts +++ b/packages/cli/src/commands/secret/rewrap.ts @@ -214,7 +214,7 @@ export default class SecretRewrap extends Command { // real; its write goes through the unwrapped driver. // ⛔ Only a table the boot MEASURED absent: any other refused read still // lands in the catch below, and an empty answer is never invented for it. - const reads = absentTableReads(stack); + const reads = absentTableReads(stack, (object) => engine.getConfigs()[object]); const secretRows: Record[] = reads.absent('sys_secret') ? [] : await secretDriver.find('sys_secret', {}); diff --git a/packages/cli/src/commands/storage/orphans.ts b/packages/cli/src/commands/storage/orphans.ts index 8deac1bec13..3c475a8d9da 100644 --- a/packages/cli/src/commands/storage/orphans.ts +++ b/packages/cli/src/commands/storage/orphans.ts @@ -160,7 +160,7 @@ export default class StorageOrphans extends Command { // throws into the catch below and still exits 1. const reads = absentTableReads(stack); const readView: StrandedOrphanInventoryEngine = { - find: (object, query) => (reads.absent(object) ? Promise.resolve([]) : engine.find(object, query)), + find: (object, query) => reads.rows(object, () => engine.find(object, query)), }; const report = await inventoryStrandedFileOrphans(readView, { maxCandidates: flags['max-candidates'], diff --git a/packages/cli/src/utils/absent-table-reads.test.ts b/packages/cli/src/utils/absent-table-reads.test.ts index ba7314e6ca5..3dc7e5fa5bc 100644 --- a/packages/cli/src/utils/absent-table-reads.test.ts +++ b/packages/cli/src/utils/absent-table-reads.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, vi, afterEach } from 'vitest'; -import { absentTableReads, secretUnionReadView } from './absent-table-reads.js'; +import { absentTableReads, isRotationManaged, secretUnionReadView } from './absent-table-reads.js'; import type { SecretReferenceEngineLike } from './secret-reference-union.js'; /** A stack that measured exactly `tables` absent. */ @@ -23,7 +23,7 @@ describe('absentTableReads', () => { // Each table is named once, sorted, and the table that was read is not named. expect(reads.line()).toBe( - '2 object(s) have no table in this database yet, so nothing is stored in them and they were not read: sys_file, sys_secret.', + '2 object(s) have no table in this database yet, so nothing is stored in them and they were read as no rows: sys_file, sys_secret.', ); }); @@ -67,6 +67,74 @@ describe('absentTableReads', () => { }); }); +describe('absentTableReads: a rotation-managed object is not measurable by the boot', () => { + const ROTATED = { lifecycle: { storage: { strategy: 'rotation', shards: 14, unit: 'day' } } }; + const schemaOf = (object: string) => (object === 'sys_activity' ? ROTATED : { name: object }); + /** A refusal shaped like the driver's: the dialect's own text sits on the `cause`. */ + const missing = (table: string) => + Object.assign(new Error('The database refused to run this query'), { + code: 'DATABASE_ERROR', + cause: new Error(`SQLITE_ERROR: no such table: ${table}`), + }); + + it('recognises a rotation declaration, and nothing else', () => { + expect(isRotationManaged(ROTATED)).toBe(true); + expect(isRotationManaged({ lifecycle: { storage: { strategy: 'ttl' } } })).toBe(false); + expect(isRotationManaged({ lifecycle: {} })).toBe(false); + expect(isRotationManaged(undefined)).toBe(false); + expect(isRotationManaged(null)).toBe(false); + }); + + it('never believes the measurement for it: the base name is a view, which the boot lists as a table to create', async () => { + // The stack answers "absent" for a table that holds rows (the view over the shards). + const reads = absentTableReads(stackMissing('sys_activity', 'sys_file'), schemaOf); + const read = vi.fn(async () => [{ id: 'activity-row' }]); + + expect(reads.absent('sys_activity')).toBe(false); + await expect(reads.rows('sys_activity', read)).resolves.toEqual([{ id: 'activity-row' }]); + expect(read).toHaveBeenCalledTimes(1); + expect(reads.line()).toBeNull(); + }); + + it('reads its missing-table refusal, for that object alone, as no rows', async () => { + const reads = absentTableReads(stackMissing(), schemaOf); + + await expect(reads.rows('sys_activity', async () => { throw missing('sys_activity'); })).resolves.toEqual([]); + expect(reads.line()).toContain('sys_activity'); + }); + + it('lets any other refusal of it through, a different relation included', async () => { + const reads = absentTableReads(stackMissing(), schemaOf); + const other = missing('some_other_table'); + const fault = new Error('database is locked'); + + await expect(reads.rows('sys_activity', async () => { throw other; })).rejects.toBe(other); + await expect(reads.rows('sys_activity', async () => { throw fault; })).rejects.toBe(fault); + expect(reads.line()).toBeNull(); + }); + + it('does not soften the refusal of an ordinary table, even a missing-table one: the boot is the only judge there', async () => { + const reads = absentTableReads(stackMissing(), schemaOf); + const refusal = missing('sys_file'); + + await expect(reads.rows('sys_file', async () => { throw refusal; })).rejects.toBe(refusal); + }); + + it('answers an ordinary table the boot measured absent without issuing the read', async () => { + const reads = absentTableReads(stackMissing('sys_file'), schemaOf); + const read = vi.fn(async () => [{ id: 'never' }]); + + await expect(reads.rows('sys_file', read)).resolves.toEqual([]); + expect(read).not.toHaveBeenCalled(); + }); + + it('a schema lookup that throws reads as "not rotation-managed", never as a crash', async () => { + const reads = absentTableReads(stackMissing('sys_file'), () => { throw new Error('registry gone'); }); + + expect(reads.absent('sys_file')).toBe(true); + }); +}); + describe('secretUnionReadView', () => { function engineWith(overrides: Partial = {}) { const find = vi.fn(async (object: string) => [{ id: `${object}-row` }]); diff --git a/packages/cli/src/utils/absent-table-reads.ts b/packages/cli/src/utils/absent-table-reads.ts index f68dbcf2fad..ba21169dbbf 100644 --- a/packages/cli/src/utils/absent-table-reads.ts +++ b/packages/cli/src/utils/absent-table-reads.ts @@ -1,5 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { isMissingTableError } from '@objectstack/types'; import { printInfo } from './format.js'; import type { SchemaStack } from './schema-migrate.js'; import type { SecretReferenceEngineLike } from './secret-reference-union.js'; @@ -15,19 +16,40 @@ import type { SecretReferenceEngineLike } from './secret-reference-union.js'; * fault and exit 1 on a project whose database does not exist yet. A table that * does not exist stores nothing, so the true answer to a read of it is no rows. * - * This is the one place a door asks. It wraps {@link SchemaStack.tableAbsent} - * and nothing else: ⛔ it is not a second mechanism and it recognises no refused - * read, so a table that exists but lacks a column (`add_columns`), or any other - * refused read, is still issued and still reported as it always was. A write - * mode boots plain, so `tableAbsent` is `false` there and every read is real. + * This is the one place a door asks. It wraps {@link SchemaStack.tableAbsent}: + * ⛔ it recognises no refused read of an ordinary table, so a table that exists + * but lacks a column (`add_columns`), or any other refused read, is still issued + * and still reported as it always was. A write mode boots plain, so + * `tableAbsent` is `false` there and every read is real. + * + * ## The one table the boot cannot measure: a rotation-managed object + * + * An object declared with `lifecycle.storage.strategy: 'rotation'` + * (`sys_activity`) is physically time-sharded: its rows live in + * `__r` tables and its base name is a read VIEW over them. The + * deferred sync asks the driver `hasTable()`, and a view is not a table, + * so on SQLite it lists the base as `create_table` on a database that serves it + * perfectly well, and `tableAbsent` answers `true` for a table that holds rows. + * Believing it would answer "nothing to rewrite" over rows the command exists + * to reach. So for a rotation-managed object the measurement is not consulted: + * the read is issued, and only its missing-table refusal, recognised with the + * shared `isMissingTableError` predicate for that object alone, reads as no + * rows. The caller says which objects those are with `schemaOf`. */ export interface AbsentTableReads { /** * Did the boot measure `object`'s table absent? Ask BEFORE the read; a `true` - * answer is recorded, so {@link notice} can name the table that was not read. + * answer is recorded, so {@link notice} can name the table. Always `false` + * for a rotation-managed object, which the boot cannot measure. */ absent(object: string): boolean; - /** The line naming every table that was answered without a read, or `null` when none was. */ + /** + * Issue `read` for `object`, or answer no rows for it without issuing it + * ({@link absent}), or, for a rotation-managed object only, answer no rows + * for the refusal of a table that is not there. + */ + rows(object: string, read: () => Promise): Promise; + /** The line naming every table that was read as no rows, or `null` when none was. */ line(): string | null; /** * Say so: on stdout in human mode, on stderr under `--json`, where stdout @@ -36,18 +58,48 @@ export interface AbsentTableReads { notice(json: boolean): void; } -export function absentTableReads(stack: Pick): AbsentTableReads { +/** Is this registered object schema declared rotation-managed? */ +export function isRotationManaged(schema: unknown): boolean { + const storage = (schema as { lifecycle?: { storage?: { strategy?: unknown } } } | null | undefined) + ?.lifecycle?.storage; + return storage?.strategy === 'rotation'; +} + +export function absentTableReads( + stack: Pick, + /** The registered schema of an object, when the caller can look one up. */ + schemaOf?: (object: string) => unknown, +): AbsentTableReads { const notRead = new Set(); + const rotationManaged = (object: string): boolean => { + try { + return isRotationManaged(schemaOf?.(object)); + } catch { + return false; + } + }; + const absent = (object: string): boolean => { + if (rotationManaged(object) || !stack.tableAbsent(object)) return false; + notRead.add(object); + return true; + }; const line = (): string | null => notRead.size > 0 ? `${notRead.size} object(s) have no table in this database yet, so nothing is stored in ` + - `them and they were not read: ${[...notRead].sort().join(', ')}.` + `them and they were read as no rows: ${[...notRead].sort().join(', ')}.` : null; return { - absent: (object) => { - if (!stack.tableAbsent(object)) return false; - notRead.add(object); - return true; + absent, + rows: async (object, read) => { + if (absent(object)) return []; + if (!rotationManaged(object)) return read(); + try { + return await read(); + } catch (error) { + if (!isMissingTableError(error, object)) throw error; + notRead.add(object); + return []; + } }, line, notice: (json) => { @@ -80,8 +132,7 @@ export function secretUnionReadView( const driver = engine.getDriverForObject(objectName); if (!driver) return driver; return { - find: (object, query, options) => - reads.absent(object) ? Promise.resolve([]) : driver.find(object, query, options), + find: (object, query, options) => reads.rows(object, () => driver.find(object, query, options)), }; }, ...(typeof engine.listDatasourceDefs === 'function' From 66ec00c8c5a4632fa5e97ea620f06f7000d50c25 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 08:36:20 +0000 Subject: [PATCH 6/7] wip(cli): pin the rotation-managed table read on the control (WIP) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .../data-commands.absent-database.integration.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts b/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts index 379f4ba7343..dcb401837b7 100644 --- a/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts +++ b/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts @@ -403,7 +403,7 @@ await k('sys_audit_log').insert({ const shard = (await k.raw("select name from sqlite_master where type = 'table' and name glob 'sys_activity__r*'"))[0]; if (!shard) throw new Error('the control has no sys_activity shard: the rotation did not run'); await k(shard.name).insert({ - id: 'act_21552', object_name: 'sys_metadata', record_id: 'm_21552', action: 'create', + id: 'act_21552', timestamp: new Date().toISOString(), type: 'create', summary: 'created', object_name: 'sys_metadata', record_id: 'm_21552', metadata: JSON.stringify({ old: null, new: { @@ -463,7 +463,8 @@ const DOORS: readonly Door[] = [ expect(doc.report.byObject.sys_audit_log.rewritten).toBe(1); // The rotation-managed table's rows are READ, though the boot listed its base name as a // table to create: skipping it would answer "nothing to rewrite" over this row. - expect(doc.report.byObject.sys_activity).toMatchObject({ scanned: 1, rewritten: 1 }); + expect(doc.report.byObject.sys_activity.scanned).toBeGreaterThanOrEqual(1); + expect(doc.report.byObject.sys_activity.rewritten).toBe(1); }, }, { From 69a1689f00764c05bed842224ae205b9100e0874 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 10:10:08 +0000 Subject: [PATCH 7/7] test(cli): the secret guards' stack doubles carry SchemaStack.tableAbsent, as the real boot returns it The two mocked-boot suites built a stack with only kernel and shutdown, so the doors' first ask (tableAbsent) threw 'stack.tableAbsent is not a function' and every case fell into the scan_failed catch. The doubles now follow the real SchemaStack shape; rewrap.guards also pins the not-asked dry run. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .../commands/secret/orphans.guards.test.ts | 3 ++ .../src/commands/secret/rewrap.guards.test.ts | 50 ++++++++++++++++--- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/secret/orphans.guards.test.ts b/packages/cli/src/commands/secret/orphans.guards.test.ts index 210ce0127fc..d6669b3aeb5 100644 --- a/packages/cli/src/commands/secret/orphans.guards.test.ts +++ b/packages/cli/src/commands/secret/orphans.guards.test.ts @@ -199,6 +199,9 @@ async function runDelete( : name === 'settings' ? { listManifests: () => SETTINGS_MANIFESTS } : undefined, }, + // `SchemaStack.tableAbsent`: nothing is deferred on a `--delete` boot, which is + // the plain one, so no table is measured absent. + tableAbsent: () => false, shutdown: async () => { /* nothing was booted */ }, } as never; }); diff --git a/packages/cli/src/commands/secret/rewrap.guards.test.ts b/packages/cli/src/commands/secret/rewrap.guards.test.ts index d534d3cd6bd..c1b2aefdf90 100644 --- a/packages/cli/src/commands/secret/rewrap.guards.test.ts +++ b/packages/cli/src/commands/secret/rewrap.guards.test.ts @@ -69,13 +69,24 @@ function freshRows(): { secrets: Row[]; settings: Row[] } { interface Harness { secrets: Row[]; writes: Array<{ where: Row; data: Row }>; + /** Every table a driver read was issued for, in order. */ + reads: string[]; } -/** Wire the mocked boot to a fake engine over `rows`. */ -function wireBoot(rows: { secrets: Row[]; settings: Row[] }, opts: { conditionalWrite?: boolean } = {}): Harness { - const harness: Harness = { secrets: rows.secrets, writes: [] }; +/** + * Wire the mocked boot to a fake engine over `rows`. + * + * `absent` names the tables the boot MEASURED absent, which is what the stack's + * `tableAbsent` answers (`SchemaStack.tableAbsent`). The default is none: every + * table exists, as on a plain `--apply` boot, where nothing is deferred. + */ +function wireBoot( + rows: { secrets: Row[]; settings: Row[] }, + opts: { conditionalWrite?: boolean; absent?: readonly string[] } = {}, +): Harness { + const harness: Harness = { secrets: rows.secrets, writes: [], reads: [] }; const secretDriver: Record = { - async find() { return harness.secrets.map((r) => ({ ...r })); }, + async find() { harness.reads.push('sys_secret'); return harness.secrets.map((r) => ({ ...r })); }, }; if (opts.conditionalWrite !== false) { secretDriver.updateMany = async (_object: string, query: { where: Row }, data: Row) => { @@ -94,13 +105,17 @@ function wireBoot(rows: { secrets: Row[]; settings: Row[] }, opts: { conditional listDatasourceDefs: () => [], getDriverForObject: (object: string) => { if (object === 'sys_secret') return secretDriver; - if (object === 'sys_setting') return { async find() { return rows.settings.map((r) => ({ ...r })); } }; - if (object === 'sys_metadata') return { async find() { return []; } }; + if (object === 'sys_setting') { + return { async find() { harness.reads.push('sys_setting'); return rows.settings.map((r) => ({ ...r })); } }; + } + if (object === 'sys_metadata') return { async find() { harness.reads.push('sys_metadata'); return []; } }; return undefined; }, }; + const absent = new Set(opts.absent ?? []); vi.mocked(bootSchemaStack).mockResolvedValue({ kernel: { getService: (name: string) => (name === 'objectql' ? engine : undefined) }, + tableAbsent: (objectName: string) => absent.has(objectName), shutdown: async () => {}, } as never); return harness; @@ -231,6 +246,29 @@ describe('os secret rewrap — guards that stop a run before any row is opened o expect(text).not.toContain(KEY_HEX); }, 60_000); + it('a dry run over tables the boot measured absent reads none of them, and reports empty work', async () => { + const h = wireBoot(freshRows(), { absent: ['sys_secret', 'sys_setting', 'sys_metadata'] }); + const { payload, exitCode } = await run(['--no-declared-datasources']); + + // "Not asked": a table that does not exist holds nothing, so no read is issued. + expect(h.reads).toEqual([]); + expect(payload.mode).toBe('dry-run'); + expect(payload.report.counts).toEqual({ total: 0, rewrap: 0, done: 0, left: 0, refused: 0, notWritten: 0 }); + // The union is enumerated, not gapped: an absent table holds no reference. + for (const family of Object.values(payload.report.families) as Array<{ status: string }>) { + expect(family.status).toBe('enumerated'); + } + expect(payload.report.refusal).toBeNull(); + expect(exitCode).toBe(0); + expect(h.writes).toEqual([]); + + // POSITIVE CONTROL: the same rows, tables present — they are read, and the row is planned. + const present = wireBoot(freshRows()); + const ok = await run(['--no-declared-datasources']); + expect(present.reads).toContain('sys_secret'); + expect(ok.payload.report.counts.total).toBe(1); + }, 60_000); + it('an unreadable --declared-datasources file is refused before the boot, never read as []', async () => { const dir = mkdtempSync(join(tmpdir(), 'os-rewrap-ds-')); try {