Skip to content

Commit 5f11cec

Browse files
fix(cli): os migrate plan names the ownership ceremony, not --allow-destructive, for the organization_id drops apply refuses (#24954)
Fixes #24925 Clause-②: no `os migrate plan` no longer offers `--allow-destructive` for the `organization_id` drops that `os migrate apply` refuses ahead of the organization-ownership ceremony (ADR-0131 D10 item 5). On a database the ceremony has not verified, each such drop is still listed and counted as drift. Its line names the ceremony as the remedy, and the footer names the ceremony's two commands. A verified database, or one with no `organization_id` drop, gets the plan it got before, byte for byte. The `--json` document, the exit code and `os migrate apply` are unchanged. ## What changed - `packages/cli/src/commands/migrate/plan.ts` - `ceremonyHeldDrops` answers which entries are held. It uses the two functions `apply`'s drop gate asks: `ceremonyOrganizationColumnDrops`, asked one entry at a time, and `readOrganizationOwnershipMarker`, read through the stack's `objectql` service as `apply` reads it. There is no second inventory and no table list. The marker is read only when such a drop is listed, and never under `--json`. - A held drop's line is the differ's finding without its `--allow-destructive` sentence (`driftFindingWithoutRemedy`, the seam `[#23925]` added), then: `a drop the organization-ownership ceremony owns, and it has not verified on this database: the ceremony drops it itself (see below).` - The footer while a drop is held: - `Apply with: os migrate apply` prints only when that command has non-destructive or additive work. It never adds `--allow-destructive`. - Then one warning counts the held drops and the other destructive changes that wait for the ceremony. It names `os migrate organization-ownership --out PLAN`, then `os migrate organization-ownership --apply --plan-file PLAN --backup-taken`, spelled as `apply`'s refusal spells them. - A driver's `[#23925]` apply limit keeps its own warning, and its count excludes held drops. - `packages/cli/src/commands/migrate/plan.apply-limit.test.ts` - Eight pins, listed below. They are folded into the neighbour so the suite pays one module load, not two. - The harness's stderr spy now runs the write callback. Each case no longer waits out the 2 s drain timeout in `utils/one-shot-exit.ts`: the file's 12 cases took 24 to 75 ms each after the first, on a shared box. - `.changeset/24925-plan-names-ownership-ceremony.md`: `@objectstack/cli` patch. ## One deviation from the suggested route, measured The suggested route kept offering `--allow-destructive` for the other destructive entries. But `apply`'s gate refuses the whole `--allow-destructive` run while any `organization_id` drop is in it. Case 1c of `apply-organization-ownership-gate.integration.test.ts` pins that, and step 1 below shows it again: an unrelated `sys_sso_provider.created_by` drop was not applied either. So `apply` would perform none of the destructive changes. The footer offers the flag for none of them, and names the rest as waiting for the ceremony. ## Measured Setup: - **Database.** An app from `create-objectstack@17.7.0` (npm), booted once on 17.7.0 with `objectstack dev --seed-admin`. That leaves 43 tables carrying `organization_id`, no ownership marker, `sys_comment.reactions` present and 0 comments. - **How it was run.** This repository's CLI built at `feb09db14` (= main), and then this branch. It ran from a minimal project directory with `OS_AUTH_SECRET` set, so the auth family composes, against copies of that SQLite file. 1. **The data path is held.** - A plain `os migrate apply --allow-destructive --yes` exits 1 at the reactions gate first, as in the card's walk. - After `os migrate comment-reactions --apply --yes` (`Converted 0 reaction(s) … recorded as verified`), `os migrate apply --allow-destructive --yes` exits **1**: `✗ Refusing this --allow-destructive run: it would drop organization_id from sys_job, sys_job_queue, sys_metadata, sys_metadata_commit, sys_metadata_history, sys_migration, sys_migration_journal, sys_secret, a drop the organization-ownership ceremony owns, and the ceremony has not verified on this database. … Nothing in this run was applied.` - Column reads before and after match: 45 tables carry `organization_id` in both; `sys_job`, `sys_metadata` and `sys_secret` keep it; `sys_sso_provider.created_by` is still there. 2. **Main's plan.** It prints `28 change(s): 6 safe, 0 needs-confirm, 22 destructive`. Each `organization_id` drop reads `… (orphaned) — "os migrate apply --allow-destructive" to drop it.`, the footer reads `Apply with: os migrate apply (add --allow-destructive for drops / tightenings)`, and the ceremony is never named. 3. **No `sys_migration` table** (a copy with `DROP TABLE sys_migration`). The branch's plan exits 0 and lists the 7 remaining drops as held. It prints no WARN or ERROR line beyond the 3 boot WARN lines the held copy prints too. 4. **This branch, on the same database.** The 8 drops carry the ceremony line, and the footer is: ```text ℹ 28 change(s): 6 safe, 0 needs-confirm, 22 destructive Apply with: os migrate apply ⚠ 8 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 14 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. ``` **Byte for byte.** Main's `plan.ts` and this branch's, each built on the same tree, ran over the same four databases. Log timestamps and the duration line were normalised before comparing. | database | main vs branch | | :--- | :--- | | 17.x, no marker | differs on the 8 held entry lines and the footer only (19 diff lines) | | 17.x plus a verified marker row | identical | | 17.x with no `sys_migration` table | differs on the 7 held entry lines and the footer only (17 diff lines) | | a fresh 18 database (birth marker removed) plus one orphan column, no `organization_id` drift | identical | ## Pins: red on main, then green; one ablation Pins in `plan.apply-limit.test.ts`. `bootSchemaStack` is stubbed, and the marker read is the real `readOrganizationOwnershipMarker` over a fake engine. - **Held, five cases.** No marker row; an unverified row; no `sys_migration` table, where the read throws and the plan answers "not verified"; nothing `apply` can do before the ceremony, so there is no apply line; and beside a driver's declared apply limit. - **`--json`.** The document is unchanged, and no marker is read. - **Two controls.** A verified marker gives today's plan, and the marker WAS read. No `organization_id` drop gives today's plan, and the marker is never read. | leg | result | | :--- | :--- | | `plan.ts` at `feb09db14` (main), tree only, restored after | 6 failed, 6 passed. The 5 held cases fail on their entry lines. The verified control fails only at its final read-count assertion: main never reads the marker, and its plan-output assertions above that line passed. | | branch | 12 passed | | ablation: `return marker.verified ? …` changed to `return true ? …` (every database treated as verified), via `scripts/ablation-replace.mjs` | 5 failed (the five held cases), 7 passed. Restored: blob `03cb80830b10` equals HEAD's, and `git diff HEAD` is empty. | The subject resolves through relative source imports (`./plan.js`), not through a package's `exports`, so no `dist/` preflight applies. ## Local verification at `003e41ab51` - `pnpm --filter @objectstack/cli typecheck` exits 0, test layer included: `check:test-typecheck: OK`. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` exits 0: `Test Files 315 passed (315)`, `Tests 4650 passed (4650)`. - The `integration` tier is left to CI: this diff touches no integration file, spawn entry or driver boot path. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 67 derived commands, plus the 11 declared WIDE-population families. All 78 exit 0. - Three first exited 3 or 1 and were re-run on the same head. `check:dual-build-cjs-loads` and `check:i18n-coverage` printed PREREQUISITE NOT MET and COULD NOT MEASURE until the unbuilt packages they read were built. `check:route-envelope` hit an ENOENT on a temporary tsup file my concurrent build was writing. - `--ran`: `✓ dispatch-gates --ran: 67 derived famil(ies) accounted for — 67 run, 0 NOT-MEASURED (a DERIVED zero — all 67 recorded an exit code and none of them is 3).` - `pnpm lint` (`eslint . --no-inline-config`) on this head exits 0 with no findings printed. ## Acceptance notes - **Ordering, measured.** - On the pristine 17.x copy, the ceremony run straight (`--out`, then `--apply … --backup-taken`) stops at its `rewrite` step: `sys_user_permission_set has no permission_set column, so no grant can name its set. Boot this release once against the database (its schema sync adds the column), then re-run.` - After `os migrate apply` without the flag (exit 0, 16 tables created or extended, 6 changes applied), the same ceremony passes `rewrite`. - A refused `os serve` boot adds that column too. - So the plan's `Apply with: os migrate apply` line above the ceremony line is a sanctioned first step, not a detour. In this minimal composition the ceremony then stopped at `column-drops` on `sys_flow_credential`, an object my measurement project does not declare. That is an artefact of the measurement set-up, not of this change. - **Not changed here (held off by the claim), for the PM to route.** - The boot's `[schema-drift]` lines in `driver-sql` still say `"os migrate apply --allow-destructive" to drop it` for these columns. Measured: `os serve` on a 17.x copy exits 1 after 22 `[schema-drift]` lines; the boot guard's refusal that follows names the ceremony. - `os migrate apply` without the flag still prints `22 destructive change(s) will be SKIPPED (re-run with --allow-destructive to include them).` and `Skipped 22 destructive change(s) (re-run with --allow-destructive to include them).`, which is a re-run its own drop gate refuses (`apply.ts`). - The plan still offers `--allow-destructive` for `sys_comment.reactions` before `os migrate comment-reactions` has run, though `apply` refuses that drop too. Its predicate is inline in `apply.ts`, with no exported function to share, so it is not folded in here. - The ceremony's two commands are spelled in `plan.ts` and, separately, in `apply.ts`'s refusal (held off). No shared constant was introduced. - Read, not measured: the boot guard also refuses a verified marker of an older shape generation (`outdated`). `apply`'s drop gate, and so this plan, ask only `isDataMigrationFlagVerified`. The plan follows `apply` here by construction. --- _Generated by [Claude Code](https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5d27d32 commit 5f11cec

3 files changed

Lines changed: 342 additions & 11 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate plan` names the organization-ownership ceremony, not `--allow-destructive`, for the `organization_id` drops `os migrate apply` refuses (#24925)
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
- **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).`
12+
- **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.
13+
- **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.
14+
15+
**What to do.** Nothing. On a 17.x database, follow the plan's footer: run the ceremony, then re-run `os migrate plan`.

‎packages/cli/src/commands/migrate/plan.apply-limit.test.ts‎

Lines changed: 235 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,33 @@
2929
*
3030
* `bootSchemaStack` is stubbed, so this runs without a database; the same
3131
* cases run against a real `sqld` in `apply.remote-face.integration.test.ts`.
32+
*
33+
* ## #24925 — a drop `apply` refuses: the ceremony's `organization_id` drops
34+
*
35+
* On a 17.x database before the organization-ownership ceremony, the plan
36+
* listed each `organization_id` column 18.x no longer declares with the
37+
* differ's `--allow-destructive` remedy and the same footer, while `os migrate
38+
* apply` refuses the whole `--allow-destructive` run until the ceremony has
39+
* verified (`utils/organization-ownership-drop-gate.ts`, ADR-0131 D10 item 5).
40+
* The plan asks the drop gate's own two functions, through the stack's
41+
* `objectql` service as `apply` does:
42+
*
43+
* 5. Unverified (no marker row, an unverified row, or no `sys_migration`
44+
* table to read): each such drop is listed and counted with the ceremony
45+
* as its remedy, every other entry keeps its message, and the footer offers
46+
* `--allow-destructive` for nothing and names the ceremony's two commands.
47+
* Beside a driver's declared limit, each keeps its own remedy.
48+
* 6. CONTROL: a verified marker, or no `organization_id` drop, gives the plan
49+
* as it always was; with no such drop the marker is not even read.
50+
* 7. The `--json` document is unchanged and reads no marker.
51+
*
52+
* The marker read is the real `readOrganizationOwnershipMarker` over a fake
53+
* engine.
3254
*/
3355

3456
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
3557
import { fileURLToPath } from 'node:url';
58+
import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system';
3659

3760
const boot = vi.hoisted(() => vi.fn(async (_opts: Record<string, unknown>): Promise<unknown> => {
3861
throw new Error('the database was opened');
@@ -135,7 +158,13 @@ async function run(argv: string[]): Promise<{ payload: any; lines: string[]; exi
135158
if (cb) cb();
136159
return true;
137160
}) as typeof process.stdout.write);
138-
vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write);
161+
// Its callback runs too, or the command's exit waits out its drain timeout
162+
// (`utils/one-shot-exit.ts`, 2 s) on every case.
163+
vi.spyOn(process.stderr, 'write').mockImplementation(((_chunk: unknown, ...rest: unknown[]) => {
164+
const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
165+
if (cb) cb();
166+
return true;
167+
}) as typeof process.stderr.write);
139168
const capture = (...parts: unknown[]) => { said.push(parts.map(String).join(' ')); };
140169
vi.spyOn(console, 'log').mockImplementation(capture);
141170
vi.spyOn(console, 'warn').mockImplementation(capture);
@@ -233,3 +262,208 @@ describe('CONTROL: a driver that declares nothing reads exactly as before', () =
233262
expect(footer(lines)).toEqual([' Apply with: os migrate apply (add --allow-destructive for drops / tightenings)']);
234263
}, CASE_TIMEOUT_MS);
235264
});
265+
266+
// ── #24925: the organization-ownership ceremony's organization_id drops ──────
267+
268+
/** An orphaned column as the shared differ reports it, with the message it writes. */
269+
function orphan(table: string, column: string): Entry {
270+
return {
271+
kind: 'unmapped_column', remoteName: table, table, column, severity: 'warning', category: 'destructive',
272+
op: { type: 'drop_column', table, column },
273+
message: `${table}.${column}: column exists in the database but not in metadata (orphaned) — "os migrate apply --allow-destructive" to drop it.`,
274+
};
275+
}
276+
277+
/** Two `organization_id` drops a 17.x database carries before the ceremony. */
278+
const ORG_JOB = orphan('sys_job', 'organization_id');
279+
const ORG_SECRET = orphan('sys_secret', 'organization_id');
280+
/** A destructive drop the ceremony does not own. */
281+
const LEGACY = orphan('sys_sso_provider', 'created_by');
282+
/** A safe entry, which `os migrate apply` performs without --allow-destructive. */
283+
const INDEX: Entry = {
284+
kind: 'missing_index', remoteName: 'sys_job', table: 'sys_job', severity: 'info', category: 'safe',
285+
op: { type: 'create_index', table: 'sys_job', createIndexName: 'idx_sys_job_created_at' },
286+
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.`,
287+
};
288+
289+
/** The line each held drop is listed with: the finding, then the ceremony. */
290+
const heldLine = (e: Entry) =>
291+
` ${e.table}.${e.column}: column exists in the database but not in metadata (orphaned) — a drop the `
292+
+ 'organization-ownership ceremony owns, and it has not verified on this database: the ceremony drops it itself (see below).';
293+
294+
const CEREMONY_STEPS =
295+
'Run "os migrate organization-ownership --out PLAN" to write and review its plan, take a backup, then '
296+
+ '"os migrate organization-ownership --apply --plan-file PLAN --backup-taken"; it drops these columns itself.';
297+
const ceremonyLine = (drops: number, waiting: number) =>
298+
` ⚠ ${drops} organization_id drop(s) above are the organization-ownership ceremony's, and it has not verified on `
299+
+ 'this database, so "os migrate apply --allow-destructive" refuses every run until it has'
300+
+ (waiting > 0 ? ` (the other ${waiting} destructive change(s) above wait for it too)` : '')
301+
+ `. ${CEREMONY_STEPS}`;
302+
303+
const MARKED_AT = '2026-10-20T00:00:00.000Z';
304+
/** The ceremony's `sys_migration` row as `readDataMigrationFlag` reads it. */
305+
const VERIFIED = { id: ORGANIZATION_OWNERSHIP_MIGRATION_ID, last_run_at: MARKED_AT, verified_at: MARKED_AT, blocking: 0 };
306+
const UNVERIFIED = { id: ORGANIZATION_OWNERSHIP_MIGRATION_ID, last_run_at: MARKED_AT, verified_at: null, blocking: 2 };
307+
308+
/**
309+
* The `objectql` service as the marker read meets it: `sys_migration` is
310+
* registered, and the row read answers `row` (or throws it, as a read of a
311+
* table the database does not have does).
312+
*/
313+
function engineAnswering(row: Record<string, unknown> | null | Error) {
314+
return {
315+
getObject: (name: string) => (name === 'sys_migration' ? { name } : undefined),
316+
findOne: vi.fn(async (object: string, options: { where?: { id?: string } }) => {
317+
expect(object).toBe('sys_migration');
318+
expect(options.where?.id).toBe(ORGANIZATION_OWNERSHIP_MIGRATION_ID);
319+
if (row instanceof Error) throw row;
320+
return row;
321+
}),
322+
};
323+
}
324+
325+
/** A booted local stack whose kernel serves `engine` as `objectql`; `limited` as {@link stackWith}'s. */
326+
function ceremonyStackWith(
327+
drift: Entry[],
328+
engine: ReturnType<typeof engineAnswering>,
329+
limited: ReadonlySet<string> = new Set(),
330+
) {
331+
const getService = vi.fn((name: string) => {
332+
if (name === 'objectql') return engine;
333+
throw new Error(`no ${name} service in this pin`);
334+
});
335+
const { stack } = stackWith(drift, limited);
336+
return {
337+
getService,
338+
stack: {
339+
...stack,
340+
driver: { config: { url: 'postgres://db.example/app' } },
341+
dbLabel: 'postgres://db.example/app',
342+
schemaSync: 'deferred',
343+
kernel: { getService },
344+
},
345+
};
346+
}
347+
348+
/** The line the plan prints under the entry acting on `target`: its finding and its remedy. */
349+
function lineUnder(lines: string[], target: string): string {
350+
const at = lines.findIndex((l) => l.includes(` ${target} [`));
351+
expect(at, `no ${target} entry in:\n${lines.join('\n')}`).toBeGreaterThanOrEqual(0);
352+
return lines[at + 1]!;
353+
}
354+
355+
/** Everything the plan prints below its summary line, but the blank lines and its timer. */
356+
function footerUnder(lines: string[]): string[] {
357+
const at = lines.findIndex((l) => /\d+ change\(s\): \d+ safe/.test(l));
358+
expect(at, lines.join('\n')).toBeGreaterThanOrEqual(0);
359+
return lines.slice(at + 1).filter((l) => l.trim() !== '' && !/^\s+\d+ms$/.test(l));
360+
}
361+
362+
describe('#24925 — a database the organization-ownership ceremony has not verified', () => {
363+
it('each organization_id drop names the ceremony, never --allow-destructive; the footer offers --allow-destructive for nothing and names both ceremony commands', async () => {
364+
const engine = engineAnswering(null);
365+
const { stack, getService } = ceremonyStackWith([INDEX, ORG_JOB, LEGACY, ORG_SECRET], engine);
366+
boot.mockResolvedValueOnce(stack);
367+
const { lines, exitCode } = await run([]);
368+
expect(exitCode, lines.join('\n')).toBe(0);
369+
370+
expect(lineUnder(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB));
371+
expect(lineUnder(lines, 'sys_secret.organization_id')).toBe(heldLine(ORG_SECRET));
372+
// The ceremony does not own this drop: it keeps the differ's message.
373+
expect(lineUnder(lines, 'sys_sso_provider.created_by')).toBe(` ${LEGACY.message}`);
374+
expect(lineUnder(lines, 'sys_job [idx_sys_job_created_at]')).toBe(` ${INDEX.message}`);
375+
// Still drift: listed and counted, never dropped from the plan.
376+
expect(lines.join('\n')).toContain('4 change(s): 1 safe, 0 needs-confirm, 3 destructive');
377+
378+
expect(footerUnder(lines)).toEqual([' Apply with: os migrate apply', ceremonyLine(2, 1)]);
379+
// Asked once, of the service `apply`'s drop gate asks.
380+
expect(getService.mock.calls).toEqual([['objectql']]);
381+
expect(engine.findOne).toHaveBeenCalledTimes(1);
382+
}, CASE_TIMEOUT_MS);
383+
384+
it('when apply can perform nothing listed before the ceremony, there is no apply line, only the ceremony', async () => {
385+
boot.mockResolvedValueOnce(ceremonyStackWith([ORG_JOB], engineAnswering(null)).stack);
386+
const { lines, exitCode } = await run([]);
387+
expect(exitCode, lines.join('\n')).toBe(0);
388+
expect(lineUnder(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB));
389+
expect(footerUnder(lines)).toEqual([ceremonyLine(1, 0)]);
390+
}, CASE_TIMEOUT_MS);
391+
392+
it('an unverified marker row holds the drops too', async () => {
393+
boot.mockResolvedValueOnce(ceremonyStackWith([ORG_JOB, LEGACY], engineAnswering(UNVERIFIED)).stack);
394+
const { lines, exitCode } = await run([]);
395+
expect(exitCode, lines.join('\n')).toBe(0);
396+
expect(lineUnder(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB));
397+
expect(footerUnder(lines)).toEqual([ceremonyLine(1, 1)]);
398+
}, CASE_TIMEOUT_MS);
399+
400+
it('a database with no sys_migration table to read answers "not verified": the read fails, the plan does not', async () => {
401+
const engine = engineAnswering(new Error('SQLITE_ERROR: no such table: sys_migration'));
402+
boot.mockResolvedValueOnce(ceremonyStackWith([ORG_JOB], engine).stack);
403+
const { lines, exitCode } = await run([]);
404+
expect(exitCode, lines.join('\n')).toBe(0);
405+
expect(engine.findOne).toHaveBeenCalledTimes(1);
406+
expect(lineUnder(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB));
407+
expect(footerUnder(lines)).toEqual([ceremonyLine(1, 0)]);
408+
}, CASE_TIMEOUT_MS);
409+
410+
it('beside a driver\'s declared apply limit, each keeps its own remedy, and the limit\'s count is its own', async () => {
411+
boot.mockResolvedValueOnce(
412+
ceremonyStackWith([ORG_JOB, TIGHTEN, INDEX], engineAnswering(null), new Set(['tighten_not_null'])).stack,
413+
);
414+
const { lines, exitCode } = await run([]);
415+
expect(exitCode, lines.join('\n')).toBe(0);
416+
expect(lineUnder(lines, 'sys_job.organization_id')).toBe(heldLine(ORG_JOB));
417+
expect(entryLine(lines, 'tighten_not_null')).toContain(`"os migrate apply" cannot apply it on this database: ${LIMIT}.`);
418+
expect(entryLine(lines, 'tighten_not_null')).not.toContain('--allow-destructive');
419+
expect(footerUnder(lines)).toEqual([
420+
' Apply with: os migrate apply',
421+
ceremonyLine(1, 0),
422+
' ⚠ 1 change(s) above cannot be applied on this database by "os migrate apply" (each says why), so they '
423+
+ 'remain drift after it runs.',
424+
]);
425+
}, CASE_TIMEOUT_MS);
426+
427+
it('--json: the document is the report, unchanged — each message as the differ wrote it, and no marker is read', async () => {
428+
const { stack, getService } = ceremonyStackWith([INDEX, ORG_JOB, LEGACY], engineAnswering(null));
429+
boot.mockResolvedValueOnce(stack);
430+
const { payload, exitCode } = await run(['--json']);
431+
expect(exitCode).toBe(0);
432+
expect(Object.keys(payload).sort()).toEqual(
433+
['changes', 'database', 'databaseSource', 'duration', 'managedTables', 'pending', 'total', 'unmanagedTables'],
434+
);
435+
expect(payload.changes.map((c: Entry) => c.message)).toEqual([INDEX.message, ORG_JOB.message, LEGACY.message]);
436+
expect(getService).not.toHaveBeenCalled();
437+
}, CASE_TIMEOUT_MS);
438+
});
439+
440+
describe('#24925 CONTROL: the plan as it always was', () => {
441+
/** What the plan prints for `drift` when nothing holds an entry back: each message, and the one footer. */
442+
function expectTodaysPlan(lines: string[], drift: Entry[]): void {
443+
for (const e of drift) {
444+
const target = e.column ? `${e.table}.${e.column}` : `${e.table} [${String(e.op.createIndexName)}]`;
445+
expect(lineUnder(lines, target)).toBe(` ${e.message}`);
446+
}
447+
expect(lines.join('\n')).not.toContain('organization-ownership');
448+
expect(footerUnder(lines)).toEqual([' Apply with: os migrate apply (add --allow-destructive for drops / tightenings)']);
449+
}
450+
451+
it('a verified ceremony: every organization_id drop keeps its message, and the footer is the one it always was', async () => {
452+
const engine = engineAnswering(VERIFIED);
453+
boot.mockResolvedValueOnce(ceremonyStackWith([INDEX, ORG_JOB, LEGACY, ORG_SECRET], engine).stack);
454+
const { lines, exitCode } = await run([]);
455+
expect(exitCode, lines.join('\n')).toBe(0);
456+
expectTodaysPlan(lines, [INDEX, ORG_JOB, LEGACY, ORG_SECRET]);
457+
// …and the marker WAS read: this control is not passing for want of a read.
458+
expect(engine.findOne).toHaveBeenCalledTimes(1);
459+
}, CASE_TIMEOUT_MS);
460+
461+
it('no organization_id drop: the plan is the one it always was, and the marker is never read', async () => {
462+
const { stack, getService } = ceremonyStackWith([INDEX, LEGACY], engineAnswering(null));
463+
boot.mockResolvedValueOnce(stack);
464+
const { lines, exitCode } = await run([]);
465+
expect(exitCode, lines.join('\n')).toBe(0);
466+
expect(getService).not.toHaveBeenCalled();
467+
expectTodaysPlan(lines, [INDEX, LEGACY]);
468+
}, CASE_TIMEOUT_MS);
469+
});

0 commit comments

Comments
 (0)