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
22 changes: 22 additions & 0 deletions .changeset/26615-dev-upgrade-names-its-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/cli': patch
---

fix(cli): `os dev`'s in-place organization-ownership upgrade names what stopped it, and the exact command that carries on

Clause-②: no
Metadata-protocol: none

When the organization-ownership boot guard refuses a development database, `os dev` copies it and runs the ceremony on it in place. When that upgrade did not finish, it could exit with 「The upgrade did not run to a report」 and no cause.

What changes:

- The ceremony's own refusal is printed in its own words, also when the terminal forces colour (`FORCE_COLOR`): the refusal lines are read with colour stripped, and the `✗` glyph is no longer printed twice.
- An apply that stopped with no refusal of its own (an uncaught error, a crash, a signal) says how it ended and prints the end of its log, without the kernel's boot narration.
- A report file still holding the apply's reservation text, or one that does not parse, is read as "no report", with the cause, never as "a blocker (above)" with nothing above. A blocked report that names no blocker prints the log's cause too.
- A stale `organization_id` the plan gives the column-drop fate is upgraded as before, but no longer silently: before the apply runs, `os dev` prints per table how many stamped values the drop discards, and names the copy that keeps them.
- Each failed upgrade ends on the exact command that carries on: `os migrate organization-ownership --dev --database-url URL --apply --plan-file PLAN --backup-taken`, with the database and the plan the upgrade wrote.

The copy taken before the upgrade is kept, as before. A development database with a stale `organization_id` column on `sys_flow_credential` and `sys_secret` (objects that no longer declare it) is upgraded and starts under `os dev`, now pinned by a test. The production ceremony is unchanged.

What to do: nothing.
1 change: 1 addition & 0 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,7 @@ export default class Dev extends Command {
sqliteFile,
env: localEnv,
cwd: process.cwd(),
announce: (line) => console.log(chalk.yellow(` ⚠ ${line}`)),
});
},
});
Expand Down
121 changes: 115 additions & 6 deletions packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
isOrganizationOwnershipRequiredMessage,
ORGANIZATION_OWNERSHIP_REQUIRED_MESSAGE,
upgradeDevelopmentDatabase,
type CeremonyRunEnd,
type DevelopmentUpgradeInput,
} from './dev-organization-ownership-upgrade.js';

Expand All @@ -29,20 +30,37 @@ const NOW = new Date('2026-10-16T10:00:00.000Z');
const BACKUP = (): string => join(dir, 'backups', 'organization-ownership-2026-10-16T10-00-00-000Z');

/** A fake CLI: answers `planCode`, and writes the files a real run writes. */
function fakeRun(opts: { planCode?: number; report?: Record<string, unknown> | null }): DevelopmentUpgradeInput['run'] {
function fakeRun(opts: {
planCode?: number;
report?: Record<string, unknown> | null;
/** What the apply prints to its log, verbatim. */
applyLog?: string;
/** How the apply ends, when not by its report's verdict. */
applyEnd?: CeremonyRunEnd;
/** What the apply leaves at its `--out` path instead of a report. */
applyFile?: string;
/** The plan file the plan run writes. */
plan?: Record<string, unknown>;
}): DevelopmentUpgradeInput['run'] {
return (args, logFile) => {
calls.push([...args]);
const out = args[args.indexOf('--out') + 1]!;
if (!args.includes('--apply')) {
writeFileSync(logFile, opts.planCode ? ' ✗ Refused (sys_mystery): sys_mystery carries organization_id …\n' : 'planned\n', { flag: 'a' });
if (!opts.planCode) writeFileSync(out, '{}');
return opts.planCode ?? 0;
if (!opts.planCode) writeFileSync(out, JSON.stringify(opts.plan ?? {}));
return { status: opts.planCode ?? 0 };
}
if (opts.applyLog) writeFileSync(logFile, opts.applyLog, { flag: 'a' });
if (opts.report) writeFileSync(out, JSON.stringify(opts.report));
return opts.report?.complete ? 0 : 1;
if (opts.applyFile !== undefined) writeFileSync(out, opts.applyFile);
return opts.applyEnd ?? { status: opts.report?.complete ? 0 : 1 };
};
}

/** The exact command every failed apply ends on: the plan this upgrade wrote, the database it opened. */
const AGAIN = (): string =>
`os migrate organization-ownership --dev --database-url file:${primary} --apply --plan-file ${join(BACKUP(), 'plan.json')} --backup-taken`;

const input = (run: DevelopmentUpgradeInput['run'], over: Partial<DevelopmentUpgradeInput> = {}): DevelopmentUpgradeInput => ({
binPath: '/cli/bin/run.js',
databaseUrl: `file:${primary}`,
Expand Down Expand Up @@ -83,7 +101,8 @@ describe('os dev — the in-place upgrade of a v17-shape development database',
const result = upgradeDevelopmentDatabase(input(fakeRun({ planCode: 1 })));
expect(result.ok).toBe(false);
expect(calls).toHaveLength(1);
expect(result.lines[0]).toContain('✗ Refused (sys_mystery)');
// The ceremony's own words; the `✗` glyph is the printer's, never doubled.
expect(result.lines[0]).toBe('Refused (sys_mystery): sys_mystery carries organization_id …');
expect(result.lines.at(-1)).toContain('nothing was changed');
expect(result.lines.at(-1)).toContain(BACKUP());
// The ceremony is open (18.x step 6): the way on is running it by hand.
Expand All @@ -97,7 +116,7 @@ describe('os dev — the in-place upgrade of a v17-shape development database',
})));
expect(result.ok).toBe(false);
expect(result.lines[0]).toBe('authored-conversion: The authored permission x differs …');
expect(result.lines.at(-1)).toContain(`--apply --plan-file ${join(BACKUP(), 'plan.json')} --backup-taken`);
expect(result.lines.at(-1)).toContain(AGAIN());
});

it('every step finished but the post-check holds an unattributable row: boots nothing, names the table, count and remedy', () => {
Expand Down Expand Up @@ -130,6 +149,96 @@ describe('os dev — the in-place upgrade of a v17-shape development database',
expect(result.lines[0]).not.toContain('17.x');
});

it('⭐ a column drop that discards stamped values is said, per table, before the apply runs, and the upgrade completes', () => {
const announced: Array<{ line: string; callsSoFar: number }> = [];
const stamps = (rowsLosingTheirStamp: number) => ({ columnPresent: true, rowsLosingTheirStamp, rowsWithNullOrganization: 0 });
const result = upgradeDevelopmentDatabase(input(
fakeRun({
plan: {
tables: [
{ object: 'sys_flow_credential', fate: 'column-drop', fateCounts: stamps(0) },
{ object: 'sys_secret', fate: 'column-drop', fateCounts: stamps(3) },
// Attribution keeps every stamp: nothing is discarded there.
{ object: 'sys_job', fate: 'attribution', fateCounts: stamps(9) },
],
},
report: { complete: true, blocking: 0, warnings: [], steps: [] },
}),
{ announce: (line) => announced.push({ line, callsSoFar: calls.length }) },
));
expect(result.ok).toBe(true);
expect(announced.map((a) => a.line)).toEqual([
`sys_secret: the plan drops its retired organization_id column, discarding 3 stamped value(s); the copy in ${BACKUP()} keeps them.`,
]);
// Said after the plan and before the apply.
expect(announced[0]!.callsSoFar).toBe(1);
expect(calls).toHaveLength(2);
});

describe('an apply that writes no report still names its cause, and the exact command that carries on', () => {
// A `FORCE_COLOR` terminal: chalk colours the refusal even into the log file.
const COLOURED = '\u001b[31m ✗ Refused: The plan file … was computed for the database A, and this run opened B.\u001b[39m\n';

it('⭐ a refusal printed in colour is read in the ceremony\'s own words', () => {
const result = upgradeDevelopmentDatabase(input(fakeRun({ applyLog: COLOURED, applyEnd: { status: 1 } })));
expect(result.ok).toBe(false);
expect(result.lines[0]).toBe('Refused: The plan file … was computed for the database A, and this run opened B.');
expect(result.lines.at(-1)).toContain('The upgrade did not run to a report.');
expect(result.lines.at(-1)).toContain(AGAIN());
expect(result.lines.at(-1)).toContain(BACKUP());
});

it('an apply that stopped with no refusal of its own (an uncaught error) names how it ended and the end of its log', () => {
const log =
'2026-10-27T10:08:09.000Z INFO Bootstrap complete\n' +
' → step column-drops…\n' +
'2026-10-27T10:08:10.000Z INFO Graceful shutdown started\n' +
' Error: SQLITE_ERROR: error in index idx_sys_secret_organization_id after drop column\n';
const result = upgradeDevelopmentDatabase(input(fakeRun({ applyLog: log, applyEnd: { status: 1 } })));
expect(result.ok).toBe(false);
expect(result.lines[0]).toContain('exited with code 1 without a refusal of its own');
expect(result.lines).toContain(' Error: SQLITE_ERROR: error in index idx_sys_secret_organization_id after drop column');
expect(result.lines).toContain(' → step column-drops…');
// The kernel's INFO narration is not the cause.
expect(result.lines.some((l) => l.includes('Bootstrap complete'))).toBe(false);
expect(result.lines.at(-1)).toContain(AGAIN());
});

it('an apply ended by a signal says so', () => {
const result = upgradeDevelopmentDatabase(input(fakeRun({ applyEnd: { status: null, signal: 'SIGKILL' } })));
expect(result.lines[0]).toContain('was ended by SIGKILL without a refusal of its own, and its log');
expect(result.lines.at(-1)).toContain('The upgrade did not run to a report.');
});

it('a report file still holding the apply\'s reservation text is no report: the cause is printed, not "a blocker (above)"', () => {
const reservation = JSON.stringify({ kind: 'organization-ownership-apply-reservation', reservedAt: '2026-10-27T10:08:09.000Z', note: '…' });
const result = upgradeDevelopmentDatabase(input(fakeRun({
applyLog: ' Error: the stack shut down mid-step\n',
applyEnd: { status: 1 },
applyFile: reservation,
})));
expect(result.ok).toBe(false);
expect(result.lines).toContain(' Error: the stack shut down mid-step');
expect(result.lines.at(-1)).toContain('The upgrade did not run to a report.');
expect(result.lines.join('\n')).not.toContain('a blocker (above)');
});

it('a report file that does not parse is named, never thrown', () => {
const result = upgradeDevelopmentDatabase(input(fakeRun({ applyEnd: { status: 1 }, applyFile: '{"kind":"organization-own' })));
expect(result.ok).toBe(false);
expect(result.lines.some((l) => l.startsWith(`Its report file ${join(BACKUP(), 'apply.json')} could not be read`))).toBe(true);
});

it('a blocked report that names no blocker prints the log\'s cause instead of an empty "above"', () => {
const result = upgradeDevelopmentDatabase(input(fakeRun({
report: { complete: false, warnings: [], steps: [{ step: 'column-drops', status: 'blocked', blockers: [] }] },
applyLog: ' ✗ The ceremony stopped at a blocker (above). Resolve it and re-run --apply; finished steps are not repeated. Report: …\n',
})));
expect(result.lines[0]).toContain('The ceremony stopped at a blocker');
expect(result.lines.at(-1)).toContain(AGAIN());
});
});

it('the IPC message is recognised by its type alone', () => {
expect(isOrganizationOwnershipRequiredMessage({ type: ORGANIZATION_OWNERSHIP_REQUIRED_MESSAGE, tables: ['sys_job'] })).toBe(true);
expect(isOrganizationOwnershipRequiredMessage({ type: 'objectstack:listening', port: 3000 })).toBe(false);
Expand Down
Loading
Loading