From 2ad405ece2d30619b4d1d37a493447ac690317e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:44:26 +0000 Subject: [PATCH 1/4] fix(cli): a narrowed os migrate --apply records no deployment flag; an unknown --object is refused value-shapes and files-to-references recorded the deployment-level ADR-0104 flag from a run narrowed by --object, and every command in the family dropped an undeclared --object name silently, so a typo scanned nothing and read as clean. A run narrowed by --object now applies its fixes and records no flag (the files-to-references producer skips it, and the deployment-wide column step does not run); an undeclared name is refused with OBJECT_NOT_FOUND, naming it and the declared objects, before anything is scanned. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .../cli/src/commands/migrate/duplicates.ts | 25 +- .../commands/migrate/files-to-references.ts | 74 +++- .../migrate/object-scope.integration.test.ts | 400 ++++++++++++++++++ .../cli/src/commands/migrate/summary-nulls.ts | 9 +- .../cli/src/commands/migrate/value-shapes.ts | 53 ++- .../src/utils/migrate-object-scope.test.ts | 64 +++ .../cli/src/utils/migrate-object-scope.ts | 93 ++++ .../src/files-to-references-migration.test.ts | 58 +++ .../src/files-to-references-migration.ts | 32 +- 9 files changed, 783 insertions(+), 25 deletions(-) create mode 100644 packages/cli/src/commands/migrate/object-scope.integration.test.ts create mode 100644 packages/cli/src/utils/migrate-object-scope.test.ts create mode 100644 packages/cli/src/utils/migrate-object-scope.ts diff --git a/packages/cli/src/commands/migrate/duplicates.ts b/packages/cli/src/commands/migrate/duplicates.ts index 08e3fda81e3..ed0a67919dc 100644 --- a/packages/cli/src/commands/migrate/duplicates.ts +++ b/packages/cli/src/commands/migrate/duplicates.ts @@ -1,8 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { Command, Flags } from '@oclif/core'; -import { emitJson, isExitSignal } from '../../utils/format.js'; +import { emitJson, errorCodeFields, isExitSignal } from '../../utils/format.js'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; +import { refuseUndeclaredObjects } from '../../utils/migrate-object-scope.js'; // The `objectql` slot's contract (#4251) — read the registry through it rather // than erasing the lookup to `any`, so a rename breaks this at compile time // instead of silently reporting zero objects. @@ -874,7 +875,9 @@ export default class MigrateDuplicates extends Command { env: 'OS_DATABASE_URL', }), object: Flags.string({ - description: 'Restrict the scan to one object (recorded in the report, so a narrowed run cannot be mistaken for a full one)', + description: + 'Restrict the scan to one object (recorded in the report, so a narrowed run cannot be mistaken for a full ' + + 'one). A name the deployment does not declare is refused', }), }; @@ -906,6 +909,18 @@ export default class MigrateDuplicates extends Command { } try { + // [#21644] `collectScanTargets` keeps only the objects it would probe, so + // a name this registry does not declare would be dropped without a word + // and the report would read "no duplicates" over a scan of nothing. + // Refused before any probe, against the set the scan draws from. + refuseUndeclaredObjects( + flags.object === undefined ? undefined : [flags.object], + stack + .allObjects() + .map((o) => (o as { name?: unknown } | null)?.name) + .filter((name): name is string => typeof name === 'string'), + ); + const { resolveSeedTenancyExec, normalizeRows, @@ -973,7 +988,11 @@ export default class MigrateDuplicates extends Command { await emitJson(report); } catch (error: unknown) { if (isExitSignal(error)) throw error; - await emitJson({ error: 'report_failed', detail: messageOf(error) }, 1, { compact: true }); + await emitJson( + { error: 'report_failed', detail: messageOf(error), ...errorCodeFields(error) }, + 1, + { compact: true }, + ); } finally { await stack.shutdown(); } diff --git a/packages/cli/src/commands/migrate/files-to-references.ts b/packages/cli/src/commands/migrate/files-to-references.ts index 6dcb1ed1c3c..292da957a99 100644 --- a/packages/cli/src/commands/migrate/files-to-references.ts +++ b/packages/cli/src/commands/migrate/files-to-references.ts @@ -21,6 +21,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 { isNarrowedRun, narrowedFlagNote, refuseUndeclaredObjects } from '../../utils/migrate-object-scope.js'; import { describeFileColumnMoveRefusal, runFileColumnMove, @@ -41,6 +42,7 @@ import type { MediaColumnMoveScan, SqlDialectName } from '@objectstack/driver-sq interface ColumnStepOutcome { skipped: | 'gate_not_passed' + | 'narrowed_run' | 'no_sql_driver' | 'no_sql_seam' | 'driver_cannot_plan' @@ -96,11 +98,19 @@ async function confirm(question: string): Promise { * Dry run by default, and a dry run writes NOTHING — not conversions, not the * flag. Not run / not passed → files keep being retained forever: storage * cost, zero data loss. + * + * [#21644] Only a run over every object records the flag or moves the + * columns. A run narrowed by `--object` converts the named objects' values and + * stops there: the producer records no flag for it, and the column step, which + * retypes every media column in the database on the strength of the gate, + * does not run. A name the deployment does not declare is refused + * (`OBJECT_NOT_FOUND`) rather than scanned as nothing. */ export default class MigrateFilesToReferences extends Command { static override description = 'Migrate legacy file-field values to sys_file references and verify the ownership ledger (ADR-0104). ' + - 'Dry-run by default; --apply also records the deployment-level migration flag when the self-check passes.'; + 'Dry-run by default; --apply also records the deployment-level migration flag when the self-check of every ' + + 'object passes.'; static override examples = [ '$ os migrate files-to-references', @@ -117,7 +127,8 @@ export default class MigrateFilesToReferences extends Command { }), apply: Flags.boolean({ description: - 'Write the conversions and record the deployment migration flag (default is a read-only dry run)', + 'Write the conversions and record the deployment migration flag (default is a read-only dry run). ' + + 'Only a run without --object records the flag', default: false, }), yes: Flags.boolean({ char: 'y', description: 'Skip the --apply confirmation prompt', default: false }), @@ -126,7 +137,9 @@ export default class MigrateFilesToReferences extends Command { default: false, }), object: Flags.string({ - description: 'Restrict to this object (repeatable; default: every object with a file field)', + description: + 'Restrict to this object (repeatable; default: every object with a file field). A narrowed run converts ' + + 'but records no deployment flag and moves no column, and a name the deployment does not declare is refused', multiple: true, }), 'max-records': Flags.integer({ @@ -144,6 +157,7 @@ export default class MigrateFilesToReferences extends Command { const { flags } = await this.parse(MigrateFilesToReferences); const timer = createTimer(); const apply = flags.apply; + const narrowed = isNarrowedRun(flags.object); if (!flags.json) { printHeader('Migrate · files-to-references'); @@ -197,7 +211,12 @@ export default class MigrateFilesToReferences extends Command { return; } const ok = await confirm( - chalk.bold('\nConvert legacy file values and record the migration flag on this database? [y/N] '), + chalk.bold( + narrowed + ? '\nConvert legacy file values of the named object(s) on this database? ' + + 'A run narrowed by --object records no deployment flag. [y/N] ' + : '\nConvert legacy file values and record the migration flag on this database? [y/N] ', + ), ); if (!ok) { printInfo('Aborted — no changes made.'); @@ -248,6 +267,13 @@ export default class MigrateFilesToReferences extends Command { 'Run "os build" in your project root first (the migration reads dist/objectstack.json), then re-run.', ); } + // [#21644] Before anything is converted: the scan keeps only the + // candidates it covers, so a name this registry does not declare would + // be dropped without a word, and the run would read as clean. + refuseUndeclaredObjects(flags.object, loadedObjects); + const narrowedNote = isNarrowedRun(flags.object) + ? narrowedFlagNote('files-to-references', flags.object, apply) + : null; const getStorage = () => { try { // Canonical slot since #9683 (service-storage also registers the @@ -293,13 +319,18 @@ export default class MigrateFilesToReferences extends Command { engine, apply, gatePassed: result.gatePassed, + narrowed, json: flags.json, }); if (flags.json) { + if (narrowedNote) logger.info(narrowedNote); await emitJson({ database: stack.dbLabel, apply, + // [#21644] Recorded in the document, so a narrowed run cannot be + // mistaken for a full one (the shape `os migrate duplicates` keeps). + filter: narrowed ? { objects: flags.object } : null, backfill: { scannedObjects: result.backfill.scannedObjects, scannedRecords: result.backfill.scannedRecords, @@ -341,7 +372,23 @@ export default class MigrateFilesToReferences extends Command { console.log(formatFileReferenceReport(result.verify)); console.log(''); - if (result.gatePassed) { + if (narrowedNote) { + // [#21644] Every sentence below would promise the flag or the + // enforcement it turns on, and a narrowed run records neither. + if (!result.gatePassed) { + for (const failure of result.gateFailures) printError(`Gate not passed: ${failure}`); + printWarning('Fix the records listed above, then re-run.'); + } else if (apply) { + printSuccess('Self-check passed over the named object(s); their conversions are written.'); + } else if (result.backfill.converted > 0) { + printInfo( + `Dry run only — ${result.backfill.converted} value(s) would be converted. Re-run with --apply to convert.`, + ); + } else { + printInfo('Data in the named object(s) is already in reference form.'); + } + printInfo(narrowedNote); + } else if (result.gatePassed) { if (apply) { printSuccess( 'Self-check passed — deployment flag recorded (adr-0104-file-references). ' + @@ -398,15 +445,23 @@ export default class MigrateFilesToReferences extends Command { engine: unknown; apply: boolean; gatePassed: boolean; + narrowed: boolean; json: boolean; }): Promise { - const { stack, apply, gatePassed, json } = args; + const { stack, apply, gatePassed, narrowed, json } = args; if (!gatePassed) { // ⛔ The ruling's "abort unless backfill + verify report zero blocking". // Not an error of this step's own — the gate already reported why. return { skipped: 'gate_not_passed', failed: false, stampedAt: null, report: null }; } + if (narrowed) { + // [#21644] ⛔ The move retypes EVERY single-value media column in the + // database, and a narrowed gate vouched for the named objects only. Its + // stamp also requires the verified flag a narrowed run does not record, + // so running it here would move columns it then could not record. + return { skipped: 'narrowed_run', failed: false, stampedAt: null, report: null }; + } if (!stack.driver || typeof stack.driver.planMediaColumnMove !== 'function') { return { skipped: 'no_sql_driver', failed: false, stampedAt: null, report: null }; } @@ -503,7 +558,12 @@ export default class MigrateFilesToReferences extends Command { /** The human-mode half of {@link runColumnStep}. JSON mode reports the same facts. */ private renderColumnStep(outcome: ColumnStepOutcome): void { if (outcome.skipped === 'gate_not_passed' || outcome.report === null) { - if (outcome.skipped === 'no_sql_driver') { + if (outcome.skipped === 'narrowed_run') { + printInfo( + 'Column step: not run — it moves every media column in the database, so only a run without ' + + '--object authorises it.', + ); + } else if (outcome.skipped === 'no_sql_driver') { printInfo( 'Column step: not applicable — the ADR-0104 file-family column move is a SQL-driver step ' + 'and no SQL driver is active here.', diff --git a/packages/cli/src/commands/migrate/object-scope.integration.test.ts b/packages/cli/src/commands/migrate/object-scope.integration.test.ts new file mode 100644 index 00000000000..65b934f2db2 --- /dev/null +++ b/packages/cli/src/commands/migrate/object-scope.integration.test.ts @@ -0,0 +1,400 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21644] The `--object` scope of the `os migrate` data-migration family, + * pinned at the public door: the CLI spawned against a real SQLite database. + * + * ## The measured defect + * + * With one off-shape value stored, `os migrate value-shapes --object + * --apply --yes --json` exited 0 with `scannedObjects: []` and + * recorded the deployment-level `adr-0104-value-shapes` flag as VERIFIED. Two + * causes, one per half of this file: + * + * - every command in the family handed `--object` to its scan as the + * candidate list, and the scan silently dropped a name the deployment does + * not declare, so a typo scanned nothing and read as clean; + * - the flag-recording commands recorded the deployment's flag from a run + * that read only the named objects, so any passing subset attested data + * nobody scanned. + * + * ## The census (one row per command; the enumeration below is this table) + * + * | command | `--object` | `--apply` records a deployment flag | where | + * | --- | --- | --- | --- | + * | `value-shapes` | repeatable | `adr-0104-value-shapes` | the CLI (`recordDataMigrationRun`) | + * | `files-to-references` | repeatable | `adr-0104-file-references` | the producer, `runFilesToReferencesMigration` | + * | `summary-nulls` | repeatable | none, by design | — | + * | `duplicates` | single | none (no `--apply`; it writes nothing) | — | + * + * ## The answers pinned + * + * - A narrowed `--apply` applies its fixes and records no deployment flag, + * and leaves a flag an earlier full-scope run recorded exactly as it was. + * A full-scope `--apply` records the flag as before. + * - An unknown `--object` exits 1 with `OBJECT_NOT_FOUND`, naming the name + * and the declared objects, on all four commands, before anything is read + * or written. + * - The measured repro leaves the flag unrecorded. + * + * Every spawn runs in the hook, each against its own copy of the seeded + * database; a case only reads what a run printed and what the database holds. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, spawnSync } from 'node:child_process'; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); +/** The source entry, as `test/helpers/serve-process.ts` spawns it; `src/` cannot import that helper. */ +const CLI = resolve(HERE, '../../../bin/run-dev.js'); + +const HOOK_TIMEOUT_MS = 600_000; +const RUN_BUDGET_MS = 120_000; + +const VALUE_SHAPES_FLAG = 'adr-0104-value-shapes'; +const FILE_REFERENCES_FLAG = 'adr-0104-file-references'; + +const ARTIFACT = { + manifest: { id: 'com.example.os21644', name: 'Narrowed apply', version: '0.0.0', type: 'app' }, + objects: [ + // No covered field: declared, and nothing for any of the four to check. + { name: 'os21644_account', fields: { name: { type: 'text' } } }, + // A `location` is a structured-JSON value class: `value-shapes` walks it. + { name: 'os21644_site', fields: { name: { type: 'text' }, geo: { type: 'location' } } }, + // An `image` is a file value class: `files-to-references` walks it. + { name: 'os21644_product', fields: { name: { type: 'text' }, image: { type: 'image' } } }, + ], +}; + +/** Env that would point a boot somewhere other than the fixture. */ +const OVERRIDING_ENV = ['OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME'] as const; + +/** + * This process's environment for a child, minus the families + * `test/helpers/serve-process.ts` `childEnv()` strips (its header says why). + */ +function childEnv(overrides: Record): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (key === 'TEST' || key === 'VITEST' || key.startsWith('VITEST_') || key === 'NODE_PATH') continue; + env[key] = value; + } + for (const key of OVERRIDING_ENV) env[key] = undefined; + return { ...env, ...overrides }; +} + +/** + * A served-shape boot (DDL performed) writes one clean row per object. The + * creation-time attestation may record flags for a datastore born empty, so + * the flag table is then emptied: every run below starts from a deployment + * that has never earned a flag, the state a migration exists for. + */ +const SEED_CHILD = ` +const rt = await import('@objectstack/runtime'); +const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin'); +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 runtime.start(); +const ql = kernel.getService('objectql'); +const SYSTEM = { context: { isSystem: true } }; +await ql.insert('os21644_account', { id: 'a1', name: 'Acme' }, SYSTEM); +await ql.insert('os21644_site', { id: 's1', name: 'HQ', geo: { lat: 1, lng: 2 } }, SYSTEM); +await ql.insert('os21644_product', { id: 'p1', name: 'Widget' }, SYSTEM); +await kernel.shutdown(); +const { SqlDriver } = await import('@objectstack/driver-sql'); +const raw = new SqlDriver({ client: 'better-sqlite3', connection: { filename: process.env.FIXTURE_DB }, useNullAsDefault: true }); +await raw.knex('sys_migration').del(); +await raw.disconnect(); +process.stderr.write('[fixture] seeded\\n'); +process.exit(0); +`; + +/** The repro's off-shape value, written past the write path: a location keyed the retired way. */ +const OFF_SHAPE_CHILD = ` +const { SqlDriver } = await import('@objectstack/driver-sql'); +const raw = new SqlDriver({ client: 'better-sqlite3', connection: { filename: process.env.FIXTURE_DB }, useNullAsDefault: true }); +await raw.knex('os21644_site').where({ id: 's1' }).update({ geo: JSON.stringify({ latitude: 1, longitude: 2 }) }); +await raw.disconnect(); +process.exit(0); +`; + +/** The flag rows and the app rows, read on a connection of our own. */ +const READ_STATE_CHILD = ` +const { SqlDriver } = await import('@objectstack/driver-sql'); +const raw = new SqlDriver({ client: 'better-sqlite3', connection: { filename: process.env.FIXTURE_DB }, useNullAsDefault: true }); +const k = raw.knex; +const flags = await k('sys_migration').select('id', 'last_run_at', 'verified_at', 'blocking').orderBy('id'); +const site = await k('os21644_site').select('id', 'geo').orderBy('id'); +const product = await k('os21644_product').select('id', 'image').orderBy('id'); +await raw.disconnect(); +process.stdout.write(JSON.stringify({ flags, site, product })); +process.exit(0); +`; + +interface Run { + code: number | null; + stdout: string; + stderr: string; +} + +interface FlagRow { + id: string; + last_run_at: string | null; + verified_at: string | null; + blocking: number; +} + +interface State { + flags: FlagRow[]; + site: Array<{ id: string; geo: string | null }>; + product: Array<{ id: string; image: string | null }>; +} + +let dir: string; +let seeded: string; +let copies = 0; + +function childNode(code: string, env: Record): { status: number | null; stdout: string; stderr: string } { + const out = spawnSync(process.execPath, ['--input-type=module', '-e', code], { + cwd: CLI_ROOT, + env: childEnv({ OS_ARTIFACT_PATH: join(dir, 'dist', 'objectstack.json'), OS_SECRET_KEY: '0e2e'.repeat(16), ...env }), + encoding: 'utf8', + timeout: RUN_BUDGET_MS, + }); + return { status: out.status, stdout: String(out.stdout), stderr: String(out.stderr) }; +} + +/** A fresh copy of the seeded database, so no run sees another's flag. */ +function freshDb(): string { + const db = join(dir, 'data', `run-${++copies}.db`); + copyFileSync(seeded, db); + return db; +} + +function readState(db: string): State { + const out = childNode(READ_STATE_CHILD, { FIXTURE_DB: db }); + if (out.status !== 0) throw new Error(`could not read the fixture database (status ${out.status})\n${out.stderr}`); + return JSON.parse(out.stdout) as State; +} + +function flagRow(state: State, id: string): FlagRow | undefined { + return state.flags.find((f) => f.id === id); +} + +/** One `os migrate --database-url file:` run, in the fixture project. */ +function runCommand(command: string, argv: string[], db: string): Promise { + const args = ['migrate', command, ...argv, '--database-url', `file:${db}`]; + return new Promise((resolveRun, rejectRun) => { + const child = spawn(process.execPath, [CLI, ...args], { + cwd: dir, + env: childEnv({ + NO_COLOR: '1', + OS_ARTIFACT_PATH: join(dir, 'dist', 'objectstack.json'), + OS_SECRET_KEY: '0e2e'.repeat(16), + }), + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (c) => { stdout += String(c); }); + child.stderr.on('data', (c) => { stderr += String(c); }); + const timer = setTimeout(() => { + child.kill('SIGKILL'); + rejectRun(new Error(`os ${args.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) => { + clearTimeout(timer); + resolveRun({ code, stdout, stderr }); + }); + }); +} + +/** + * The enumeration across the four commands: the census above, as data. + * `narrowTo` is a declared object the command walks; `flag` is the + * deployment flag its full-scope `--apply` records, or `null` for none. + */ +const FAMILY = [ + { command: 'value-shapes', narrowTo: 'os21644_site', flag: VALUE_SHAPES_FLAG, apply: true }, + { command: 'files-to-references', narrowTo: 'os21644_product', flag: FILE_REFERENCES_FLAG, apply: true }, + { command: 'summary-nulls', narrowTo: 'os21644_site', flag: null, apply: true }, + { command: 'duplicates', narrowTo: 'os21644_site', flag: null, apply: false }, +] as const; +type Member = (typeof FAMILY)[number]; + +const MISSPELLED = 'os21644_sitee'; + +interface Outcome { + db: string; + run: Run; + state: State; +} + +const narrowed = new Map(); +const full = new Map(); +const unknown = new Map(); +let narrowedAfterFull: Outcome & { verifiedBefore: FlagRow | undefined }; +let unknownHuman: Run; +let reproFullDry: Run; +let repro: Outcome; +let reproRightName: Outcome; + +/** `--apply --yes --json` where the command has an apply mode; `duplicates` is always JSON and writes nothing. */ +function applyArgs(member: Member, ...scope: string[]): string[] { + return member.apply ? [...scope, '--apply', '--yes', '--json'] : scope; +} + +beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-21644-')); + mkdirSync(join(dir, 'dist'), { recursive: true }); + mkdirSync(join(dir, 'data'), { recursive: true }); + writeFileSync(join(dir, 'dist', 'objectstack.json'), JSON.stringify(ARTIFACT)); + seeded = join(dir, 'data', 'seeded.db'); + const seed = childNode(SEED_CHILD, { FIXTURE_PROJECT: dir, FIXTURE_DB: seeded }); + if (seed.status !== 0 || !seed.stderr.includes('[fixture] seeded')) { + throw new Error(`the fixture database was not seeded (status ${seed.status})\n${seed.stderr}`); + } + + for (const member of FAMILY) { + const narrowDb = freshDb(); + const narrowRun = await runCommand(member.command, applyArgs(member, '--object', member.narrowTo), narrowDb); + narrowed.set(member.command, { db: narrowDb, run: narrowRun, state: readState(narrowDb) }); + + const fullDb = freshDb(); + const fullRun = await runCommand(member.command, applyArgs(member), fullDb); + full.set(member.command, { db: fullDb, run: fullRun, state: readState(fullDb) }); + + const unknownDb = freshDb(); + const unknownRun = await runCommand(member.command, applyArgs(member, '--object', MISSPELLED), unknownDb); + unknown.set(member.command, { db: unknownDb, run: unknownRun, state: readState(unknownDb) }); + } + + // A narrowed run over a deployment that already earned the flag leaves it as it was. + const earnedDb = freshDb(); + await runCommand('value-shapes', ['--apply', '--yes', '--json'], earnedDb); + const verifiedBefore = flagRow(readState(earnedDb), VALUE_SHAPES_FLAG); + const after = await runCommand('value-shapes', ['--object', 'os21644_site', '--apply', '--yes', '--json'], earnedDb); + narrowedAfterFull = { db: earnedDb, run: after, state: readState(earnedDb), verifiedBefore }; + + unknownHuman = await runCommand('value-shapes', ['--object', MISSPELLED, '--apply', '--yes'], freshDb()); + + // The measured repro: one off-shape value stored, then a misspelled --object --apply. + const reproDb = freshDb(); + const offShape = childNode(OFF_SHAPE_CHILD, { FIXTURE_DB: reproDb }); + if (offShape.status !== 0) throw new Error(`the off-shape value was not written\n${offShape.stderr}`); + const reproRightDb = join(dir, 'data', 'repro-right.db'); + copyFileSync(reproDb, reproRightDb); + reproFullDry = await runCommand('value-shapes', ['--json'], reproDb); + const reproRun = await runCommand('value-shapes', ['--object', MISSPELLED, '--apply', '--yes', '--json'], reproDb); + repro = { db: reproDb, run: reproRun, state: readState(reproDb) }; + const rightRun = await runCommand('value-shapes', ['--object', 'os21644_site', '--apply', '--yes', '--json'], reproRightDb); + reproRightName = { db: reproRightDb, run: rightRun, state: readState(reproRightDb) }; +}, HOOK_TIMEOUT_MS); + +afterAll(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); +}); + +const FLAG_RECORDING = FAMILY.filter((m) => m.flag !== null); + +describe('[#21644] a deployment-level flag is written only by a full-scope run', () => { + it.each(FLAG_RECORDING)('$command: a narrowed --apply exits 0, reports flag null and records no flag', ({ command, flag, narrowTo }) => { + const { run, state } = narrowed.get(command)!; + expect(run.code, run.stderr).toBe(0); + const doc = JSON.parse(run.stdout); + expect(doc).toMatchObject({ apply: true, gatePassed: true, flag: null, filter: { objects: [narrowTo] } }); + expect(flagRow(state, flag!)).toBeUndefined(); + // The output says why, and names the run that records it. + expect(run.stderr).toContain(`Narrowed by --object to ${narrowTo}, so no deployment flag was recorded.`); + expect(run.stderr).toContain(`"os migrate ${command} --apply"`); + }); + + it.each(FLAG_RECORDING)('$command: a full-scope --apply records the flag verified, as before', ({ command, flag }) => { + const { run, state } = full.get(command)!; + expect(run.code, run.stderr).toBe(0); + const doc = JSON.parse(run.stdout); + expect(doc).toMatchObject({ apply: true, gatePassed: true, filter: null }); + expect(doc.flag).toMatchObject({ id: flag, blocking: 0 }); + expect(doc.flag.verified_at).toBeTruthy(); + expect(flagRow(state, flag!)?.verified_at).toBeTruthy(); + }); + + it.each(FAMILY.filter((m) => m.flag === null))('$command: records no flag, narrowed or not, as before', ({ command }) => { + for (const outcome of [narrowed.get(command)!, full.get(command)!]) { + expect(outcome.run.code, outcome.run.stderr).toBe(0); + expect(outcome.state.flags).toEqual([]); + } + }); + + it('a narrowed --apply leaves a flag an earlier full-scope run recorded exactly as it was', () => { + const { run, state, verifiedBefore } = narrowedAfterFull; + expect(run.code, run.stderr).toBe(0); + expect(JSON.parse(run.stdout)).toMatchObject({ flag: null, filter: { objects: ['os21644_site'] } }); + expect(verifiedBefore?.verified_at).toBeTruthy(); + expect(flagRow(state, VALUE_SHAPES_FLAG)).toEqual(verifiedBefore); + }); + + it('files-to-references: the deployment-wide column step does not run on a narrowed run', () => { + expect(JSON.parse(narrowed.get('files-to-references')!.run.stdout)).toMatchObject({ + columnMove: null, + columnsMovedAt: null, + }); + }); +}); + +describe('[#21644] an unknown --object is an error, never narrowed to nothing', () => { + it.each(FAMILY)('$command: exits 1 with OBJECT_NOT_FOUND, naming the name and the declared objects', ({ command }) => { + const { run } = unknown.get(command)!; + expect(run.code, run.stderr).toBe(1); + const doc = JSON.parse(run.stdout); + expect(doc.code).toBe('OBJECT_NOT_FOUND'); + const message = String(doc.detail ?? doc.error); + expect(message).toContain(`'${MISSPELLED}'`); + expect(message).toMatch(/Declared objects: [^\n]*os21644_account, os21644_product, os21644_site/); + }); + + it.each(FAMILY)('$command: refused before anything was read or written', ({ command }) => { + const { run, state } = unknown.get(command)!; + expect(state.flags).toEqual([]); + expect(state.product).toEqual([{ id: 'p1', image: null }]); + expect(run.stdout).not.toContain('scannedObjects'); + }); + + it('human mode: exits 1 and names the unknown object', () => { + expect(unknownHuman.code, unknownHuman.stderr).toBe(1); + expect(`${unknownHuman.stdout}\n${unknownHuman.stderr}`).toContain(`Object '${MISSPELLED}' not found`); + }); +}); + +describe('[#21644] the measured repro: an off-shape value plus a misspelled --object --apply', () => { + it('the control: a full-scope scan sees the off-shape value and fails the gate', () => { + expect(reproFullDry.code, reproFullDry.stderr).toBe(1); + expect(JSON.parse(reproFullDry.stdout)).toMatchObject({ gatePassed: false, scan: { blocking: 1 } }); + }); + + it('the misspelled run exits 1 with OBJECT_NOT_FOUND and leaves the flag unrecorded', () => { + expect(repro.run.code, repro.run.stderr).toBe(1); + expect(JSON.parse(repro.run.stdout).code).toBe('OBJECT_NOT_FOUND'); + expect(flagRow(repro.state, VALUE_SHAPES_FLAG)).toBeUndefined(); + }); + + it('spelled right, the narrowed run finds the value, exits 1, and still records no flag', () => { + expect(reproRightName.run.code, reproRightName.run.stderr).toBe(1); + expect(JSON.parse(reproRightName.run.stdout)).toMatchObject({ gatePassed: false, flag: null, scan: { blocking: 1 } }); + expect(flagRow(reproRightName.state, VALUE_SHAPES_FLAG)).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/migrate/summary-nulls.ts b/packages/cli/src/commands/migrate/summary-nulls.ts index 60fe01f4b2a..2c1c856d2c3 100644 --- a/packages/cli/src/commands/migrate/summary-nulls.ts +++ b/packages/cli/src/commands/migrate/summary-nulls.ts @@ -19,6 +19,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 { refuseUndeclaredObjects } from '../../utils/migrate-object-scope.js'; // Type-only, so the heavy engine package is still loaded lazily below: this is // the surface the migration actually needs, which is wider than the `objectql` // slot contract by exactly one member (`getOwnedSummaryDescriptors`). Naming it @@ -104,7 +105,9 @@ export default class MigrateSummaryNulls extends Command { default: false, }), object: Flags.string({ - description: 'Restrict to this object (repeatable; default: every object owning a count/sum roll-up)', + description: + 'Restrict to this object (repeatable; default: every object owning a count/sum roll-up). A name the ' + + 'deployment does not declare is refused', multiple: true, }), 'recompute-undefined-on-empty': Flags.string({ @@ -224,6 +227,10 @@ export default class MigrateSummaryNulls extends Command { 'Run "os build" in your project root first (the migration reads dist/objectstack.json), then re-run.', ); } + // [#21644] The walk keeps only the candidates owning a roll-up, so a name + // this registry does not declare would be dropped without a word and the + // run would report "nothing to backfill". Refused before any row is read. + refuseUndeclaredObjects(flags.object, loadedObjects); const { backfillSummaryNulls, formatSummaryBackfillReport } = await import('@objectstack/objectql'); diff --git a/packages/cli/src/commands/migrate/value-shapes.ts b/packages/cli/src/commands/migrate/value-shapes.ts index 4ec27cedf0c..9f241dc20a8 100644 --- a/packages/cli/src/commands/migrate/value-shapes.ts +++ b/packages/cli/src/commands/migrate/value-shapes.ts @@ -17,6 +17,7 @@ import { } from '../../utils/format.js'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js'; +import { isNarrowedRun, narrowedFlagNote, refuseUndeclaredObjects } from '../../utils/migrate-object-scope.js'; async function confirm(question: string): Promise { if (!process.stdin.isTTY) return false; // non-interactive → require --yes @@ -40,6 +41,11 @@ async function confirm(question: string): Promise { * `adr-0104-value-shapes` flag. That flag, never the platform version, is what * turns strict enforcement of those classes on for THIS deployment. * + * [#21644] Only a run over every object records it. A run narrowed by + * `--object` reads only the named objects, so it records no flag and says so; + * and a name the deployment does not declare is refused (`OBJECT_NOT_FOUND`) + * rather than scanned as nothing (`utils/migrate-object-scope.ts`). + * * ## No backfill, deliberately * * Unlike its sibling this run rewrites nothing. The file migration converts @@ -63,7 +69,7 @@ async function confirm(question: string): Promise { export default class MigrateValueShapes extends Command { static override description = 'Scan stored reference and structured-JSON field values against the ADR-0104 value contract. ' + - 'Read-only; --apply records the deployment-level migration flag when the scan finds zero violations.'; + 'Read-only; --apply records the deployment-level migration flag when a scan of every object finds zero violations.'; static override examples = [ '$ os migrate value-shapes', @@ -79,12 +85,15 @@ export default class MigrateValueShapes extends Command { }), apply: Flags.boolean({ description: - 'Record the deployment migration flag when the scan passes (the scan itself is always read-only)', + 'Record the deployment migration flag when the scan passes (the scan itself is always read-only). ' + + 'Only a run without --object records it', default: false, }), yes: Flags.boolean({ char: 'y', description: 'Skip the --apply confirmation prompt', default: false }), object: Flags.string({ - description: 'Restrict to this object (repeatable; default: every object with a covered field)', + description: + 'Restrict to this object (repeatable; default: every object with a covered field). A narrowed run records ' + + 'no deployment flag, and a name the deployment does not declare is refused', multiple: true, }), 'max-records': Flags.integer({ @@ -98,6 +107,7 @@ export default class MigrateValueShapes extends Command { const { flags } = await this.parse(MigrateValueShapes); const timer = createTimer(); const apply = flags.apply; + const narrowed = isNarrowedRun(flags.object); if (!flags.json) printHeader('Migrate · value-shapes'); @@ -114,14 +124,21 @@ export default class MigrateValueShapes extends Command { return; } printWarning( - 'Apply mode records this deployment\'s migration flag, which turns on strict value-shape ' + - 'enforcement. Re-run with --yes to confirm, or run without --apply to preview.', + narrowed + ? 'Apply mode was asked for, but a run narrowed by --object records no deployment flag. ' + + 'Re-run with --yes to confirm, or run without --apply to preview.' + : 'Apply mode records this deployment\'s migration flag, which turns on strict value-shape ' + + 'enforcement. Re-run with --yes to confirm, or run without --apply to preview.', ); this.exit(1); return; } const ok = await confirm( - chalk.bold('\nRecord the value-shape migration flag on this database if the scan passes? [y/N] '), + chalk.bold( + narrowed + ? '\nScan the named object(s)? A run narrowed by --object records no deployment flag. [y/N] ' + : '\nRecord the value-shape migration flag on this database if the scan passes? [y/N] ', + ), ); if (!ok) { printInfo('Aborted — nothing recorded.'); @@ -169,6 +186,10 @@ export default class MigrateValueShapes extends Command { 'Run "os build" in your project root first (the migration reads dist/objectstack.json), then re-run.', ); } + // [#21644] The scan keeps only the candidates it covers, so a name this + // registry does not declare would be dropped without a word and the run + // would read as clean. Refused here, against the set the scan draws from. + refuseUndeclaredObjects(flags.object, loadedObjects); const { scanValueShapes, valueShapeScanPassed, formatValueShapeScanReport } = await import('@objectstack/objectql'); @@ -214,8 +235,15 @@ export default class MigrateValueShapes extends Command { // apply run that passed. A failing apply run still records — deliberately: // it stamps `blocking` and clears `verified_at`, so a deployment whose data // has regressed closes its own gate rather than coasting on an old pass. + // + // [#21644] ⛔ Never on a run narrowed by `--object`, passing or failing: + // the flag attests every object's stored data, and this run read only the + // named ones. A flag an earlier full-scope run recorded is left as it was. let flag: unknown = null; - if (apply) { + const narrowedNote = isNarrowedRun(flags.object) + ? narrowedFlagNote('value-shapes', flags.object, apply) + : null; + if (apply && !narrowed) { const { recordDataMigrationRun } = await import('@objectstack/platform-objects/system'); const { VALUE_SHAPES_MIGRATION_ID } = await import('@objectstack/spec/system'); flag = await recordDataMigrationRun(engine, { @@ -240,9 +268,13 @@ export default class MigrateValueShapes extends Command { if (flags.json) { if (notStoredLine) logger.info(notStoredLine); + if (narrowedNote) logger.info(narrowedNote); await emitJson({ database: stack.dbLabel, apply, + // [#21644] Recorded in the document, so a narrowed run cannot be + // mistaken for a full one (the shape `os migrate duplicates` keeps). + filter: narrowed ? { objects: flags.object } : null, scan: report, gatePassed: passed, flag, @@ -258,7 +290,12 @@ export default class MigrateValueShapes extends Command { console.log(''); if (notStoredLine) printInfo(notStoredLine); - if (passed && apply) { + if (narrowedNote) { + if (passed) printSuccess(`Scan clean over the named object(s) (${timer.elapsed()}).`); + else printError('Scan found violations — fix the values named above, then re-run.'); + printInfo(narrowedNote); + if (!passed) this.exit(1); + } else if (passed && apply) { printSuccess( `Scan clean — recorded the deployment flag. Reference and structured-JSON value shapes ` + `are now enforced on this deployment (${timer.elapsed()}).`, diff --git a/packages/cli/src/utils/migrate-object-scope.test.ts b/packages/cli/src/utils/migrate-object-scope.test.ts new file mode 100644 index 00000000000..64018a6e297 --- /dev/null +++ b/packages/cli/src/utils/migrate-object-scope.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { isNarrowedRun, narrowedFlagNote, refuseUndeclaredObjects } from './migrate-object-scope.js'; + +const DECLARED = ['os_site', 'os_account', 'sys_user']; + +function refusal(requested: string[] | undefined, declared: string[] = DECLARED) { + try { + refuseUndeclaredObjects(requested, declared); + } catch (error) { + return error as Error & { code?: string; status?: number; object?: string }; + } + return null; +} + +describe('[#21644] refuseUndeclaredObjects: an unknown --object is refused, never narrowed to nothing', () => { + it('a misspelled name answers the OBJECT_NOT_FOUND envelope, naming it and the declared objects', () => { + const error = refusal(['os_sitee']); + expect(error).not.toBeNull(); + expect(error?.code).toBe('OBJECT_NOT_FOUND'); + expect(error?.status).toBe(404); + expect(error?.object).toBe('os_sitee'); + expect(error?.message).toContain("'os_sitee'"); + // Every declared object, sorted, so the operator can correct the spelling. + expect(error?.message).toContain('os_account, os_site, sys_user'); + }); + + it('names every unknown name once, and keeps the first on the envelope', () => { + const error = refusal(['os_site', 'nope', 'also_nope', 'nope']); + expect(error?.code).toBe('OBJECT_NOT_FOUND'); + expect(error?.object).toBe('nope'); + expect(error?.message).toContain("Objects 'nope', 'also_nope' not found"); + expect(error?.message.match(/'nope'/g)).toHaveLength(1); + }); + + it('a deployment that declares nothing says so', () => { + expect(refusal(['os_site'], [])?.message).toContain('This deployment declares no object.'); + }); + + it('declared names, a full-scope run and an empty list pass', () => { + expect(refusal(['os_site'])).toBeNull(); + expect(refusal(['os_site', 'sys_user'])).toBeNull(); + expect(refusal(undefined)).toBeNull(); + expect(refusal([])).toBeNull(); + }); +}); + +describe('[#21644] isNarrowedRun: any --object narrows', () => { + it('a list narrows, even an empty one or one naming every declared object; no list does not', () => { + expect(isNarrowedRun(['os_site'])).toBe(true); + expect(isNarrowedRun([...DECLARED])).toBe(true); + expect(isNarrowedRun([])).toBe(true); + expect(isNarrowedRun(undefined)).toBe(false); + }); + + it('the note names the objects, whether a flag was skipped, and the full-scope command', () => { + const applied = narrowedFlagNote('value-shapes', ['os_site'], true); + expect(applied).toContain('os_site'); + expect(applied).toContain('no deployment flag was recorded'); + expect(applied).toContain('"os migrate value-shapes --apply"'); + expect(narrowedFlagNote('files-to-references', ['os_site'], false)).not.toContain('was recorded'); + }); +}); diff --git a/packages/cli/src/utils/migrate-object-scope.ts b/packages/cli/src/utils/migrate-object-scope.ts new file mode 100644 index 00000000000..b78a88b42cc --- /dev/null +++ b/packages/cli/src/utils/migrate-object-scope.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { objectNotFoundError } from '@objectstack/core'; + +/** + * The `--object` scope of the `os migrate` data-migration family (#21644): + * `value-shapes`, `files-to-references`, `summary-nulls` and `duplicates`. + * + * ## An unknown name is refused, never narrowed to nothing + * + * Each command hands `--object` to its scan as the candidate list, and every + * scan keeps only the candidates it covers. A name the deployment does not + * declare used to be filtered out without a word: a misspelled `--object` + * scanned nothing, reported it clean and exited 0, and `value-shapes --apply` + * went on to record the deployment flag as verified over that empty scan. + * + * So a name the booted registry does not declare is refused before the scan, + * with the platform's own envelope: `objectNotFoundError`, `OBJECT_NOT_FOUND`, + * the answer `os migrate unmapped-columns` gives the same mistake. The message + * names every unknown name and the declared objects. + * + * "Declared" is the registry the command's own boot resolved, which is the set + * each scan draws its default candidates from (`engine.getConfigs()`, or + * `stack.allObjects()` for `duplicates`). The refusal and the scan therefore + * judge one population. A declared object the command has nothing to check on + * (no covered field) is NOT refused: it is a real object, and an empty answer + * about it is a true one. + * + * ## A narrowed run records no deployment-level flag + * + * `value-shapes` and `files-to-references` record a flag that attests the + * stored data of the whole deployment, and that flag turns strict enforcement + * on. A run narrowed by `--object` read only the named objects, so it can + * attest nothing about the rest: it applies its fixes and records no flag. + * + * Any `--object` narrows, even a list that happens to name every declared + * object. The flag is earned by the one spelling that means "every object", + * a run without `--object`, and never by comparing a list against the + * registry of the moment. A second definition of "the whole deployment" would + * have to track every way the declared set can change between two runs. + */ + +/** + * Was this run narrowed by `--object`? Any list narrows, an empty one too: a + * scan handed `[]` walks nothing, which is the last run a flag may come from. + */ +export function isNarrowedRun(objects: readonly string[] | undefined): objects is readonly string[] { + return objects !== undefined; +} + +/** + * The sentence both faces print on a narrowed run of a flag-recording + * command, so the operator learns why no flag was recorded and the one + * command that records it. + */ +export function narrowedFlagNote(command: string, objects: readonly string[], apply: boolean): string { + const list = objects.length > 0 ? objects.join(', ') : 'no object'; + return ( + `Narrowed by --object to ${list}` + + (apply ? ', so no deployment flag was recorded.' : '.') + + ' The flag attests the stored data of every object, so only a run without --object records it: ' + + `"os migrate ${command} --apply".` + ); +} + +/** + * Refuse every `--object` name the booted registry does not declare, before + * anything is scanned or written. + * + * Throws the `OBJECT_NOT_FOUND` envelope (`code`, `status: 404`, `object` + * naming the first unknown name). Its message names every unknown name and the + * declared objects, sorted. + */ +export function refuseUndeclaredObjects( + requested: readonly string[] | undefined, + declared: Iterable, +): void { + if (!requested || requested.length === 0) return; + const known = new Set(declared); + const unknown = [...new Set(requested.filter((name) => !known.has(name)))]; + if (unknown.length === 0) return; + + const error = objectNotFoundError(unknown[0]); + const names = unknown.map((name) => `'${name}'`).join(', '); + const declaredList = [...known].sort(); + error.message = + `${unknown.length === 1 ? 'Object' : 'Objects'} ${names} not found: --object takes an object this ` + + 'deployment declares, and nothing was scanned. ' + + (declaredList.length > 0 + ? `Declared objects: ${declaredList.join(', ')}.` + : 'This deployment declares no object.'); + throw error; +} diff --git a/packages/services/service-storage/src/files-to-references-migration.test.ts b/packages/services/service-storage/src/files-to-references-migration.test.ts index 959776fd540..2f1efc106e6 100644 --- a/packages/services/service-storage/src/files-to-references-migration.test.ts +++ b/packages/services/service-storage/src/files-to-references-migration.test.ts @@ -150,6 +150,64 @@ describe('runFilesToReferencesMigration (#3617)', () => { expect(await isDataMigrationVerified(engine, MIGRATION)).toBe(false); }); + // [#21644] A run narrowed by `objects` read only those objects, so it may + // not attest the deployment: it converts what it finds and records no flag. + it('a narrowed apply converts what it finds and records NO flag, even when its gate passes', async () => { + const engine = fakeEngine({ + product: [{ id: 'p1', image: '/api/v1/storage/files/f1' }], + sys_file: [{ id: 'f1', status: 'committed', ref_object: 'product', ref_id: 'p1', ref_field: 'image' }], + }); + + const result = await run(engine, { apply: true, objects: ['product'] }); + + expect(engine.tables.product[0].image).toBe('f1'); // the fix still lands + expect(result.backfill.converted).toBe(1); + expect(result.gatePassed).toBe(true); + expect(result.flag).toBeNull(); + expect(engine.tables.sys_migration).toHaveLength(0); + expect(await isDataMigrationVerified(engine, MIGRATION)).toBe(false); + }); + + it('a narrowed apply that finds a blocking discrepancy records nothing either', async () => { + const engine = fakeEngine({ + product: [{ id: 'p1', image: 'f1' }], + sys_file: [{ id: 'f1', status: 'committed' }], + }); + + const result = await run(engine, { apply: true, objects: ['product'] }); + + expect(result.gatePassed).toBe(false); + expect(result.flag).toBeNull(); + expect(engine.tables.sys_migration).toHaveLength(0); + }); + + it('a narrowed apply leaves a flag an earlier full-scope run recorded exactly as it was', async () => { + const engine = fakeEngine({ + product: [{ id: 'p1', image: 'f1' }], + sys_file: [{ id: 'f1', ref_object: 'product', ref_id: 'p1', ref_field: 'image', status: 'committed' }], + }); + await run(engine, { apply: true }); + const earned = { ...engine.tables.sys_migration[0] }; + expect(await isDataMigrationVerified(engine, MIGRATION)).toBe(true); + + await run(engine, { apply: true, objects: ['product'] }); + + expect(engine.tables.sys_migration).toEqual([earned]); + }); + + it('an empty objects list walks nothing, so it narrows too and records no flag', async () => { + const engine = fakeEngine({ + product: [{ id: 'p1', image: 'f1' }], + sys_file: [{ id: 'f1', status: 'committed' }], + }); + + const result = await run(engine, { apply: true, objects: [] }); + + expect(result.verify.scannedObjects).toEqual([]); + expect(result.flag).toBeNull(); + expect(engine.tables.sys_migration).toHaveLength(0); + }); + it('external URLs are advisory: reported on the flag, never blocking the gate', async () => { const engine = fakeEngine({ product: [{ id: 'p1', image: 'https://cdn.example.com/logo.png' }], diff --git a/packages/services/service-storage/src/files-to-references-migration.ts b/packages/services/service-storage/src/files-to-references-migration.ts index 87c44ef4b92..1aa9fea27d4 100644 --- a/packages/services/service-storage/src/files-to-references-migration.ts +++ b/packages/services/service-storage/src/files-to-references-migration.ts @@ -24,8 +24,8 @@ import { verifyFileReferences, type FileReferenceReport } from './verify-file-re * `sys_file` ids (dry run unless `apply`), * 2. {@link verifyFileReferences} — reconcile the ownership ledger against * what records actually hold, - * 3. on an APPLY run, record the outcome on the deployment-level - * `sys_migration` flag (`adr-0104-file-references`). + * 3. on an APPLY run over every object, record the outcome on the + * deployment-level `sys_migration` flag (`adr-0104-file-references`). * * The flag — not the platform version — is what may later open released-file * collection (#3459 PR-5b) and strict media value-shape enforcement (#3438) @@ -48,15 +48,29 @@ import { verifyFileReferences, type FileReferenceReport } from './verify-file-re * my deployment's posture" never depends on what the run happened to find. * A failing APPLY run, by contrast, deliberately records `blocking` and * clears `verified_at`: data that has regressed closes its own gate. + * + * ## A run narrowed by `objects` records NO flag (#21644) + * + * The flag attests every object's file values, and a run handed `objects` + * read only those. So it converts what it finds and records nothing, passing + * or failing, and a flag an earlier full-scope run recorded stays as it was. + * Any list narrows, an empty one too: `[]` walks nothing at all. A run that + * should earn the flag omits `objects`. */ /** Engine surface the migration needs — the union of its three halves'. */ export type FilesToReferencesEngine = BackfillEngine & MigrationFlagEngine; export interface FilesToReferencesOptions { - /** Write conversions and record the deployment flag. Omit for a dry run. */ + /** + * Write conversions, and record the deployment flag when the run is not + * narrowed by `objects`. Omit for a dry run. + */ apply?: boolean; - /** Restrict to these objects (default: every object with a file field). */ + /** + * Restrict to these objects (default: every object with a file field). A + * narrowed run records no deployment flag. + */ objects?: string[]; /** Safety bound on records read per object; exceeding it fails the gate. */ maxRecordsPerObject?: number; @@ -71,7 +85,10 @@ export interface FilesToReferencesResult { gatePassed: boolean; /** Why the gate did not pass (empty when it did). */ gateFailures: string[]; - /** The flag row as recorded (apply runs only; null on a dry run). */ + /** + * The flag row as recorded: a full-scope apply run only, so null on a dry + * run and on a run narrowed by `objects`. + */ flag: DataMigrationFlag | null; } @@ -113,8 +130,11 @@ export async function runFilesToReferencesMigration( } const gatePassed = gateFailures.length === 0; + // ⛔ Never from a run narrowed by `objects` (see the header): the flag is a + // claim about every object's file values, and this run read only some. + const narrowed = options.objects !== undefined; let flag: DataMigrationFlag | null = null; - if (apply) { + if (apply && !narrowed) { const advisory = (verify.issues.length - verify.blocking) + backfill.externalUrls + backfill.unresolvable; flag = await recordDataMigrationRun(engine, { From dac7045074afd1873178fe7b86489fa684ac538a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:47:31 +0000 Subject: [PATCH 2/4] docs(cli): os migrate --object narrows a run; only a full-scope run records a flag; changeset Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .changeset/21644-narrowed-apply-flag.md | 13 +++++++++++++ content/docs/deployment/cli.mdx | 18 ++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 .changeset/21644-narrowed-apply-flag.md diff --git a/.changeset/21644-narrowed-apply-flag.md b/.changeset/21644-narrowed-apply-flag.md new file mode 100644 index 00000000000..0c88c5f9d17 --- /dev/null +++ b/.changeset/21644-narrowed-apply-flag.md @@ -0,0 +1,13 @@ +--- +'@objectstack/cli': patch +'@objectstack/service-storage': patch +--- + +`os migrate value-shapes` and `os migrate files-to-references` record the deployment-level ADR-0104 flag only from a run over every object, and every command in the `os migrate` data-migration family refuses an `--object` name the deployment does not declare (#21644). + +Clause-②: no + +- **A narrowed `--apply` records no deployment flag.** The flag attests the stored data of every object and turns strict enforcement on, but a run narrowed by `--object` reads only the named objects. Such a run still applies its fixes: `files-to-references` converts the named objects' values. It records no flag, whether it passes or fails, and leaves a flag that an earlier full-scope run recorded exactly as it was. Its output says why and names the run that records the flag: the same command without `--object`. The `--json` document carries `filter: { objects }`, which is `null` on a full-scope run, so a narrowed run is never mistaken for a full one. Any `--object` narrows, even a list that names every object. A full-scope `--apply` records the flag as before. +- **`runFilesToReferencesMigration`** (`@objectstack/service-storage`) skips the flag write when it is given `objects`. That includes `[]`, which walks nothing. Its `flag` result is `null` on a narrowed run. +- **The column step of `files-to-references` does not run on a narrowed run.** It retypes every single-value media column in the database on the authority of the gate, and a narrowed gate vouches only for the named objects. Before this change, a narrowed `--apply` or a misspelled one moved those columns and stamped `columns_moved_at`. +- **An unknown `--object` is an error.** This applies to `value-shapes`, `files-to-references`, `summary-nulls` and `duplicates`. A name the booted registry does not declare exits 1 with `OBJECT_NOT_FOUND`, and the error names that name and the declared objects. The check runs before anything is read or written. Until now, such a name was filtered out of the scan without a word, so a typo scanned nothing and read as a clean run. `duplicates` reports the refusal as `{ error: 'report_failed', detail, code }`. A declared object that the command has nothing to check on is still accepted. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 6c7a0463810..f184490a698 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1132,11 +1132,25 @@ report no secret and no file. A read the command cannot avoid and that fails for 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. +**`--object` narrows a run, and only a run over every object records a flag.** +`files-to-references`, `value-shapes`, `summary-nulls` and `duplicates` take +`--object` to restrict the run to the objects you name. `duplicates` takes one name, +and the others are repeatable. A name your deployment does not declare is refused +with `OBJECT_NOT_FOUND` and exit 1 before anything is read or written. The error +names the unknown name and the declared objects, so a misspelling is never answered +as a clean run over nothing. A narrowed `--apply` applies its fixes to the named +objects. `files-to-references` and `value-shapes` then record **no** deployment flag, +because the flag is a claim about every object's stored data and a narrowed run read +only some. A narrowed `files-to-references` run does not move the media columns +either. The output says so, a flag that an earlier full run recorded is left as it +was, and `--json` carries `filter: { objects }`. Any `--object` narrows, even a list +that names every object, so run the command without `--object` to record the flag. + ```bash os migrate files-to-references # Dry run: full report, writes nothing os migrate files-to-references --apply # Convert, verify, record the flag (prompts) os migrate files-to-references --apply --yes --json # CI / scripts -os migrate files-to-references --object product # Restrict to one object (repeatable) +os migrate files-to-references --object product # Restrict to one object (repeatable); records no flag ``` A `file` / `image` / `avatar` / `video` / `audio` field value is an opaque @@ -1189,7 +1203,7 @@ The same gate for the **non-media** value classes — references (`lookup`, os migrate value-shapes # Scan: full report, writes nothing os migrate value-shapes --apply # Scan, then record the flag if clean (prompts) os migrate value-shapes --apply --yes --json # CI / scripts -os migrate value-shapes --object contact # Restrict to one object (repeatable) +os migrate value-shapes --object contact # Restrict to one object (repeatable); records no flag ``` **This one converts nothing.** Its sibling rewrites legacy file values because From 80e5eda6ea71407c2c60a17b35f4d287dfe7e99e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:52:14 +0000 Subject: [PATCH 3/4] test(cli): the unknown --object refusal emits only the refusal document; the column-step control Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .../migrate/object-scope.integration.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/migrate/object-scope.integration.test.ts b/packages/cli/src/commands/migrate/object-scope.integration.test.ts index 65b934f2db2..57784735318 100644 --- a/packages/cli/src/commands/migrate/object-scope.integration.test.ts +++ b/packages/cli/src/commands/migrate/object-scope.integration.test.ts @@ -353,6 +353,12 @@ describe('[#21644] a deployment-level flag is written only by a full-scope run', columnMove: null, columnsMovedAt: null, }); + // The control: over every object it runs, moves the media column and records it. + const control = JSON.parse(full.get('files-to-references')!.run.stdout); + expect(control.columnsMovedAt).toBeTruthy(); + expect(control.columnMove.outcomes.map((o: { table: string; status: string }) => [o.table, o.status])).toEqual([ + ['os21644_product', 'moved'], + ]); }); }); @@ -371,7 +377,11 @@ describe('[#21644] an unknown --object is an error, never narrowed to nothing', const { run, state } = unknown.get(command)!; expect(state.flags).toEqual([]); expect(state.product).toEqual([{ id: 'p1', image: null }]); - expect(run.stdout).not.toContain('scannedObjects'); + // The one document is the refusal, and no report: nothing was scanned. + // (`duplicates` keeps its own error shape, a token plus `detail`.) + expect(Object.keys(JSON.parse(run.stdout)).sort()).toEqual( + command === 'duplicates' ? ['code', 'detail', 'error'] : ['code', 'error'], + ); }); it('human mode: exits 1 and names the unknown object', () => { From fe988c20f04722ee9f3eb41f59b47e1f282c5053 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:14:57 +0000 Subject: [PATCH 4/4] test(service-storage): date the earned flag in the past so a narrowed rewrite cannot read as unchanged Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .../src/files-to-references-migration.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/services/service-storage/src/files-to-references-migration.test.ts b/packages/services/service-storage/src/files-to-references-migration.test.ts index 2f1efc106e6..5eb8bfaf3a9 100644 --- a/packages/services/service-storage/src/files-to-references-migration.test.ts +++ b/packages/services/service-storage/src/files-to-references-migration.test.ts @@ -187,6 +187,14 @@ describe('runFilesToReferencesMigration (#3617)', () => { sys_file: [{ id: 'f1', ref_object: 'product', ref_id: 'p1', ref_field: 'image', status: 'committed' }], }); await run(engine, { apply: true }); + // Dated in the past, so a rewrite by the narrowed run cannot land on the + // same millisecond and read as "unchanged". + const EARNED_AT = '2026-01-01T00:00:00.000Z'; + Object.assign(engine.tables.sys_migration[0], { + last_run_at: EARNED_AT, + verified_at: EARNED_AT, + updated_at: EARNED_AT, + }); const earned = { ...engine.tables.sys_migration[0] }; expect(await isDataMigrationVerified(engine, MIGRATION)).toBe(true);