Skip to content
Closed
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
21 changes: 21 additions & 0 deletions .changeset/22937-cli-ceremony-promotions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/cli': minor
---

feat(cli): `os migrate organization-ownership --apply` promotes customized email templates and moves the settings global rung (ADR-0131 D10)

Clause-②: yes (widening)

Two steps join the ceremony, after the authored catalog conversion and before the verified id→name rewrite. Each is checkpointed and resumable like the others.

- **`template-promotion`** (ruling C on ADR-0131 §6 Q1). Every `sys_email_template` row an admin customized (`customized: true`) or authored (`managed_by` other than a seeder) becomes an environment `email_template` item through the metadata door, one per `(name, locale)`, before mirror deletion removes the rows. Seeded, uncustomized copies are not promoted. A promotion that already landed is skipped. The step stops, keeping every row, in three cases:
- two rows of one `(name, locale)` that differ;
- an environment template that already exists and differs, since the environment's edit is never overwritten;
- a door refusal.

Under a walled posture the rows are reported and kept: promoting them would apply one organization's edit to every organization.
- **`settings-move`** (ADR-0131 D7). Every `sys_setting` row with `scope = 'global'` is copied into `sys_platform_setting` by the database itself (`INSERT … SELECT`), so `value` and the `value_enc` secret handle move verbatim. The row then leaves `sys_setting`. A key `sys_platform_setting` already holds with the same value is only removed from `sys_setting`. The step stops, moving nothing for that key, in two cases:
- `sys_platform_setting` already holds the key with a different value (the one the runtime serves);
- `sys_setting` holds differing rows for the key.

Mirror deletion now also deletes the promoted template rows: under `single`, once the promotion step has finished, every remaining `sys_email_template` row is a mirror. The apply report gains `movedOut` / `movedIn` per table, and row counts reconcile as before − deleted − moved out + moved in.
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
```
13 changes: 13 additions & 0 deletions .changeset/22937-core-email-template-row-definition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/core': minor
---

feat(core): `emailTemplateDefinitionFromRow` — a `sys_email_template` row as the `email_template` definition it carries (ADR-0131 D10, ruling C on §6 Q1)

Clause-②: yes (widening)

A new pure export beside `permissionSetDefinitionFromRow` and `positionDefinitionFromRow`: one row in, one body out, with no engine, no I/O and no import. The upgrade ceremony (`os migrate organization-ownership --apply`) uses it to promote a customized or admin-authored template row to an environment `email_template` item through the metadata door before the row is deleted. It runs only in that ceremony, never at boot.

The mapping: `body_html` → `bodyHtml`, `body_text` → `bodyText`, `from_address` / `from_name` → `fromOverride`, `reply_to` → `replyTo`, `variables_json` → `variables`, and `active` keeps its stored meaning. `label` falls back to the name and `locale` to `en-US`. The retired `category` / `is_system` columns and every provenance or state column are left out. It does not validate; the door does.

Nothing existing changes.
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