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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/22937-cli-organization-ownership-apply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/cli': minor
---

feat(cli): `os migrate organization-ownership --apply` — the v18 organization-ownership ceremony's apply step and post-check (ADR-0131 D10, ceremony items 2 to 4)

Clause-②: yes (widening)

The command that planned the ceremony now performs it. Without `--apply` nothing changes: the read-only plan, written to a file. With `--apply`:

- **The backup comes first.** It is the operator's act: pass `--backup-taken`, or answer yes at the prompt. A non-interactive run without the flag is refused before the database is opened.
- **The fates run in order**, each step stopping the run where it meets a blocker:
1. **Attribution** — every NULL `organization_id` row the plan derives an owner for takes it (a parent, subject or holder anchor; under `single`, the Default Organization). A row with no derivable owner is never assigned one: it stays NULL and is listed by id.
2. **Authored catalog rows** — permission sets and positions an organization authored in Setup become environment metadata through the metadata door (under `single`; under a wall they are reported), with each position's binding rows carried into its `permissionSets`.
3. **The verified id→name rewrite** — every `sys_user_permission_set` grant names its set in `permission_set`, then every grant is verified to name a set the security catalog resolves. `permission_set_id` stays.
4. **Mirror deletion** — only after that verification, read again just before the first delete: the seeded catalog copies (per organization and NULL residue), the binding rows, seeded capabilities and seeded templates. Authored, customized and organization-stamped rows stay.
5. **Column drops** — `organization_id` leaves the deployment-level objects through the schema-drift ceremony (`os migrate apply`'s machinery), never a hand `ALTER`. A table whose rows still need another step keeps its column, and the run says why.
- **Idempotent and resumable.** Every write is conditioned on the state it changes, and each finished step is checkpointed in `sys_migration` (row `adr-0131-organization-ownership`); a run interrupted mid-step resumes at that step and never re-derives an owner already written.
- **The post-check** re-reads the plan and reports, per table, what remains. The ledger row records `applied_at` once every step has finished and `verified_at` when the post-check also reads zero discrepancies. Exit code 1 while the ceremony is unfinished or the post-check finds a discrepancy.
- A JSON report is written beside the plan (`organization-ownership-apply-TIMESTAMP.json`, or `--out`); an existing file is never overwritten.

```bash
os migrate organization-ownership --out plan.json # read the plan first
os migrate organization-ownership --apply --backup-taken # then perform it
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `os migrate organization-ownership --apply` asks about the backup before it
* opens the database (ADR-0131 D10 ceremony item 2): without `--backup-taken`
* a non-interactive run is refused, and the boot never happens. The flag means
* nothing without `--apply`, and is refused there too.
*/

import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { fileURLToPath } from 'node:url';

const boot = vi.hoisted(() => vi.fn(async () => {
throw new Error('the database was opened');
}));
vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: boot }));

type Command = (typeof import('./organization-ownership.js'))['default'];
let MigrateOrganizationOwnership: Command;

// The command's module graph is large, and oclif reads the package's config on
// the first run: both are paid outside the 5 s default (CASE_TIMEOUT_MS).
beforeAll(async () => {
({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
}, 60_000);

const CASE_TIMEOUT_MS = 60_000;

const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url));

async function runJson(argv: string[]): Promise<{ payload: Record<string, unknown>; exitCode: unknown }> {
const chunks: 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);
vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write);
try {
await MigrateOrganizationOwnership.run(argv, { root: CLI_ROOT });
} catch (error) {
if ((error as { oclif?: unknown }).oclif === undefined) throw error;
}
const exitCode = process.exitCode;
process.exitCode = savedExit;
vi.restoreAllMocks();
return { payload: JSON.parse(chunks.join('').trim()) as Record<string, unknown>, exitCode };
}

afterEach(() => {
boot.mockClear();
});

describe('os migrate organization-ownership --apply — the backup precondition', () => {
it('refuses a non-interactive --apply without --backup-taken, before the boot', async () => {
const { payload, exitCode } = await runJson(['--apply', '--json']);
expect(payload).toMatchObject({ error: 'apply_refused', reason: 'backup-unacknowledged' });
expect(exitCode).toBe(1);
expect(boot).not.toHaveBeenCalled();
}, CASE_TIMEOUT_MS);

it('refuses --backup-taken without --apply, before the boot', async () => {
const { payload, exitCode } = await runJson(['--backup-taken', '--json']);
expect(payload).toMatchObject({ error: 'plan_refused', reason: 'flag-without-apply' });
expect(exitCode).toBe(1);
expect(boot).not.toHaveBeenCalled();
}, CASE_TIMEOUT_MS);

it('with --backup-taken, goes on to the boot', async () => {
const { payload } = await runJson(['--apply', '--backup-taken', '--json']);
expect(payload).toMatchObject({ error: 'boot_failed', reason: 'boot-failed' });
expect(boot).toHaveBeenCalledTimes(1);
expect(boot.mock.calls[0]).toEqual([expect.objectContaining({ deferSchemaDdl: true, composeHostStack: true })]);
}, CASE_TIMEOUT_MS);
});
Loading
Loading