From 8955572b418a8123d4c0d90b5e97a1ccfc7a4fb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 17:45:31 +0000 Subject: [PATCH 1/5] fix(cli): os migrate plan names the ownership ceremony for the organization_id drops apply refuses Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- packages/cli/src/commands/migrate/plan.ts | 102 +++++++++++++++++++--- 1 file changed, 92 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/migrate/plan.ts b/packages/cli/src/commands/migrate/plan.ts index e3011dcc785..03cb80830b1 100644 --- a/packages/cli/src/commands/migrate/plan.ts +++ b/packages/cli/src/commands/migrate/plan.ts @@ -2,6 +2,7 @@ import { Command, Flags } from '@oclif/core'; import chalk from 'chalk'; +import type { ManagedDriftEntry } from '@objectstack/driver-sql'; import { printHeader, printSuccess, @@ -39,6 +40,7 @@ import { } from '../../utils/unmanaged-tables.js'; import { describeOccupancy, JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js'; import { describeDriverSqliteFile } from '../../utils/connection-display.js'; +import { ceremonyOrganizationColumnDrops, readOrganizationOwnershipMarker } from '../../utils/organization-ownership-drop-gate.js'; import { resolveTenancyPosture, collectGlobalUniques, @@ -83,6 +85,18 @@ import { * driver that declares nothing (every database but a Turso REMOTE one) gets * the plan it always got, byte for byte. The `--json` document is the report * and is unchanged. + * + * [#24925, ADR-0131 D10 item 5] Nor a remedy the apply refuses. On a database + * the organization-ownership ceremony has not verified (a 17.x one before the + * ceremony), `os migrate apply --allow-destructive` refuses every run that + * would drop an `organization_id` column, the whole run, and no flag stands + * that down (`utils/organization-ownership-drop-gate.ts`). Each such drop is + * listed and counted as drift with the ceremony as its remedy, and the footer + * offers `--allow-destructive` for nothing while one is listed: it names the + * ceremony's two commands instead. Which drops are held, and whether the + * ceremony verified, are read through the two functions `apply` asks, never a + * second list. A verified database, or one with no such drop, gets the plan it + * always got, byte for byte; the `--json` document is unchanged. */ export default class MigratePlan extends Command { static override description = @@ -405,34 +419,48 @@ export default class MigratePlan extends Command { // but a drop (it runs no SQLite table rebuild). Empty for a driver that // declares nothing, and then everything below reads as it always has. const limits = collectDriftApplyLimits(stack, drift); + // [#24925] The `organization_id` drops `os migrate apply` refuses here + // until the organization-ownership ceremony has verified. Empty on a + // verified database and on one with no such drop, and then everything + // below reads as it always has. + const held = await ceremonyHeldDrops(stack, drift); await renderPendingSchemaWork(pending); - if (limits.size === 0) { + if (limits.size === 0 && held.size === 0) { renderPlan(drift); } else { // Still listed, still counted: it IS drift. Only the remedy changes, // from a command that cannot perform it here to the reason it cannot. - renderPlan(drift, await describeWithApplyLimits(limits)); + const byLimit = limits.size === 0 ? (d: ManagedDriftEntry) => d.message : await describeWithApplyLimits(limits); + renderPlan(drift, held.size === 0 ? byLimit : await describeWithCeremonyHolds(held, byLimit)); } if (pending.length > 0) printInfo(await summarizePendingSchemaWork(pending)); printInfo(summarize(drift)); - if (limits.size === 0) { + if (limits.size === 0 && held.size === 0) { console.log(chalk.dim(' Apply with: ') + chalk.white('os migrate apply') + chalk.dim(' (add --allow-destructive for drops / tightenings)')); } else { // `--allow-destructive` only for a destructive entry this database can // apply; no apply line at all when it can apply nothing listed here. - const appliable = drift.filter((d) => !limits.has(d)); + const appliable = drift.filter((d) => !limits.has(d) && !held.has(d)); const appliableDestructive = appliable.filter((d) => d.category === 'destructive').length; - if (appliable.length > 0 || pending.length > 0) { - console.log(chalk.dim(' Apply with: ') + chalk.white('os migrate apply') + (appliableDestructive > 0 + // [#24925] While the ceremony holds a drop, `apply` refuses every + // --allow-destructive run, so it is offered for nothing: what `apply` + // performs now is the rest, and the destructive changes wait. + const appliableNow = held.size === 0 ? appliable : appliable.filter((d) => d.category !== 'destructive'); + if (appliableNow.length > 0 || pending.length > 0) { + console.log(chalk.dim(' Apply with: ') + chalk.white('os migrate apply') + (held.size === 0 && appliableDestructive > 0 ? chalk.dim(` (add --allow-destructive for the ${appliableDestructive} destructive change(s) it can apply on this database)`) : '')); } - printWarning( - `${limits.size} change(s) above cannot be applied on this database by "os migrate apply" (each says why), ` - + 'so they remain drift after it runs.', - ); + if (held.size > 0) printWarning(describeCeremonyHold(held.size, appliableDestructive)); + const limited = [...limits.keys()].filter((d) => !held.has(d)).length; + if (limited > 0) { + printWarning( + `${limited} change(s) above cannot be applied on this database by "os migrate apply" (each says why), ` + + 'so they remain drift after it runs.', + ); + } } console.log(chalk.dim(` ${timer.display()}`)); console.log(''); @@ -445,3 +473,57 @@ export default class MigratePlan extends Command { } } } + +/** + * [#24925] The entries `os migrate apply` refuses on this database: each + * `organization_id` drop, while the organization-ownership ceremony has not + * verified. Answered by the two functions `apply`'s drop gate asks, through + * the same `objectql` service, so the plan and the refusal cannot disagree: + * `ceremonyOrganizationColumnDrops`, asked of one entry at a time, says which + * entries; `readOrganizationOwnershipMarker` says whether the ceremony + * verified, and answers "not verified" for a database with no `sys_migration` + * table or row. The marker is read only when such a drop is listed, so every + * other plan reads nothing it did not read before. + */ +async function ceremonyHeldDrops( + stack: { kernel: unknown }, + drift: readonly ManagedDriftEntry[], +): Promise> { + const drops = drift.filter((d) => ceremonyOrganizationColumnDrops([d]).length > 0); + if (drops.length === 0) return new Set(); + const kernel = stack.kernel as { getService?: (n: string) => unknown }; + const engine = kernel.getService?.call(kernel, 'objectql'); + const marker = await readOrganizationOwnershipMarker(engine as never); + return marker.verified ? new Set() : new Set(drops); +} + +/** + * [#24925] The {@link renderPlan} line for a held drop: the finding without + * its `--allow-destructive` sentence, then the ceremony as its remedy. Every + * other entry is described by `otherwise`. The driver package is imported at + * the point of use, never statically (`schema-migrate.lazy-driver-import.test.ts`). + */ +async function describeWithCeremonyHolds( + held: ReadonlySet, + otherwise: (d: ManagedDriftEntry) => string, +): Promise<(d: ManagedDriftEntry) => string> { + const { driftFindingWithoutRemedy } = await import('@objectstack/driver-sql'); + return (d) => (held.has(d) + ? `${driftFindingWithoutRemedy(d)} — a drop the organization-ownership ceremony owns, and it has not verified ` + + 'on this database: the ceremony drops it itself (see below).' + : otherwise(d)); +} + +/** + * [#24925] The footer line for the held drops: why `--allow-destructive` is + * offered for nothing, and the ceremony's two commands, as `os migrate apply`'s + * refusal names them. `waiting` counts the other destructive entries that run + * could perform once the ceremony has verified. + */ +function describeCeremonyHold(drops: number, waiting: number): string { + return `${drops} organization_id drop(s) above are the organization-ownership ceremony's, and it has not verified ` + + 'on this database, so "os migrate apply --allow-destructive" refuses every run until it has' + + (waiting > 0 ? ` (the other ${waiting} destructive change(s) above wait for it too)` : '') + + '. Run "os migrate organization-ownership --out PLAN" to write and review its plan, take a backup, then ' + + '"os migrate organization-ownership --apply --plan-file PLAN --backup-taken"; it drops these columns itself.'; +} From 2042d462d290d1f71143a2349651dc666fa1872b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 17:52:49 +0000 Subject: [PATCH 2/5] test(cli): pin os migrate plan's ceremony hold on organization_id drops, with verified and no-drop controls Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- .../plan.organization-ownership-hold.test.ts | 350 ++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 packages/cli/src/commands/migrate/plan.organization-ownership-hold.test.ts diff --git a/packages/cli/src/commands/migrate/plan.organization-ownership-hold.test.ts b/packages/cli/src/commands/migrate/plan.organization-ownership-hold.test.ts new file mode 100644 index 00000000000..fd3d2ffc54b --- /dev/null +++ b/packages/cli/src/commands/migrate/plan.organization-ownership-hold.test.ts @@ -0,0 +1,350 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #24925 — `os migrate plan` never offers `--allow-destructive` for an + * `organization_id` drop that `os migrate apply` refuses, and names the + * organization-ownership ceremony instead (ADR-0131 D10 item 5). + * + * ## What used to happen + * + * On a 17.x database before the ceremony, the plan listed each + * `organization_id` column 18.x no longer declares with the differ's remedy, + * `"os migrate apply --allow-destructive" to drop it.`, and closed with + * `(add --allow-destructive for drops / tightenings)`. That apply refuses the + * whole run while the ceremony has not verified (the drop gate, + * `utils/organization-ownership-drop-gate.ts`), and the plan said nothing of + * the ceremony. + * + * ## What is pinned + * + * The plan asks the drop gate's own two functions: which entries are + * `organization_id` drops, and whether the ceremony's marker is verified, read + * through the stack's `objectql` service as `apply` reads it. + * + * 1. Unverified (no marker row, an unverified row, or no `sys_migration` + * table to read): each such drop is listed and counted with the ceremony + * as its remedy; every other entry keeps its message; the footer offers + * `--allow-destructive` for nothing and names the ceremony's two commands. + * 2. CONTROL: a verified marker gives the plan as it always was. + * 3. CONTROL: no `organization_id` drop gives the plan as it always was, and + * the marker is not read. + * 4. The `--json` document is the report, unchanged, and reads no marker. + * 5. Beside a driver's declared apply limit (`plan.apply-limit.test.ts`), + * both remedies hold and the limit's count excludes the held drops. + * + * `bootSchemaStack` is stubbed, so this runs without a database. The marker + * read itself is the real `readOrganizationOwnershipMarker` over a fake + * engine. + */ + +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system'; + +const boot = vi.hoisted(() => vi.fn(async (_opts: Record): Promise => { + throw new Error('the database was opened'); +})); +vi.mock('../../utils/schema-migrate.js', async (importOriginal) => ({ + ...(await importOriginal()), + bootSchemaStack: boot, +})); +vi.mock('../../utils/migrate-occupancy-gate.js', () => ({ + OCCUPANCY_HINT: 'occupancy hint', + probeMigrationTarget: vi.fn(async () => ({ status: 'not_applicable' })), +})); + +type PlanCommand = (typeof import('./plan.js'))['default']; +let MigratePlan: PlanCommand; + +beforeAll(async () => { + ({ default: MigratePlan } = await import('./plan.js')); +}, 60_000); + +const CASE_TIMEOUT_MS = 60_000; +const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url)); + +afterEach(() => { + boot.mockReset(); + boot.mockImplementation(async () => { + throw new Error('the database was opened'); + }); +}); + +type Entry = { + kind: string; remoteName: string; table: string; column?: string; severity: string; + category: 'safe' | 'needs_confirm' | 'destructive'; op: Record & { type: string }; message: string; +}; + +/** An orphaned column as the shared differ reports it, with the message it writes. */ +function orphan(table: string, column: string): Entry { + return { + kind: 'unmapped_column', remoteName: table, table, column, severity: 'warning', category: 'destructive', + op: { type: 'drop_column', table, column }, + message: `${table}.${column}: column exists in the database but not in metadata (orphaned) — "os migrate apply --allow-destructive" to drop it.`, + }; +} + +/** Two `organization_id` drops a 17.x database carries before the ceremony. */ +const ORG_JOB = orphan('sys_job', 'organization_id'); +const ORG_SECRET = orphan('sys_secret', 'organization_id'); +/** A destructive drop the ceremony does not own. */ +const LEGACY = orphan('sys_sso_provider', 'created_by'); +/** A safe entry, which `os migrate apply` performs without --allow-destructive. */ +const INDEX: Entry = { + kind: 'missing_index', remoteName: 'sys_job', table: 'sys_job', severity: 'info', category: 'safe', + op: { type: 'create_index', table: 'sys_job', createIndexName: 'idx_sys_job_created_at' }, + message: `sys_job: metadata declares index 'idx_sys_job_created_at' (created_at) but the database has no such index — run "os migrate apply" to create it.`, +}; +/** A tightening a Turso REMOTE driver declares it cannot apply. */ +const TIGHTEN: Entry = { + kind: 'nullability_mismatch', remoteName: 't', table: 't', column: 'code', severity: 'error', category: 'destructive', + op: { type: 'tighten_not_null', table: 't', column: 'code' }, + message: 't.code: metadata declares `storage.notNull` but the column is nullable — existing nulls must be backfilled. Run "os migrate apply --allow-destructive".', +}; +const LIMIT = 'it needs the SQLite table rebuild, which the Turso REMOTE transport does not run'; + +/** The line each held drop is listed with: the finding, then the ceremony. */ +const heldLine = (e: Entry) => + ` ${e.table}.${e.column}: column exists in the database but not in metadata (orphaned) — a drop the ` + + 'organization-ownership ceremony owns, and it has not verified on this database: the ceremony drops it itself (see below).'; + +const CEREMONY_STEPS = + 'Run "os migrate organization-ownership --out PLAN" to write and review its plan, take a backup, then ' + + '"os migrate organization-ownership --apply --plan-file PLAN --backup-taken"; it drops these columns itself.'; + +const now = '2026-10-20T00:00:00.000Z'; +/** The ceremony's `sys_migration` row as `readDataMigrationFlag` reads it. */ +const VERIFIED = { id: ORGANIZATION_OWNERSHIP_MIGRATION_ID, last_run_at: now, verified_at: now, blocking: 0 }; +const UNVERIFIED = { id: ORGANIZATION_OWNERSHIP_MIGRATION_ID, last_run_at: now, verified_at: null, blocking: 2 }; + +/** + * The `objectql` service as the marker read meets it: `sys_migration` is + * registered, and the row read answers `row` (or throws it, as a read of a + * table the database does not have does). + */ +function engineAnswering(row: Record | null | Error) { + return { + getObject: (name: string) => (name === 'sys_migration' ? { name } : undefined), + findOne: vi.fn(async (object: string, options: { where?: { id?: string } }) => { + expect(object).toBe('sys_migration'); + expect(options.where?.id).toBe(ORGANIZATION_OWNERSHIP_MIGRATION_ID); + if (row instanceof Error) throw row; + return row; + }), + }; +} + +/** A booted stack as `bootSchemaStack` answers it, its kernel serving `engine` as `objectql`. */ +function stackWith( + drift: Entry[], + engine: ReturnType, + opts: { limited?: ReadonlySet; pending?: unknown[] } = {}, +) { + const getService = vi.fn((name: string) => { + if (name === 'objectql') return engine; + throw new Error(`no ${name} service in this pin`); + }); + return { + getService, + stack: { + driver: { config: { url: 'postgres://db.example/app' } }, + dbLabel: 'postgres://db.example/app', + dbSource: { kind: 'flag' }, + schemaSync: 'deferred', + telemetryDatabase: null, + drivers: [], + detectManagedDrift: vi.fn(async () => drift), + driftApplyLimit: (e: Entry): string | null => (opts.limited?.has(e.op.type) ? LIMIT : null), + applyMigrationEntries: vi.fn(async () => { throw new Error('a plan applies nothing'); }), + pendingSchemaWork: opts.pending ?? [], + flushSchemaDdl: vi.fn(async () => { throw new Error('a plan creates nothing'); }), + managedTableCount: 3, + kernel: { getService }, + tableAbsent: () => false, + allObjects: () => [], + composition: { + plugins: [], hostConfigPath: null, hostConfigLoaded: false, hostConfigError: null, + servedBoot: { mirrored: false, reason: 'not-composed' }, notes: [], coverage: null, + }, + shutdown: async () => {}, + }, + }; +} + +/** The real command, with its output captured: the JSON document, or the human lines. */ +async function run(argv: string[]): Promise<{ payload: any; lines: string[]; exitCode: number }> { + const chunks: string[] = []; + const said: string[] = []; + const savedExit = process.exitCode; + vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => { + chunks.push(String(chunk)); + const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; + if (cb) cb(); + return true; + }) as typeof process.stdout.write); + // Its callback runs too, or the command's exit waits out its drain timeout + // (`utils/one-shot-exit.ts`) on every case. + vi.spyOn(process.stderr, 'write').mockImplementation(((_chunk: unknown, ...rest: unknown[]) => { + const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; + if (cb) cb(); + return true; + }) as typeof process.stderr.write); + const capture = (...parts: unknown[]) => { said.push(parts.map(String).join(' ')); }; + vi.spyOn(console, 'log').mockImplementation(capture); + vi.spyOn(console, 'warn').mockImplementation(capture); + vi.spyOn(console, 'error').mockImplementation(capture); + let processExit: number | undefined; + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + processExit = code ?? 0; + throw new Error(`__PROCESS_EXIT__:${processExit}`); + }) as never); + let thrownExit: number | undefined; + try { + await MigratePlan.run(argv, { root: CLI_ROOT }); + } catch (error) { + const oclif = (error as { oclif?: { exit?: number } }).oclif; + const trapped = error instanceof Error && error.message.startsWith('__PROCESS_EXIT__'); + if (oclif === undefined && !trapped) throw error; + if (oclif) thrownExit = oclif.exit; + } + const exitCode = processExit ?? thrownExit ?? (process.exitCode as number | undefined) ?? 0; + process.exitCode = savedExit; + vi.restoreAllMocks(); + const out = chunks.join('').trim(); + return { + payload: argv.includes('--json') ? JSON.parse(out) : null, + lines: said.join('\n').split('\n'), + exitCode, + }; +} + +/** The line the plan prints under an entry's heading: its finding and its remedy. */ +function entryLine(lines: string[], target: string): string { + const at = lines.findIndex((l) => l.includes(` ${target} [`)); + expect(at, `no ${target} entry in:\n${lines.join('\n')}`).toBeGreaterThanOrEqual(0); + return lines[at + 1]!; +} + +/** Everything the plan prints below its summary line, but the blank lines and its timer: the footer. */ +function footer(lines: string[]): string[] { + const at = lines.findIndex((l) => /\d+ change\(s\): \d+ safe/.test(l)); + expect(at, lines.join('\n')).toBeGreaterThanOrEqual(0); + return lines.slice(at + 1).filter((l) => l.trim() !== '' && !/^\s+\d+ms$/.test(l)); +} + +describe('a database the organization-ownership ceremony has not verified', () => { + it('each organization_id drop names the ceremony, never --allow-destructive; the footer offers --allow-destructive for nothing and names both ceremony commands', async () => { + const engine = engineAnswering(null); + const { stack, getService } = stackWith([INDEX, ORG_JOB, LEGACY, ORG_SECRET], engine); + boot.mockResolvedValueOnce(stack); + const { lines, exitCode } = await run([]); + expect(exitCode, lines.join('\n')).toBe(0); + + expect(entryLine(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB)); + expect(entryLine(lines, 'sys_secret.organization_id')).toBe(heldLine(ORG_SECRET)); + // The ceremony does not own this drop: it keeps the differ's message. + expect(entryLine(lines, 'sys_sso_provider.created_by')).toBe(` ${LEGACY.message}`); + expect(entryLine(lines, 'sys_job [idx_sys_job_created_at]')).toBe(` ${INDEX.message}`); + // Still drift: listed and counted, never dropped from the plan. + expect(lines.join('\n')).toContain('4 change(s): 1 safe, 0 needs-confirm, 3 destructive'); + + expect(footer(lines)).toEqual([ + ' Apply with: os migrate apply', + ' ⚠ 2 organization_id drop(s) above are the organization-ownership ceremony\'s, and it has not verified on this ' + + 'database, so "os migrate apply --allow-destructive" refuses every run until it has (the other 1 destructive ' + + `change(s) above wait for it too). ${CEREMONY_STEPS}`, + ]); + // Asked once, of the service `apply`'s drop gate asks. + expect(getService.mock.calls).toEqual([['objectql']]); + expect(engine.findOne).toHaveBeenCalledTimes(1); + }, CASE_TIMEOUT_MS); + + it('when apply can perform nothing listed until the ceremony, there is no apply line, only the ceremony', async () => { + boot.mockResolvedValueOnce(stackWith([ORG_JOB], engineAnswering(null)).stack); + const { lines, exitCode } = await run([]); + expect(exitCode, lines.join('\n')).toBe(0); + expect(entryLine(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB)); + expect(footer(lines)).toEqual([ + ' ⚠ 1 organization_id drop(s) above are the organization-ownership ceremony\'s, and it has not verified on this ' + + `database, so "os migrate apply --allow-destructive" refuses every run until it has. ${CEREMONY_STEPS}`, + ]); + }, CASE_TIMEOUT_MS); + + it('an unverified marker row holds the drops too', async () => { + boot.mockResolvedValueOnce(stackWith([ORG_JOB, LEGACY], engineAnswering(UNVERIFIED)).stack); + const { lines, exitCode } = await run([]); + expect(exitCode, lines.join('\n')).toBe(0); + expect(entryLine(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB)); + expect(lines.join('\n')).not.toContain('(add --allow-destructive'); + expect(lines.join('\n')).toContain('1 organization_id drop(s) above are the organization-ownership ceremony\'s'); + }, CASE_TIMEOUT_MS); + + it('a database with no sys_migration table to read answers "not verified": the read fails, the plan does not', async () => { + const engine = engineAnswering(new Error('SQLITE_ERROR: no such table: sys_migration')); + boot.mockResolvedValueOnce(stackWith([ORG_JOB], engine).stack); + const { lines, exitCode } = await run([]); + expect(exitCode, lines.join('\n')).toBe(0); + expect(engine.findOne).toHaveBeenCalledTimes(1); + expect(entryLine(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB)); + expect(footer(lines).join('\n')).toContain(CEREMONY_STEPS); + }, CASE_TIMEOUT_MS); + + it('--json: the document is the report, unchanged — each message as the differ wrote it, and no marker is read', async () => { + const { stack, getService } = stackWith([INDEX, ORG_JOB, LEGACY], engineAnswering(null)); + boot.mockResolvedValueOnce(stack); + const { payload, exitCode } = await run(['--json']); + expect(exitCode).toBe(0); + expect(Object.keys(payload).sort()).toEqual( + ['changes', 'database', 'databaseSource', 'duration', 'managedTables', 'pending', 'total', 'unmanagedTables'], + ); + expect(payload.changes.map((c: Entry) => c.message)).toEqual([INDEX.message, ORG_JOB.message, LEGACY.message]); + expect(getService).not.toHaveBeenCalled(); + }, CASE_TIMEOUT_MS); + + it('beside a driver\'s declared apply limit, each keeps its own remedy, and the limit\'s count is its own', async () => { + const drift = [ORG_JOB, TIGHTEN, INDEX]; + boot.mockResolvedValueOnce(stackWith(drift, engineAnswering(null), { limited: new Set(['tighten_not_null']) }).stack); + const { lines, exitCode } = await run([]); + expect(exitCode, lines.join('\n')).toBe(0); + expect(entryLine(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB)); + expect(entryLine(lines, 't.code')).toContain(`"os migrate apply" cannot apply it on this database: ${LIMIT}.`); + expect(entryLine(lines, 't.code')).not.toContain('--allow-destructive'); + expect(footer(lines)).toEqual([ + ' Apply with: os migrate apply', + ' ⚠ 1 organization_id drop(s) above are the organization-ownership ceremony\'s, and it has not verified on this ' + + `database, so "os migrate apply --allow-destructive" refuses every run until it has. ${CEREMONY_STEPS}`, + ' ⚠ 1 change(s) above cannot be applied on this database by "os migrate apply" (each says why), so they ' + + 'remain drift after it runs.', + ]); + }, CASE_TIMEOUT_MS); +}); + +describe('CONTROL: the plan as it always was', () => { + /** What the plan prints for `drift` when nothing holds an entry back: each message, and the one footer. */ + function expectTodaysPlan(lines: string[], drift: Entry[]): void { + for (const e of drift) { + const target = e.column ? `${e.table}.${e.column}` : `${e.table} [${String(e.op.createIndexName)}]`; + expect(entryLine(lines, target)).toBe(` ${e.message}`); + } + expect(lines.join('\n')).not.toContain('organization-ownership'); + expect(footer(lines)).toEqual([' Apply with: os migrate apply (add --allow-destructive for drops / tightenings)']); + } + + it('a verified ceremony: every organization_id drop keeps its message, and the footer is the one it always was', async () => { + const engine = engineAnswering(VERIFIED); + boot.mockResolvedValueOnce(stackWith([INDEX, ORG_JOB, LEGACY, ORG_SECRET], engine).stack); + const { lines, exitCode } = await run([]); + expect(exitCode, lines.join('\n')).toBe(0); + expect(engine.findOne).toHaveBeenCalledTimes(1); + expectTodaysPlan(lines, [INDEX, ORG_JOB, LEGACY, ORG_SECRET]); + }, CASE_TIMEOUT_MS); + + it('no organization_id drop: the plan is the one it always was, and the marker is never read', async () => { + const { stack, getService } = stackWith([INDEX, LEGACY], engineAnswering(null)); + boot.mockResolvedValueOnce(stack); + const { lines, exitCode } = await run([]); + expect(exitCode, lines.join('\n')).toBe(0); + expect(getService).not.toHaveBeenCalled(); + expectTodaysPlan(lines, [INDEX, LEGACY]); + }, CASE_TIMEOUT_MS); +}); From acb47261239260393366af9e0bd5886eeb1d742d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 17:55:41 +0000 Subject: [PATCH 3/5] test(cli): fold the ceremony-hold pins into plan.apply-limit.test.ts, one module load for both Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- .../commands/migrate/plan.apply-limit.test.ts | 235 +++++++++++- .../plan.organization-ownership-hold.test.ts | 350 ------------------ 2 files changed, 234 insertions(+), 351 deletions(-) delete mode 100644 packages/cli/src/commands/migrate/plan.organization-ownership-hold.test.ts diff --git a/packages/cli/src/commands/migrate/plan.apply-limit.test.ts b/packages/cli/src/commands/migrate/plan.apply-limit.test.ts index aab69f52b18..4f8b8ec8fdf 100644 --- a/packages/cli/src/commands/migrate/plan.apply-limit.test.ts +++ b/packages/cli/src/commands/migrate/plan.apply-limit.test.ts @@ -29,10 +29,33 @@ * * `bootSchemaStack` is stubbed, so this runs without a database; the same * cases run against a real `sqld` in `apply.remote-face.integration.test.ts`. + * + * ## #24925 — a drop `apply` refuses: the ceremony's `organization_id` drops + * + * On a 17.x database before the organization-ownership ceremony, the plan + * listed each `organization_id` column 18.x no longer declares with the + * differ's `--allow-destructive` remedy and the same footer, while `os migrate + * apply` refuses the whole `--allow-destructive` run until the ceremony has + * verified (`utils/organization-ownership-drop-gate.ts`, ADR-0131 D10 item 5). + * The plan asks the drop gate's own two functions, through the stack's + * `objectql` service as `apply` does: + * + * 5. Unverified (no marker row, an unverified row, or no `sys_migration` + * table to read): each such drop is listed and counted with the ceremony + * as its remedy, every other entry keeps its message, and the footer offers + * `--allow-destructive` for nothing and names the ceremony's two commands. + * Beside a driver's declared limit, each keeps its own remedy. + * 6. CONTROL: a verified marker, or no `organization_id` drop, gives the plan + * as it always was; with no such drop the marker is not even read. + * 7. The `--json` document is unchanged and reads no marker. + * + * The marker read is the real `readOrganizationOwnershipMarker` over a fake + * engine. */ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { fileURLToPath } from 'node:url'; +import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system'; const boot = vi.hoisted(() => vi.fn(async (_opts: Record): Promise => { throw new Error('the database was opened'); @@ -135,7 +158,13 @@ async function run(argv: string[]): Promise<{ payload: any; lines: string[]; exi if (cb) cb(); return true; }) as typeof process.stdout.write); - vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write); + // Its callback runs too, or the command's exit waits out its drain timeout + // (`utils/one-shot-exit.ts`, 2 s) on every case. + vi.spyOn(process.stderr, 'write').mockImplementation(((_chunk: unknown, ...rest: unknown[]) => { + const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; + if (cb) cb(); + return true; + }) as typeof process.stderr.write); const capture = (...parts: unknown[]) => { said.push(parts.map(String).join(' ')); }; vi.spyOn(console, 'log').mockImplementation(capture); vi.spyOn(console, 'warn').mockImplementation(capture); @@ -233,3 +262,207 @@ describe('CONTROL: a driver that declares nothing reads exactly as before', () = expect(footer(lines)).toEqual([' Apply with: os migrate apply (add --allow-destructive for drops / tightenings)']); }, CASE_TIMEOUT_MS); }); + +// ── #24925: the organization-ownership ceremony's organization_id drops ────── + +/** An orphaned column as the shared differ reports it, with the message it writes. */ +function orphan(table: string, column: string): Entry { + return { + kind: 'unmapped_column', remoteName: table, table, column, severity: 'warning', category: 'destructive', + op: { type: 'drop_column', table, column }, + message: `${table}.${column}: column exists in the database but not in metadata (orphaned) — "os migrate apply --allow-destructive" to drop it.`, + }; +} + +/** Two `organization_id` drops a 17.x database carries before the ceremony. */ +const ORG_JOB = orphan('sys_job', 'organization_id'); +const ORG_SECRET = orphan('sys_secret', 'organization_id'); +/** A destructive drop the ceremony does not own. */ +const LEGACY = orphan('sys_sso_provider', 'created_by'); +/** A safe entry, which `os migrate apply` performs without --allow-destructive. */ +const INDEX: Entry = { + kind: 'missing_index', remoteName: 'sys_job', table: 'sys_job', severity: 'info', category: 'safe', + op: { type: 'create_index', table: 'sys_job', createIndexName: 'idx_sys_job_created_at' }, + message: `sys_job: metadata declares index 'idx_sys_job_created_at' (created_at) but the database has no such index — run "os migrate apply" to create it.`, +}; + +/** The line each held drop is listed with: the finding, then the ceremony. */ +const heldLine = (e: Entry) => + ` ${e.table}.${e.column}: column exists in the database but not in metadata (orphaned) — a drop the ` + + 'organization-ownership ceremony owns, and it has not verified on this database: the ceremony drops it itself (see below).'; + +const CEREMONY_STEPS = + 'Run "os migrate organization-ownership --out PLAN" to write and review its plan, take a backup, then ' + + '"os migrate organization-ownership --apply --plan-file PLAN --backup-taken"; it drops these columns itself.'; +const ceremonyLine = (drops: number, waiting: number) => + ` ⚠ ${drops} organization_id drop(s) above are the organization-ownership ceremony's, and it has not verified on ` + + 'this database, so "os migrate apply --allow-destructive" refuses every run until it has' + + (waiting > 0 ? ` (the other ${waiting} destructive change(s) above wait for it too)` : '') + + `. ${CEREMONY_STEPS}`; + +const MARKED_AT = '2026-10-20T00:00:00.000Z'; +/** The ceremony's `sys_migration` row as `readDataMigrationFlag` reads it. */ +const VERIFIED = { id: ORGANIZATION_OWNERSHIP_MIGRATION_ID, last_run_at: MARKED_AT, verified_at: MARKED_AT, blocking: 0 }; +const UNVERIFIED = { id: ORGANIZATION_OWNERSHIP_MIGRATION_ID, last_run_at: MARKED_AT, verified_at: null, blocking: 2 }; + +/** + * The `objectql` service as the marker read meets it: `sys_migration` is + * registered, and the row read answers `row` (or throws it, as a read of a + * table the database does not have does). + */ +function engineAnswering(row: Record | null | Error) { + return { + getObject: (name: string) => (name === 'sys_migration' ? { name } : undefined), + findOne: vi.fn(async (object: string, options: { where?: { id?: string } }) => { + expect(object).toBe('sys_migration'); + expect(options.where?.id).toBe(ORGANIZATION_OWNERSHIP_MIGRATION_ID); + if (row instanceof Error) throw row; + return row; + }), + }; +} + +/** A booted local stack whose kernel serves `engine` as `objectql`; `limited` as {@link stackWith}'s. */ +function ceremonyStackWith( + drift: Entry[], + engine: ReturnType, + limited: ReadonlySet = new Set(), +) { + const getService = vi.fn((name: string) => { + if (name === 'objectql') return engine; + throw new Error(`no ${name} service in this pin`); + }); + const { stack } = stackWith(drift, limited); + return { + getService, + stack: { + ...stack, + driver: { config: { url: 'postgres://db.example/app' } }, + dbLabel: 'postgres://db.example/app', + schemaSync: 'deferred', + kernel: { getService }, + }, + }; +} + +/** The line the plan prints under the entry acting on `target`: its finding and its remedy. */ +function lineUnder(lines: string[], target: string): string { + const at = lines.findIndex((l) => l.includes(` ${target} [`)); + expect(at, `no ${target} entry in:\n${lines.join('\n')}`).toBeGreaterThanOrEqual(0); + return lines[at + 1]!; +} + +/** Everything the plan prints below its summary line, but the blank lines and its timer. */ +function footerUnder(lines: string[]): string[] { + const at = lines.findIndex((l) => /\d+ change\(s\): \d+ safe/.test(l)); + expect(at, lines.join('\n')).toBeGreaterThanOrEqual(0); + return lines.slice(at + 1).filter((l) => l.trim() !== '' && !/^\s+\d+ms$/.test(l)); +} + +describe('#24925 — a database the organization-ownership ceremony has not verified', () => { + it('each organization_id drop names the ceremony, never --allow-destructive; the footer offers --allow-destructive for nothing and names both ceremony commands', async () => { + const engine = engineAnswering(null); + const { stack, getService } = ceremonyStackWith([INDEX, ORG_JOB, LEGACY, ORG_SECRET], engine); + boot.mockResolvedValueOnce(stack); + const { lines, exitCode } = await run([]); + expect(exitCode, lines.join('\n')).toBe(0); + + expect(lineUnder(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB)); + expect(lineUnder(lines, 'sys_secret.organization_id')).toBe(heldLine(ORG_SECRET)); + // The ceremony does not own this drop: it keeps the differ's message. + expect(lineUnder(lines, 'sys_sso_provider.created_by')).toBe(` ${LEGACY.message}`); + expect(lineUnder(lines, 'sys_job [idx_sys_job_created_at]')).toBe(` ${INDEX.message}`); + // Still drift: listed and counted, never dropped from the plan. + expect(lines.join('\n')).toContain('4 change(s): 1 safe, 0 needs-confirm, 3 destructive'); + + expect(footerUnder(lines)).toEqual([' Apply with: os migrate apply', ceremonyLine(2, 1)]); + // Asked once, of the service `apply`'s drop gate asks. + expect(getService.mock.calls).toEqual([['objectql']]); + expect(engine.findOne).toHaveBeenCalledTimes(1); + }, CASE_TIMEOUT_MS); + + it('when apply can perform nothing listed before the ceremony, there is no apply line, only the ceremony', async () => { + boot.mockResolvedValueOnce(ceremonyStackWith([ORG_JOB], engineAnswering(null)).stack); + const { lines, exitCode } = await run([]); + expect(exitCode, lines.join('\n')).toBe(0); + expect(lineUnder(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB)); + expect(footerUnder(lines)).toEqual([ceremonyLine(1, 0)]); + }, CASE_TIMEOUT_MS); + + it('an unverified marker row holds the drops too', async () => { + boot.mockResolvedValueOnce(ceremonyStackWith([ORG_JOB, LEGACY], engineAnswering(UNVERIFIED)).stack); + const { lines, exitCode } = await run([]); + expect(exitCode, lines.join('\n')).toBe(0); + expect(lineUnder(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB)); + expect(footerUnder(lines)).toEqual([ceremonyLine(1, 1)]); + }, CASE_TIMEOUT_MS); + + it('a database with no sys_migration table to read answers "not verified": the read fails, the plan does not', async () => { + const engine = engineAnswering(new Error('SQLITE_ERROR: no such table: sys_migration')); + boot.mockResolvedValueOnce(ceremonyStackWith([ORG_JOB], engine).stack); + const { lines, exitCode } = await run([]); + expect(exitCode, lines.join('\n')).toBe(0); + expect(engine.findOne).toHaveBeenCalledTimes(1); + expect(lineUnder(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB)); + expect(footerUnder(lines)).toEqual([ceremonyLine(1, 0)]); + }, CASE_TIMEOUT_MS); + + it('beside a driver\'s declared apply limit, each keeps its own remedy, and the limit\'s count is its own', async () => { + boot.mockResolvedValueOnce( + ceremonyStackWith([ORG_JOB, TIGHTEN, INDEX], engineAnswering(null), new Set(['tighten_not_null'])).stack, + ); + const { lines, exitCode } = await run([]); + expect(exitCode, lines.join('\n')).toBe(0); + expect(lineUnder(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB)); + expect(entryLine(lines, 'tighten_not_null')).toContain(`"os migrate apply" cannot apply it on this database: ${LIMIT}.`); + expect(entryLine(lines, 'tighten_not_null')).not.toContain('--allow-destructive'); + expect(footerUnder(lines)).toEqual([ + ' Apply with: os migrate apply', + ceremonyLine(1, 0), + ' ⚠ 1 change(s) above cannot be applied on this database by "os migrate apply" (each says why), so they ' + + 'remain drift after it runs.', + ]); + }, CASE_TIMEOUT_MS); + + it('--json: the document is the report, unchanged — each message as the differ wrote it, and no marker is read', async () => { + const { stack, getService } = ceremonyStackWith([INDEX, ORG_JOB, LEGACY], engineAnswering(null)); + boot.mockResolvedValueOnce(stack); + const { payload, exitCode } = await run(['--json']); + expect(exitCode).toBe(0); + expect(Object.keys(payload).sort()).toEqual( + ['changes', 'database', 'databaseSource', 'duration', 'managedTables', 'pending', 'total', 'unmanagedTables'], + ); + expect(payload.changes.map((c: Entry) => c.message)).toEqual([INDEX.message, ORG_JOB.message, LEGACY.message]); + expect(getService).not.toHaveBeenCalled(); + }, CASE_TIMEOUT_MS); +}); + +describe('#24925 CONTROL: the plan as it always was', () => { + /** What the plan prints for `drift` when nothing holds an entry back: each message, and the one footer. */ + function expectTodaysPlan(lines: string[], drift: Entry[]): void { + for (const e of drift) { + const target = e.column ? `${e.table}.${e.column}` : `${e.table} [${String(e.op.createIndexName)}]`; + expect(lineUnder(lines, target)).toBe(` ${e.message}`); + } + expect(lines.join('\n')).not.toContain('organization-ownership'); + expect(footerUnder(lines)).toEqual([' Apply with: os migrate apply (add --allow-destructive for drops / tightenings)']); + } + + it('a verified ceremony: every organization_id drop keeps its message, and the footer is the one it always was', async () => { + const engine = engineAnswering(VERIFIED); + boot.mockResolvedValueOnce(ceremonyStackWith([INDEX, ORG_JOB, LEGACY, ORG_SECRET], engine).stack); + const { lines, exitCode } = await run([]); + expect(exitCode, lines.join('\n')).toBe(0); + expect(engine.findOne).toHaveBeenCalledTimes(1); + expectTodaysPlan(lines, [INDEX, ORG_JOB, LEGACY, ORG_SECRET]); + }, CASE_TIMEOUT_MS); + + it('no organization_id drop: the plan is the one it always was, and the marker is never read', async () => { + const { stack, getService } = ceremonyStackWith([INDEX, LEGACY], engineAnswering(null)); + boot.mockResolvedValueOnce(stack); + const { lines, exitCode } = await run([]); + expect(exitCode, lines.join('\n')).toBe(0); + expect(getService).not.toHaveBeenCalled(); + expectTodaysPlan(lines, [INDEX, LEGACY]); + }, CASE_TIMEOUT_MS); +}); diff --git a/packages/cli/src/commands/migrate/plan.organization-ownership-hold.test.ts b/packages/cli/src/commands/migrate/plan.organization-ownership-hold.test.ts deleted file mode 100644 index fd3d2ffc54b..00000000000 --- a/packages/cli/src/commands/migrate/plan.organization-ownership-hold.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * #24925 — `os migrate plan` never offers `--allow-destructive` for an - * `organization_id` drop that `os migrate apply` refuses, and names the - * organization-ownership ceremony instead (ADR-0131 D10 item 5). - * - * ## What used to happen - * - * On a 17.x database before the ceremony, the plan listed each - * `organization_id` column 18.x no longer declares with the differ's remedy, - * `"os migrate apply --allow-destructive" to drop it.`, and closed with - * `(add --allow-destructive for drops / tightenings)`. That apply refuses the - * whole run while the ceremony has not verified (the drop gate, - * `utils/organization-ownership-drop-gate.ts`), and the plan said nothing of - * the ceremony. - * - * ## What is pinned - * - * The plan asks the drop gate's own two functions: which entries are - * `organization_id` drops, and whether the ceremony's marker is verified, read - * through the stack's `objectql` service as `apply` reads it. - * - * 1. Unverified (no marker row, an unverified row, or no `sys_migration` - * table to read): each such drop is listed and counted with the ceremony - * as its remedy; every other entry keeps its message; the footer offers - * `--allow-destructive` for nothing and names the ceremony's two commands. - * 2. CONTROL: a verified marker gives the plan as it always was. - * 3. CONTROL: no `organization_id` drop gives the plan as it always was, and - * the marker is not read. - * 4. The `--json` document is the report, unchanged, and reads no marker. - * 5. Beside a driver's declared apply limit (`plan.apply-limit.test.ts`), - * both remedies hold and the limit's count excludes the held drops. - * - * `bootSchemaStack` is stubbed, so this runs without a database. The marker - * read itself is the real `readOrganizationOwnershipMarker` over a fake - * engine. - */ - -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -import { fileURLToPath } from 'node:url'; -import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system'; - -const boot = vi.hoisted(() => vi.fn(async (_opts: Record): Promise => { - throw new Error('the database was opened'); -})); -vi.mock('../../utils/schema-migrate.js', async (importOriginal) => ({ - ...(await importOriginal()), - bootSchemaStack: boot, -})); -vi.mock('../../utils/migrate-occupancy-gate.js', () => ({ - OCCUPANCY_HINT: 'occupancy hint', - probeMigrationTarget: vi.fn(async () => ({ status: 'not_applicable' })), -})); - -type PlanCommand = (typeof import('./plan.js'))['default']; -let MigratePlan: PlanCommand; - -beforeAll(async () => { - ({ default: MigratePlan } = await import('./plan.js')); -}, 60_000); - -const CASE_TIMEOUT_MS = 60_000; -const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url)); - -afterEach(() => { - boot.mockReset(); - boot.mockImplementation(async () => { - throw new Error('the database was opened'); - }); -}); - -type Entry = { - kind: string; remoteName: string; table: string; column?: string; severity: string; - category: 'safe' | 'needs_confirm' | 'destructive'; op: Record & { type: string }; message: string; -}; - -/** An orphaned column as the shared differ reports it, with the message it writes. */ -function orphan(table: string, column: string): Entry { - return { - kind: 'unmapped_column', remoteName: table, table, column, severity: 'warning', category: 'destructive', - op: { type: 'drop_column', table, column }, - message: `${table}.${column}: column exists in the database but not in metadata (orphaned) — "os migrate apply --allow-destructive" to drop it.`, - }; -} - -/** Two `organization_id` drops a 17.x database carries before the ceremony. */ -const ORG_JOB = orphan('sys_job', 'organization_id'); -const ORG_SECRET = orphan('sys_secret', 'organization_id'); -/** A destructive drop the ceremony does not own. */ -const LEGACY = orphan('sys_sso_provider', 'created_by'); -/** A safe entry, which `os migrate apply` performs without --allow-destructive. */ -const INDEX: Entry = { - kind: 'missing_index', remoteName: 'sys_job', table: 'sys_job', severity: 'info', category: 'safe', - op: { type: 'create_index', table: 'sys_job', createIndexName: 'idx_sys_job_created_at' }, - message: `sys_job: metadata declares index 'idx_sys_job_created_at' (created_at) but the database has no such index — run "os migrate apply" to create it.`, -}; -/** A tightening a Turso REMOTE driver declares it cannot apply. */ -const TIGHTEN: Entry = { - kind: 'nullability_mismatch', remoteName: 't', table: 't', column: 'code', severity: 'error', category: 'destructive', - op: { type: 'tighten_not_null', table: 't', column: 'code' }, - message: 't.code: metadata declares `storage.notNull` but the column is nullable — existing nulls must be backfilled. Run "os migrate apply --allow-destructive".', -}; -const LIMIT = 'it needs the SQLite table rebuild, which the Turso REMOTE transport does not run'; - -/** The line each held drop is listed with: the finding, then the ceremony. */ -const heldLine = (e: Entry) => - ` ${e.table}.${e.column}: column exists in the database but not in metadata (orphaned) — a drop the ` - + 'organization-ownership ceremony owns, and it has not verified on this database: the ceremony drops it itself (see below).'; - -const CEREMONY_STEPS = - 'Run "os migrate organization-ownership --out PLAN" to write and review its plan, take a backup, then ' - + '"os migrate organization-ownership --apply --plan-file PLAN --backup-taken"; it drops these columns itself.'; - -const now = '2026-10-20T00:00:00.000Z'; -/** The ceremony's `sys_migration` row as `readDataMigrationFlag` reads it. */ -const VERIFIED = { id: ORGANIZATION_OWNERSHIP_MIGRATION_ID, last_run_at: now, verified_at: now, blocking: 0 }; -const UNVERIFIED = { id: ORGANIZATION_OWNERSHIP_MIGRATION_ID, last_run_at: now, verified_at: null, blocking: 2 }; - -/** - * The `objectql` service as the marker read meets it: `sys_migration` is - * registered, and the row read answers `row` (or throws it, as a read of a - * table the database does not have does). - */ -function engineAnswering(row: Record | null | Error) { - return { - getObject: (name: string) => (name === 'sys_migration' ? { name } : undefined), - findOne: vi.fn(async (object: string, options: { where?: { id?: string } }) => { - expect(object).toBe('sys_migration'); - expect(options.where?.id).toBe(ORGANIZATION_OWNERSHIP_MIGRATION_ID); - if (row instanceof Error) throw row; - return row; - }), - }; -} - -/** A booted stack as `bootSchemaStack` answers it, its kernel serving `engine` as `objectql`. */ -function stackWith( - drift: Entry[], - engine: ReturnType, - opts: { limited?: ReadonlySet; pending?: unknown[] } = {}, -) { - const getService = vi.fn((name: string) => { - if (name === 'objectql') return engine; - throw new Error(`no ${name} service in this pin`); - }); - return { - getService, - stack: { - driver: { config: { url: 'postgres://db.example/app' } }, - dbLabel: 'postgres://db.example/app', - dbSource: { kind: 'flag' }, - schemaSync: 'deferred', - telemetryDatabase: null, - drivers: [], - detectManagedDrift: vi.fn(async () => drift), - driftApplyLimit: (e: Entry): string | null => (opts.limited?.has(e.op.type) ? LIMIT : null), - applyMigrationEntries: vi.fn(async () => { throw new Error('a plan applies nothing'); }), - pendingSchemaWork: opts.pending ?? [], - flushSchemaDdl: vi.fn(async () => { throw new Error('a plan creates nothing'); }), - managedTableCount: 3, - kernel: { getService }, - tableAbsent: () => false, - allObjects: () => [], - composition: { - plugins: [], hostConfigPath: null, hostConfigLoaded: false, hostConfigError: null, - servedBoot: { mirrored: false, reason: 'not-composed' }, notes: [], coverage: null, - }, - shutdown: async () => {}, - }, - }; -} - -/** The real command, with its output captured: the JSON document, or the human lines. */ -async function run(argv: string[]): Promise<{ payload: any; lines: string[]; exitCode: number }> { - const chunks: string[] = []; - const said: string[] = []; - const savedExit = process.exitCode; - vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => { - chunks.push(String(chunk)); - const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; - if (cb) cb(); - return true; - }) as typeof process.stdout.write); - // Its callback runs too, or the command's exit waits out its drain timeout - // (`utils/one-shot-exit.ts`) on every case. - vi.spyOn(process.stderr, 'write').mockImplementation(((_chunk: unknown, ...rest: unknown[]) => { - const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; - if (cb) cb(); - return true; - }) as typeof process.stderr.write); - const capture = (...parts: unknown[]) => { said.push(parts.map(String).join(' ')); }; - vi.spyOn(console, 'log').mockImplementation(capture); - vi.spyOn(console, 'warn').mockImplementation(capture); - vi.spyOn(console, 'error').mockImplementation(capture); - let processExit: number | undefined; - vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { - processExit = code ?? 0; - throw new Error(`__PROCESS_EXIT__:${processExit}`); - }) as never); - let thrownExit: number | undefined; - try { - await MigratePlan.run(argv, { root: CLI_ROOT }); - } catch (error) { - const oclif = (error as { oclif?: { exit?: number } }).oclif; - const trapped = error instanceof Error && error.message.startsWith('__PROCESS_EXIT__'); - if (oclif === undefined && !trapped) throw error; - if (oclif) thrownExit = oclif.exit; - } - const exitCode = processExit ?? thrownExit ?? (process.exitCode as number | undefined) ?? 0; - process.exitCode = savedExit; - vi.restoreAllMocks(); - const out = chunks.join('').trim(); - return { - payload: argv.includes('--json') ? JSON.parse(out) : null, - lines: said.join('\n').split('\n'), - exitCode, - }; -} - -/** The line the plan prints under an entry's heading: its finding and its remedy. */ -function entryLine(lines: string[], target: string): string { - const at = lines.findIndex((l) => l.includes(` ${target} [`)); - expect(at, `no ${target} entry in:\n${lines.join('\n')}`).toBeGreaterThanOrEqual(0); - return lines[at + 1]!; -} - -/** Everything the plan prints below its summary line, but the blank lines and its timer: the footer. */ -function footer(lines: string[]): string[] { - const at = lines.findIndex((l) => /\d+ change\(s\): \d+ safe/.test(l)); - expect(at, lines.join('\n')).toBeGreaterThanOrEqual(0); - return lines.slice(at + 1).filter((l) => l.trim() !== '' && !/^\s+\d+ms$/.test(l)); -} - -describe('a database the organization-ownership ceremony has not verified', () => { - it('each organization_id drop names the ceremony, never --allow-destructive; the footer offers --allow-destructive for nothing and names both ceremony commands', async () => { - const engine = engineAnswering(null); - const { stack, getService } = stackWith([INDEX, ORG_JOB, LEGACY, ORG_SECRET], engine); - boot.mockResolvedValueOnce(stack); - const { lines, exitCode } = await run([]); - expect(exitCode, lines.join('\n')).toBe(0); - - expect(entryLine(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB)); - expect(entryLine(lines, 'sys_secret.organization_id')).toBe(heldLine(ORG_SECRET)); - // The ceremony does not own this drop: it keeps the differ's message. - expect(entryLine(lines, 'sys_sso_provider.created_by')).toBe(` ${LEGACY.message}`); - expect(entryLine(lines, 'sys_job [idx_sys_job_created_at]')).toBe(` ${INDEX.message}`); - // Still drift: listed and counted, never dropped from the plan. - expect(lines.join('\n')).toContain('4 change(s): 1 safe, 0 needs-confirm, 3 destructive'); - - expect(footer(lines)).toEqual([ - ' Apply with: os migrate apply', - ' ⚠ 2 organization_id drop(s) above are the organization-ownership ceremony\'s, and it has not verified on this ' - + 'database, so "os migrate apply --allow-destructive" refuses every run until it has (the other 1 destructive ' - + `change(s) above wait for it too). ${CEREMONY_STEPS}`, - ]); - // Asked once, of the service `apply`'s drop gate asks. - expect(getService.mock.calls).toEqual([['objectql']]); - expect(engine.findOne).toHaveBeenCalledTimes(1); - }, CASE_TIMEOUT_MS); - - it('when apply can perform nothing listed until the ceremony, there is no apply line, only the ceremony', async () => { - boot.mockResolvedValueOnce(stackWith([ORG_JOB], engineAnswering(null)).stack); - const { lines, exitCode } = await run([]); - expect(exitCode, lines.join('\n')).toBe(0); - expect(entryLine(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB)); - expect(footer(lines)).toEqual([ - ' ⚠ 1 organization_id drop(s) above are the organization-ownership ceremony\'s, and it has not verified on this ' - + `database, so "os migrate apply --allow-destructive" refuses every run until it has. ${CEREMONY_STEPS}`, - ]); - }, CASE_TIMEOUT_MS); - - it('an unverified marker row holds the drops too', async () => { - boot.mockResolvedValueOnce(stackWith([ORG_JOB, LEGACY], engineAnswering(UNVERIFIED)).stack); - const { lines, exitCode } = await run([]); - expect(exitCode, lines.join('\n')).toBe(0); - expect(entryLine(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB)); - expect(lines.join('\n')).not.toContain('(add --allow-destructive'); - expect(lines.join('\n')).toContain('1 organization_id drop(s) above are the organization-ownership ceremony\'s'); - }, CASE_TIMEOUT_MS); - - it('a database with no sys_migration table to read answers "not verified": the read fails, the plan does not', async () => { - const engine = engineAnswering(new Error('SQLITE_ERROR: no such table: sys_migration')); - boot.mockResolvedValueOnce(stackWith([ORG_JOB], engine).stack); - const { lines, exitCode } = await run([]); - expect(exitCode, lines.join('\n')).toBe(0); - expect(engine.findOne).toHaveBeenCalledTimes(1); - expect(entryLine(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB)); - expect(footer(lines).join('\n')).toContain(CEREMONY_STEPS); - }, CASE_TIMEOUT_MS); - - it('--json: the document is the report, unchanged — each message as the differ wrote it, and no marker is read', async () => { - const { stack, getService } = stackWith([INDEX, ORG_JOB, LEGACY], engineAnswering(null)); - boot.mockResolvedValueOnce(stack); - const { payload, exitCode } = await run(['--json']); - expect(exitCode).toBe(0); - expect(Object.keys(payload).sort()).toEqual( - ['changes', 'database', 'databaseSource', 'duration', 'managedTables', 'pending', 'total', 'unmanagedTables'], - ); - expect(payload.changes.map((c: Entry) => c.message)).toEqual([INDEX.message, ORG_JOB.message, LEGACY.message]); - expect(getService).not.toHaveBeenCalled(); - }, CASE_TIMEOUT_MS); - - it('beside a driver\'s declared apply limit, each keeps its own remedy, and the limit\'s count is its own', async () => { - const drift = [ORG_JOB, TIGHTEN, INDEX]; - boot.mockResolvedValueOnce(stackWith(drift, engineAnswering(null), { limited: new Set(['tighten_not_null']) }).stack); - const { lines, exitCode } = await run([]); - expect(exitCode, lines.join('\n')).toBe(0); - expect(entryLine(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB)); - expect(entryLine(lines, 't.code')).toContain(`"os migrate apply" cannot apply it on this database: ${LIMIT}.`); - expect(entryLine(lines, 't.code')).not.toContain('--allow-destructive'); - expect(footer(lines)).toEqual([ - ' Apply with: os migrate apply', - ' ⚠ 1 organization_id drop(s) above are the organization-ownership ceremony\'s, and it has not verified on this ' - + `database, so "os migrate apply --allow-destructive" refuses every run until it has. ${CEREMONY_STEPS}`, - ' ⚠ 1 change(s) above cannot be applied on this database by "os migrate apply" (each says why), so they ' - + 'remain drift after it runs.', - ]); - }, CASE_TIMEOUT_MS); -}); - -describe('CONTROL: the plan as it always was', () => { - /** What the plan prints for `drift` when nothing holds an entry back: each message, and the one footer. */ - function expectTodaysPlan(lines: string[], drift: Entry[]): void { - for (const e of drift) { - const target = e.column ? `${e.table}.${e.column}` : `${e.table} [${String(e.op.createIndexName)}]`; - expect(entryLine(lines, target)).toBe(` ${e.message}`); - } - expect(lines.join('\n')).not.toContain('organization-ownership'); - expect(footer(lines)).toEqual([' Apply with: os migrate apply (add --allow-destructive for drops / tightenings)']); - } - - it('a verified ceremony: every organization_id drop keeps its message, and the footer is the one it always was', async () => { - const engine = engineAnswering(VERIFIED); - boot.mockResolvedValueOnce(stackWith([INDEX, ORG_JOB, LEGACY, ORG_SECRET], engine).stack); - const { lines, exitCode } = await run([]); - expect(exitCode, lines.join('\n')).toBe(0); - expect(engine.findOne).toHaveBeenCalledTimes(1); - expectTodaysPlan(lines, [INDEX, ORG_JOB, LEGACY, ORG_SECRET]); - }, CASE_TIMEOUT_MS); - - it('no organization_id drop: the plan is the one it always was, and the marker is never read', async () => { - const { stack, getService } = stackWith([INDEX, LEGACY], engineAnswering(null)); - boot.mockResolvedValueOnce(stack); - const { lines, exitCode } = await run([]); - expect(exitCode, lines.join('\n')).toBe(0); - expect(getService).not.toHaveBeenCalled(); - expectTodaysPlan(lines, [INDEX, LEGACY]); - }, CASE_TIMEOUT_MS); -}); From e5a2b5063487dd749bfaf15e17b4e10000ab2014 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 17:57:01 +0000 Subject: [PATCH 4/5] test(cli): the verified control asserts the plan before the marker read Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- packages/cli/src/commands/migrate/plan.apply-limit.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/migrate/plan.apply-limit.test.ts b/packages/cli/src/commands/migrate/plan.apply-limit.test.ts index 4f8b8ec8fdf..b8bc9ce7f60 100644 --- a/packages/cli/src/commands/migrate/plan.apply-limit.test.ts +++ b/packages/cli/src/commands/migrate/plan.apply-limit.test.ts @@ -453,8 +453,9 @@ describe('#24925 CONTROL: the plan as it always was', () => { boot.mockResolvedValueOnce(ceremonyStackWith([INDEX, ORG_JOB, LEGACY, ORG_SECRET], engine).stack); const { lines, exitCode } = await run([]); expect(exitCode, lines.join('\n')).toBe(0); - expect(engine.findOne).toHaveBeenCalledTimes(1); expectTodaysPlan(lines, [INDEX, ORG_JOB, LEGACY, ORG_SECRET]); + // …and the marker WAS read: this control is not passing for want of a read. + expect(engine.findOne).toHaveBeenCalledTimes(1); }, CASE_TIMEOUT_MS); it('no organization_id drop: the plan is the one it always was, and the marker is never read', async () => { From 003e41ab5135d4db35636dcae23c5ab7b1d2d343 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 17:58:30 +0000 Subject: [PATCH 5/5] chore(changeset): @objectstack/cli patch for os migrate plan's ceremony hold Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- .changeset/24925-plan-names-ownership-ceremony.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/24925-plan-names-ownership-ceremony.md diff --git a/.changeset/24925-plan-names-ownership-ceremony.md b/.changeset/24925-plan-names-ownership-ceremony.md new file mode 100644 index 00000000000..63de7d91fee --- /dev/null +++ b/.changeset/24925-plan-names-ownership-ceremony.md @@ -0,0 +1,15 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): `os migrate plan` names the organization-ownership ceremony, not `--allow-destructive`, for the `organization_id` drops `os migrate apply` refuses (#24925) + +Clause-②: no + +On a database the organization-ownership ceremony has not verified, such as a 17.x one before the ceremony, `os migrate apply --allow-destructive` refuses every run that would drop an `organization_id` column, and drops nothing. No flag stands that refusal down. `os migrate plan` still listed each of those columns as `"os migrate apply --allow-destructive" to drop it.`, closed with `(add --allow-destructive for drops / tightenings)`, and did not mention the ceremony. + +- **Now.** Each such drop is still listed and counted as destructive drift. Its line names the ceremony as its remedy: `… (orphaned) — a drop the organization-ownership ceremony owns, and it has not verified on this database: the ceremony drops it itself (see below).` +- **The footer.** It offers `--allow-destructive` for nothing while such a drop is listed, because `apply` refuses the whole run. It still offers `os migrate apply` when there is other work that command performs without the flag. Then it names the ceremony's two commands: `os migrate organization-ownership --out PLAN`, then `os migrate organization-ownership --apply --plan-file PLAN --backup-taken`. It also counts the other destructive changes that wait for the ceremony. +- **Unchanged.** On a database whose ceremony has verified, or one with no `organization_id` drop, the plan reads as before, byte for byte, and on the second it reads no marker. The `--json` document, the exit code and `os migrate apply` are unchanged. + +**What to do.** Nothing. On a 17.x database, follow the plan's footer: run the ceremony, then re-run `os migrate plan`.