Skip to content

Commit 90c79b5

Browse files
committed
test(cli): pin os migrate apply --allow-destructive on a host-declared platform-global object (red on base)
Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude <noreply@anthropic.com>
1 parent c9810dd commit 90c79b5

1 file changed

Lines changed: 146 additions & 0 deletions

File tree

‎packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts‎

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@
4040
*
4141
* Every case runs the real command, plan then apply, over a fresh copy of the
4242
* database; each fate the case does not exercise is `report`. SQLite only.
43+
*
44+
* ## `os migrate apply --allow-destructive` on the same composition
45+
*
46+
* The schema-drift report of this composition offers the two declared
47+
* objects' `organization_id` for the destructive apply. Its drop gate
48+
* (`organization-ownership-drop-gate.ts`, ADR-0131 D10 item 5) holds that drop
49+
* back until the ceremony's marker is verified — on these host-declared
50+
* objects as on the platform's inventory, since the ceremony plans every
51+
* table that carries the column. The real `os migrate apply` command, over a
52+
* fresh copy of the same v17 database:
53+
*
54+
* - no marker: refused, naming the ceremony; nothing dropped;
55+
* - CONTROL: a verified marker: the drop applies;
56+
* - CONTROL: a destructive drop of another column is unchanged.
4357
*/
4458

4559
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
@@ -50,7 +64,9 @@ import { randomBytes } from 'node:crypto';
5064
import { tmpdir } from 'node:os';
5165
import { dirname, join, resolve } from 'node:path';
5266
import { fileURLToPath, pathToFileURL } from 'node:url';
67+
import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system';
5368
import { isExitSignal } from './format.js';
69+
import MigrateApply from '../commands/migrate/apply.js';
5470
import MigrateOrganizationOwnership from '../commands/migrate/organization-ownership.js';
5571

5672
// Paid at module load, not inside a clocked case (`check:test-source-alias`).
@@ -248,6 +264,74 @@ async function ceremony(name: string, drop: string): Promise<Run> {
248264
}
249265
}
250266

267+
/** A fresh copy of the v17 database for one `os migrate apply` case, with `prepare` run on it raw. */
268+
function applyCase(name: string, prepare?: (db: Database.Database) => void): string {
269+
const caseDir = join(dir, 'apply-cases', name);
270+
mkdirSync(caseDir, { recursive: true });
271+
const dbFile = join(caseDir, 'v17.db');
272+
copyFileSync(templateDb, dbFile);
273+
if (prepare) {
274+
const db = new Database(dbFile);
275+
try {
276+
prepare(db);
277+
} finally {
278+
db.close();
279+
}
280+
}
281+
return dbFile;
282+
}
283+
284+
/** The real `os migrate apply --allow-destructive --yes --json` over `dbFile`, from the host's project. */
285+
async function migrateApply(dbFile: string): Promise<{ payload: any; exitCode: number }> {
286+
const savedExit = process.exitCode;
287+
const swallow = ((_chunk: unknown, ...rest: unknown[]) => {
288+
const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
289+
if (cb) cb();
290+
return true;
291+
}) as typeof process.stdout.write;
292+
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow);
293+
vi.spyOn(process.stderr, 'write').mockImplementation(swallow);
294+
vi.spyOn(console, 'log').mockImplementation(() => {});
295+
vi.spyOn(console, 'warn').mockImplementation(() => {});
296+
vi.spyOn(console, 'error').mockImplementation(() => {});
297+
// `run()` ends the one-shot process itself once the document is written.
298+
let processExit: number | undefined;
299+
vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
300+
processExit = code ?? 0;
301+
throw new Error(`__PROCESS_EXIT__:${processExit}`);
302+
}) as never);
303+
process.chdir(projectDir);
304+
let thrownExit: number | undefined;
305+
try {
306+
try {
307+
await MigrateApply.run(['--database-url', `file:${dbFile}`, '--allow-destructive', '--yes', '--json'], { root: CLI_ROOT });
308+
} catch (error) {
309+
const trapped = error instanceof Error && error.message.startsWith('__PROCESS_EXIT__');
310+
if (!trapped && !isExitSignal(error)) throw error;
311+
if (!trapped) thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit;
312+
}
313+
const out = stdout.mock.calls.map((c) => String(c[0])).join('').trim();
314+
let payload: any;
315+
try {
316+
payload = JSON.parse(out);
317+
} catch {
318+
payload = { unparsed: out.slice(-600) };
319+
}
320+
return { payload, exitCode: processExit ?? thrownExit ?? (process.exitCode as number | undefined) ?? 0 };
321+
} finally {
322+
process.chdir(savedCwd);
323+
process.exitCode = savedExit;
324+
vi.restoreAllMocks();
325+
}
326+
}
327+
328+
/** The `organization_id` drops among an apply payload's entries, by table, sorted. */
329+
const organizationDropsIn = (entries: any[] | undefined): string[] =>
330+
(entries ?? [])
331+
.filter((d) => d?.op?.type === 'drop_column' && d.op.column === 'organization_id')
332+
.map((d) => String(d.table))
333+
.sort();
334+
251335
const columnDrops = (report: any): { status: string; summary: Record<string, number>; blockers: string[] } =>
252336
report.steps.find((s: { step: string }) => s.step === 'column-drops');
253337
const blockersFor = (report: any, table: string): string[] =>
@@ -337,3 +421,65 @@ describe('organization-ownership --apply: column-drop on an object the host decl
337421
expect(exitCode).toBe(1);
338422
}, CASE_TIMEOUT_MS);
339423
});
424+
425+
describe('os migrate apply --allow-destructive: the organization_id drop on a host-declared platform-global object waits for the ceremony', () => {
426+
it('holds the drop back until the marker is verified, naming the ceremony, and drops nothing', async () => {
427+
const dbFile = applyCase('no-marker');
428+
const { payload, exitCode } = await migrateApply(dbFile);
429+
430+
expect(exitCode, JSON.stringify(payload).slice(0, 600)).toBe(1);
431+
expect(payload).toMatchObject({
432+
message: 'refused_organization_ownership_unmigrated',
433+
tables: [...DECLARED].sort(),
434+
applied: [],
435+
created: [],
436+
marker: null,
437+
});
438+
expect(payload.detail).toMatch(/os migrate organization-ownership --apply/);
439+
// The drops the refusal held back are the ones the drift offered.
440+
expect(organizationDropsIn(payload.skipped)).toEqual([...DECLARED].sort());
441+
for (const table of HOST_OBJECTS) expect(hasOrganizationColumn(dbFile, table), table).toBe(true);
442+
}, CASE_TIMEOUT_MS);
443+
444+
it('CONTROL: once the ceremony\'s marker is verified, the drop applies', async () => {
445+
const dbFile = applyCase('verified-marker', (db) => {
446+
const now = new Date().toISOString();
447+
db.prepare('INSERT INTO sys_migration (id, last_run_at, verified_at, blocking) VALUES (?, ?, ?, 0)').run(
448+
ORGANIZATION_OWNERSHIP_MIGRATION_ID,
449+
now,
450+
now,
451+
);
452+
});
453+
const { payload, exitCode } = await migrateApply(dbFile);
454+
455+
expect(exitCode, JSON.stringify(payload).slice(0, 600)).toBe(0);
456+
expect(payload.message).toBeUndefined();
457+
expect(organizationDropsIn(payload.applied)).toEqual([...DECLARED].sort());
458+
for (const table of DECLARED) expect(hasOrganizationColumn(dbFile, table), table).toBe(false);
459+
// The undeclared object's column is the release's, and stays.
460+
expect(hasOrganizationColumn(dbFile, AUDIT)).toBe(true);
461+
}, CASE_TIMEOUT_MS);
462+
463+
it('CONTROL: a destructive drop of another column is unchanged', async () => {
464+
// The declared objects' organization_id already gone, and a column no
465+
// object declares on one of them: the run's one destructive change.
466+
const dbFile = applyCase('other-column', (db) => {
467+
for (const table of DECLARED) db.exec(`ALTER TABLE "${table}" DROP COLUMN organization_id`);
468+
db.exec(`ALTER TABLE "${LEDGER}" ADD COLUMN legacy_23684_note TEXT`);
469+
});
470+
const { payload, exitCode } = await migrateApply(dbFile);
471+
472+
expect(exitCode, JSON.stringify(payload).slice(0, 600)).toBe(0);
473+
expect(payload.message).toBeUndefined();
474+
expect(
475+
(payload.applied as any[]).filter((d) => d?.op?.type === 'drop_column').map((d) => `${d.table}.${d.op.column}`),
476+
).toEqual([`${LEDGER}.legacy_23684_note`]);
477+
const db = new Database(dbFile, { readonly: true });
478+
try {
479+
const columns = (db.prepare(`PRAGMA table_info("${LEDGER}")`).all() as Array<{ name: string }>).map((c) => c.name);
480+
expect(columns).not.toContain('legacy_23684_note');
481+
} finally {
482+
db.close();
483+
}
484+
}, CASE_TIMEOUT_MS);
485+
});

0 commit comments

Comments
 (0)