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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/24567-drop-gate-refusal-18-0.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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);

Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
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<Record<string, unknown>>()),
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<typeof vi.fn>;
let applyMigrationEntries: ReturnType<typeof vi.fn>;

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<typeof vi.spyOn>;
let log: ReturnType<typeof vi.spyOn>;
let stderr: ReturnType<typeof vi.spyOn>;
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);
});
21 changes: 15 additions & 6 deletions packages/cli/src/commands/migrate/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
21 changes: 20 additions & 1 deletion packages/cli/src/commands/migrate/organization-ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<void> {
const { flags } = await this.parse(MigrateOrganizationOwnership);
if (flags.json) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading