diff --git a/.changeset/24567-drop-gate-refusal-18-0.md b/.changeset/24567-drop-gate-refusal-18-0.md new file mode 100644 index 00000000000..e28c7313491 --- /dev/null +++ b/.changeset/24567-drop-gate-refusal-18-0.md @@ -0,0 +1,13 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): `os migrate apply --allow-destructive`'s organization-ownership refusal says what 18.0 supports for a v17 database, and names no step of the command 18.0 refuses + +Clause-②: no + +- **`os migrate apply --allow-destructive` on a v17-shaped database.** When the run would drop `organization_id` from a table the organization-ownership ceremony owns, and the ceremony has not verified on the database, the refusal now says that 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path, whose ceremony drops these columns itself. The v18 boot refusal and `os migrate organization-ownership`'s own refusal say the same. Before, it told the operator to run `os migrate organization-ownership --out PLAN` and then `--apply --plan-file PLAN`, which 18.0 refuses on every database (`NOT_IMPLEMENTED`). The `--json` refusal's `detail` no longer names that command either: it reads "No run of the organization-ownership ceremony is recorded on this database.", or names the last unverified run with its date and blocking count. +- **`os migrate organization-ownership --help`** shows what the command answers in 18.0: exit `1` with `NOT_IMPLEMENTED`, nothing read or written, and the same refusal as JSON. Before, it showed six plan and apply examples that 18.0 refuses. +- **`os dev` on a v17-shaped development database.** When it does not upgrade the database in place (the ceremony's plan is refused, or the database is not a local SQLite file), it now ends with the same position: keep a v17 development database on a 17.x release until the 18.x release that carries the upgrade path, and upgrade it there. Before, it told the developer to run `os migrate organization-ownership --dev` and then the same command with `--apply`. + +Nothing else changes. The refusals still hold back the whole run, and their message code (`refused_organization_ownership_unmigrated`), `tables`, `marker`, exit code `1` and every flag are as before. diff --git a/packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts b/packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts index 8a2e7ef0bc6..9510bb0aa3b 100644 --- a/packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts +++ b/packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts @@ -104,6 +104,9 @@ function caseDb(columns: readonly string[]): string { const dropArgv = (file: string) => ['--database-url', `file:${file}`, '--allow-destructive', '--yes', '--json']; +/** A step of `os migrate organization-ownership`, which 18.0 refuses on every database. */ +const WITHDRAWN_COMMAND = /os migrate organization-ownership|--plan-file|organization-ownership --out/; + beforeAll(async () => { for (const key of [...OVERRIDING_ENV, 'NODE_ENV'] as const) savedEnv[key] = process.env[key]; for (const key of OVERRIDING_ENV) delete process.env[key]; @@ -151,7 +154,9 @@ describe('os migrate apply --allow-destructive waits for the organization-owners created: [], marker: null, }); - expect(payload.detail).toMatch(/os migrate organization-ownership --apply/); + expect(payload.detail).toMatch(/organization-ownership ceremony/); + // 18.0 refuses `os migrate organization-ownership` on every database: the refusal names no step of it. + expect(payload.detail).not.toMatch(WITHDRAWN_COMMAND); expect(columnsOf(file, 'sys_platform_setting')).toContain('organization_id'); }, CASE_TIMEOUT_MS); @@ -164,6 +169,7 @@ describe('os migrate apply --allow-destructive waits for the organization-owners expect(exitCode).toBe(1); expect(payload).toMatchObject({ message: 'refused_organization_ownership_unmigrated', tables: ['sys_platform_setting'] }); expect(payload.detail).toMatch(/is not verified \(2 blocking/); + expect(payload.detail).not.toMatch(WITHDRAWN_COMMAND); expect(columnsOf(file, 'sys_platform_setting')).toContain('organization_id'); }, CASE_TIMEOUT_MS); diff --git a/packages/cli/src/commands/migrate/apply.organization-ownership-refusal.test.ts b/packages/cli/src/commands/migrate/apply.organization-ownership-refusal.test.ts new file mode 100644 index 00000000000..0cd96b6978c --- /dev/null +++ b/packages/cli/src/commands/migrate/apply.organization-ownership-refusal.test.ts @@ -0,0 +1,194 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D10 item 5] What `os migrate apply --allow-destructive` tells the + * operator when it holds back an `organization_id` drop the organization-ownership + * ceremony owns, in 18.0. + * + * 18.0 supports fresh installs only, and its registered + * `os migrate organization-ownership` refuses on every database, plan and apply + * alike (`organization-ownership.release-refusal.test.ts`). So the drop gate's + * refusal, the human face and the `--json` `detail` both, names no step of that + * command: it says what 18.0 supports for a v17 database, the 17.x release to + * keep it on and the 18.x release that carries the upgrade path, as the boot + * refusal does (`organizationOwnershipRefusalMessage`, `@objectstack/runtime`). + * + * ## What is real here + * + * The command's own parse, its drift handling and the gate run for real. The + * boot, the occupancy probe, the process exit and the ceremony's marker read + * are replaced: the subject is the text the gate hands the operator, for each + * marker state it can read, and a control proves the harness reaches the + * writes once the marker is verified. The real command on a v17-shape SQLite + * table is `apply-organization-ownership-gate.integration.test.ts`. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import MigrateApply from './apply.js'; +import { isExitSignal } from '../../utils/format.js'; + +// ⛔ `vi.hoisted`, never a value import of `bootSchemaStack`: this suite boots +// nothing, and a value import of that symbol files a test in the integration +// tier (`vitest-tiers.ts`). +const mocks = vi.hoisted(() => ({ + bootSchemaStack: vi.fn(), + exitOneShotCommand: vi.fn(async (_code: number) => {}), + readOrganizationOwnershipMarker: vi.fn(), +})); + +vi.mock('../../utils/schema-migrate.js', async (importOriginal) => ({ + ...(await importOriginal>()), + bootSchemaStack: mocks.bootSchemaStack, +})); +vi.mock('../../utils/migrate-occupancy-gate.js', () => ({ + OCCUPANCY_HINT: 'occupancy hint', + probeMigrationTarget: vi.fn(async () => ({ status: 'free' })), +})); +vi.mock('../../utils/one-shot-exit.js', () => ({ exitOneShotCommand: mocks.exitOneShotCommand })); +vi.mock('../../utils/organization-ownership-drop-gate.js', async (importOriginal) => ({ + ...(await importOriginal>()), + readOrganizationOwnershipMarker: mocks.readOrganizationOwnershipMarker, +})); + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); +/** oclif builds its whole command table on the first `run()` in a process. */ +const RUN_TIMEOUT = 60_000; + +/** + * The drift a v17-shape table offers: a physical `organization_id` on a table + * whose v18 object declares none. + */ +const ORGANIZATION_DROP = { + category: 'destructive', + table: 'sys_platform_setting', + column: 'organization_id', + op: { type: 'drop_column', column: 'organization_id' }, + description: 'sys_platform_setting.organization_id: column exists in the database but not in metadata (orphaned)', +}; + +/** A step of `os migrate organization-ownership`, which 18.0 refuses on every database. */ +const WITHDRAWN_COMMAND = /os migrate organization-ownership|--plan-file|organization-ownership --out/; + +const UNVERIFIED = { id: 'adr-0131-organization-ownership', last_run_at: '2026-10-01T00:00:00.000Z', verified_at: null, blocking: 2 }; +const VERIFIED = { ...UNVERIFIED, verified_at: '2026-10-01T00:00:00.000Z', blocking: 0 }; + +let flushSchemaDdl: ReturnType; +let applyMigrationEntries: ReturnType; + +function bootWithOrganizationDrop(): void { + flushSchemaDdl = vi.fn(async () => []); + applyMigrationEntries = vi.fn(async () => ({ applied: [ORGANIZATION_DROP], skipped: [] })); + mocks.bootSchemaStack.mockResolvedValue({ + driver: {}, + drivers: [], + dbLabel: 'file:v17.db', + dbSource: { kind: 'flag' }, + telemetryDatabase: null, + composition: { plugins: [], hostConfigPath: null, hostConfigLoaded: false, hostConfigError: null, notes: [] }, + detectManagedDrift: async () => [ORGANIZATION_DROP], + driftApplyLimit: () => null, + pendingSchemaWork: [], + kernel: { getService: () => ({}) }, + flushSchemaDdl, + applyMigrationEntries, + shutdown: vi.fn(async () => {}), + }); +} + +let stdout: ReturnType; +let log: ReturnType; +let stderr: ReturnType; +let savedExitCode: typeof process.exitCode; +beforeEach(() => { + savedExitCode = process.exitCode; + process.exitCode = undefined; + mocks.bootSchemaStack.mockReset(); + mocks.exitOneShotCommand.mockClear(); + mocks.readOrganizationOwnershipMarker.mockReset(); + // `emitJson` awaits the write's DRAIN callback, so the double must invoke it. + stdout = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, enc?: unknown, cb?: unknown) => { + const done = typeof enc === 'function' ? enc : cb; + if (typeof done === 'function') done(); + return true; + }) as typeof process.stdout.write); + log = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderr = vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + stdout.mockRestore(); + log.mockRestore(); + stderr.mockRestore(); + process.exitCode = savedExitCode; +}); + +/** + * Run the command; answer the status it ends the process with, the JSON + * document it emitted, and the refusal line the human face printed. + */ +async function runApply(argv: string[]) { + const err = await MigrateApply.run(argv, { root: CLI_ROOT }).then(() => null, (e: unknown) => e); + if (err !== null && !isExitSignal(err)) throw err; + const exit = err !== null + ? (err as { oclif?: { exit?: number } }).oclif?.exit + : mocks.exitOneShotCommand.mock.calls.at(-1)?.[0]; + const out = stdout.mock.calls.map((c: unknown[]) => String(c[0])).join('').trim(); + const refusal = log.mock.calls.map((c: unknown[]) => c.map(String).join(' ')).find((line: string) => line.includes('Refusing this --allow-destructive run')); + return { exit, payload: out ? JSON.parse(out) : null, refusal }; +} + +const ARGV = ['--database-url', 'file:v17.db', '--allow-destructive', '--yes']; + +describe('os migrate apply --allow-destructive: the organization-ownership refusal names no command 18.0 refuses', () => { + for (const [state, flag] of [['no ceremony run recorded', null], ['an unverified run recorded', UNVERIFIED]] as const) { + it(`${state}: the human face refuses, exit 1, saying what 18.0 supports, and nothing is written`, async () => { + bootWithOrganizationDrop(); + mocks.readOrganizationOwnershipMarker.mockResolvedValue({ verified: false, flag }); + + const { exit, refusal } = await runApply(ARGV); + + expect(exit).toBe(1); + expect(refusal, 'the gate printed its refusal').toBeDefined(); + expect(refusal).toContain('sys_platform_setting'); + expect(refusal).not.toMatch(WITHDRAWN_COMMAND); + expect(refusal).toContain('17.x release'); + expect(refusal).toContain('18.x release'); + if (flag) expect(refusal).toContain('2 blocking'); + expect(flushSchemaDdl).not.toHaveBeenCalled(); + expect(applyMigrationEntries).not.toHaveBeenCalled(); + }, RUN_TIMEOUT); + + it(`${state}: under --json the refusal's detail names no command 18.0 refuses either`, async () => { + bootWithOrganizationDrop(); + mocks.readOrganizationOwnershipMarker.mockResolvedValue({ verified: false, flag }); + + const { exit, payload } = await runApply([...ARGV, '--json']); + + expect(exit).toBe(1); + expect(payload).toMatchObject({ + message: 'refused_organization_ownership_unmigrated', + tables: ['sys_platform_setting'], + applied: [], + created: [], + marker: flag, + }); + expect(payload.detail).not.toMatch(WITHDRAWN_COMMAND); + expect(applyMigrationEntries).not.toHaveBeenCalled(); + }, RUN_TIMEOUT); + } +}); + +describe('control', () => { + it('a verified marker lets the drop through and exits 0, so the harness can tell a refusal from a run', async () => { + bootWithOrganizationDrop(); + mocks.readOrganizationOwnershipMarker.mockResolvedValue({ verified: true, flag: VERIFIED }); + + const { exit, payload } = await runApply([...ARGV, '--json']); + + expect(exit).toBe(0); + expect(applyMigrationEntries).toHaveBeenCalledTimes(1); + expect(payload.applied).toEqual([ORGANIZATION_DROP]); + }, RUN_TIMEOUT); +}); diff --git a/packages/cli/src/commands/migrate/apply.ts b/packages/cli/src/commands/migrate/apply.ts index f1a9263957b..7a6414669b7 100644 --- a/packages/cli/src/commands/migrate/apply.ts +++ b/packages/cli/src/commands/migrate/apply.ts @@ -571,10 +571,19 @@ export default class MigrateApply extends Command { ); const marker = await readOrganizationOwnershipMarker(engine as never); if (!marker.verified) { + // 18.0 supports fresh installs only, and its `os migrate + // organization-ownership` refuses on every database, plan and apply + // alike (`ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE`, ruling record + // 6259608309 on the v18 epic, item 1). So neither the `detail` nor the + // refusal names a step of that command: they say what 18.0 supports + // for a v17 database, in the boot refusal's terms + // (`organizationOwnershipRefusalMessage`, `@objectstack/runtime`). + // The 18.x release that registers the ceremony names its steps here + // again. const detail = marker.flag - ? `The last run of "os migrate organization-ownership --apply --plan-file FILE" (${marker.flag.last_run_at}) is not verified ` - + `(${marker.flag.blocking} blocking item(s)).` - : 'No run of "os migrate organization-ownership --apply --plan-file FILE" is recorded on this database.'; + ? `The last run of the organization-ownership ceremony recorded on this database (${marker.flag.last_run_at}) ` + + `is not verified (${marker.flag.blocking} blocking item(s)).` + : 'No run of the organization-ownership ceremony is recorded on this database.'; if (flags.json) { await emitJson({ ...target, created: [], applied: [], skipped: drift, pending, @@ -585,9 +594,9 @@ export default class MigrateApply extends Command { printError( `Refusing this --allow-destructive run: it would drop organization_id from ${organizationDrops.join(', ')}, a drop the organization-ownership ceremony ` + `owns, and the ceremony has not verified on this database. ${detail} Dropping the column first would turn ` - + 'every legacy organization row into an environment row without the ceremony\'s promotion or report. Run ' - + '"os migrate organization-ownership --out PLAN" to write and review its plan, then ' - + '"os migrate organization-ownership --apply --plan-file PLAN"; it drops these columns itself, in its ' + + 'every legacy organization row into an environment row without the ceremony\'s promotion or report. 18.0 ' + + 'supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release ' + + 'until the 18.x release that carries the upgrade path, whose ceremony drops these columns itself, in its ' + 'column-drops step, before it records the run verified. Nothing in this run was applied.', ); this.exit(1); diff --git a/packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts b/packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts index 20929cce72d..d700cba367b 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts @@ -143,6 +143,18 @@ describe('os migrate organization-ownership — refused in 18.0', () => { expect(mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE).toContain('an 18.x release'); }); + it('its --help examples show the refusal: none is a plan or an apply 18.0 refuses on every database', () => { + const examples = mod.default.examples.map((e) => (typeof e === 'string' ? { command: e, description: '' } : e)); + expect(examples.length).toBeGreaterThan(0); + for (const { command, description } of examples) { + expect(command).toMatch(/migrate organization-ownership/); + expect(command).not.toMatch(/--out|--apply|--plan-file|--fate-supplement/); + expect(description).toContain('NOT_IMPLEMENTED'); + } + // The ceremony class keeps its own examples, for the 18.x release that registers it. + expect(mod.OrganizationOwnershipCeremony.examples.some((e) => String(e).includes('--apply --plan-file'))).toBe(true); + }); + it('the registered command keeps the ceremony class whole underneath: same flags, its own run', () => { expect(Object.getPrototypeOf(mod.default)).toBe(mod.OrganizationOwnershipCeremony); expect(mod.default.flags).toBe(mod.OrganizationOwnershipCeremony.flags); diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts index e9f30602b3a..0eac0d5700b 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.ts @@ -327,7 +327,7 @@ export class OrganizationOwnershipCeremony extends Command { 'verified id-to-name rewrite, mirror deletion and column drops, in that order, ' + 'resumable from a recorded checkpoint, then the post-check.'; - static override examples = [ + static override examples: Command.Example[] = [ '$ os migrate organization-ownership', '$ os migrate organization-ownership --out plans/v18-ownership.json', '$ os migrate organization-ownership --database-url postgres://… --json', @@ -1125,6 +1125,25 @@ export default class MigrateOrganizationOwnership extends OrganizationOwnershipC 'Refused in 18.0, which supports fresh installs only: the v17 → v18 upgrade path ships in an 18.x release. ' + OrganizationOwnershipCeremony.description; + /** + * The ceremony's own examples show a plan and an apply, which 18.0 refuses on + * every database, fresh or not, so `--help` shows what an invocation answers + * in this release instead. The ceremony class keeps its examples for the 18.x + * release that registers it. + */ + static override examples: Command.Example[] = [ + { + description: + 'Refused in 18.0, plan and apply alike: exit 1 with NOT_IMPLEMENTED, nothing read or written. Keep a v17 ' + + 'database on a 17.x release until the 18.x release that carries the upgrade path', + command: '<%= config.bin %> migrate organization-ownership', + }, + { + description: 'The same refusal as JSON: error, reason upgrade-unavailable, code NOT_IMPLEMENTED, and the detail', + command: '<%= config.bin %> migrate organization-ownership --json', + }, + ]; + override async run(): Promise { const { flags } = await this.parse(MigrateOrganizationOwnership); if (flags.json) { diff --git a/packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts b/packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts index e1f18ce3d33..16980324527 100644 --- a/packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts +++ b/packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts @@ -86,6 +86,8 @@ describe('os dev — the in-place upgrade of a v17-shape development database', expect(result.lines[0]).toContain('✗ Refused (sys_mystery)'); expect(result.lines.at(-1)).toContain('nothing was changed'); expect(result.lines.at(-1)).toContain(BACKUP()); + expect(result.lines.at(-1)).toContain('17.x release'); + expect(result.lines.at(-1)).not.toContain('--plan-file'); }); it('a blocked step boots nothing: each blocker is printed, and how to resume', () => { @@ -118,12 +120,15 @@ describe('os dev — the in-place upgrade of a v17-shape development database', expect(result.lines.at(-1)).toContain(`--apply --plan-file ${join(BACKUP(), 'plan.json')} --backup-taken`); }); - it('a database that is not a local SQLite file is not upgraded in place: the ceremony is named instead', () => { + it('a database that is not a local SQLite file is not upgraded in place: 18.0\'s position is named instead', () => { const result = upgradeDevelopmentDatabase(input(fakeRun({}), { sqliteFile: null, databaseUrl: 'postgres://db/app' })); expect(result.ok).toBe(false); expect(calls).toEqual([]); expect(existsSync(join(dir, 'backups'))).toBe(false); - expect(result.lines[0]).toContain('os migrate organization-ownership --dev'); + expect(result.lines[0]).toContain('17.x release'); + // 18.0 refuses `os migrate organization-ownership` on every database: the way on names no step of it. + expect(result.lines[0]).not.toContain('os migrate organization-ownership'); + expect(result.lines[0]).not.toContain('--plan-file'); }); it('the IPC message is recognised by its type alone', () => { diff --git a/packages/cli/src/utils/dev-organization-ownership-upgrade.ts b/packages/cli/src/utils/dev-organization-ownership-upgrade.ts index a6f71cde019..47a5a0318ec 100644 --- a/packages/cli/src/utils/dev-organization-ownership-upgrade.ts +++ b/packages/cli/src/utils/dev-organization-ownership-upgrade.ts @@ -118,9 +118,15 @@ function refusalLines(logFile: string): string[] { * Synchronous on purpose — nothing else may open the files while it runs. */ export function upgradeDevelopmentDatabase(input: DevelopmentUpgradeInput): DevelopmentUpgradeResult { + // The way on, when os dev does not upgrade the database itself. 18.0 supports + // fresh installs only, and its `os migrate organization-ownership` refuses on + // every database (`ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE`), so this names + // no step of that command: it says what 18.0 supports for a v17 database, in + // the boot refusal's terms. The 18.x release that registers the ceremony names + // its steps here again. const manual = - 'Run `os migrate organization-ownership --dev` (review its plan), then the same command with --apply ' + - '--plan-file FILE --backup-taken, and start os dev again.'; + '18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 development database on a ' + + '17.x release until the 18.x release that carries the upgrade path, and upgrade it there.'; if (!input.sqliteFile) { return { ok: false, diff --git a/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts b/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts index 9f7bd1482e9..71da34a2ad2 100644 --- a/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts +++ b/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts @@ -436,7 +436,9 @@ describe('os migrate apply --allow-destructive: the organization_id drop on a ho created: [], marker: null, }); - expect(payload.detail).toMatch(/os migrate organization-ownership --apply/); + expect(payload.detail).toMatch(/organization-ownership ceremony/); + // 18.0 refuses `os migrate organization-ownership` on every database: the refusal names no step of it. + expect(payload.detail).not.toMatch(/os migrate organization-ownership|--plan-file|organization-ownership --out/); // The drops the refusal held back are the ones the drift offered. expect(organizationDropsIn(payload.skipped)).toEqual([...DECLARED].sort()); for (const table of HOST_OBJECTS) expect(hasOrganizationColumn(dbFile, table), table).toBe(true);