Skip to content

Commit 49d85fc

Browse files
committed
feat(cli): the ceremony promotes customized email templates and moves the settings global rung
Two steps of `os migrate organization-ownership --apply`, per the maintainer's ruling on #22937 (Q3, Q4 → A), checkpointed and resumable like the others: - template-promotion: customized and admin-authored sys_email_template rows become environment email_template items through the metadata door (core's emailTemplateDefinitionFromRow) before mirror deletion; never over an environment template that differs; reported under a wall. - settings-move: the inventory's global-rung-move departure, executed: sys_setting scope = global rows copied into sys_platform_setting by INSERT ... SELECT, then removed; a key already served with a different value is a blocker. The sys_sharing_rule declared-rule mirrors are measured and not removed: the declared-rule seeder is still live and rule grants key on the row id. Claude-Session: https://claude.ai/code/session_01DjKf52bcyXa3fbGW723phR Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 691bb95 commit 49d85fc

6 files changed

Lines changed: 424 additions & 29 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
feat(cli): `os migrate organization-ownership --apply` promotes customized email templates and moves the settings global rung (ADR-0131 D10)
6+
7+
Clause-②: yes (widening)
8+
9+
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.
10+
11+
- **`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:
12+
- two rows of one `(name, locale)` that differ;
13+
- an environment template that already exists and differs, since the environment's edit is never overwritten;
14+
- a door refusal.
15+
16+
Under a walled posture the rows are reported and kept: promoting them would apply one organization's edit to every organization.
17+
- **`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:
18+
- `sys_platform_setting` already holds the key with a different value (the one the runtime serves);
19+
- `sys_setting` holds differing rows for the key.
20+
21+
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.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/core': minor
3+
---
4+
5+
feat(core): `emailTemplateDefinitionFromRow` — a `sys_email_template` row as the `email_template` definition it carries (ADR-0131 D10, ruling C on §6 Q1)
6+
7+
Clause-②: yes (widening)
8+
9+
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.
10+
11+
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.
12+
13+
Nothing existing changes.

‎packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,24 @@
77
* nothing without `--apply`, and is refused there too.
88
*/
99

10-
import { afterEach, describe, expect, it, vi } from 'vitest';
10+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
1111
import { fileURLToPath } from 'node:url';
1212

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

18-
const { default: MigrateOrganizationOwnership } = await import('./organization-ownership.js');
18+
type Command = (typeof import('./organization-ownership.js'))['default'];
19+
let MigrateOrganizationOwnership: Command;
20+
21+
// The command's module graph is large, and oclif reads the package's config on
22+
// the first run: both are paid outside the 5 s default (CASE_TIMEOUT_MS).
23+
beforeAll(async () => {
24+
({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
25+
}, 60_000);
26+
27+
const CASE_TIMEOUT_MS = 60_000;
1928

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

@@ -50,19 +59,19 @@ describe('os migrate organization-ownership --apply — the backup precondition'
5059
expect(payload).toMatchObject({ error: 'apply_refused', reason: 'backup-unacknowledged' });
5160
expect(exitCode).toBe(1);
5261
expect(boot).not.toHaveBeenCalled();
53-
});
62+
}, CASE_TIMEOUT_MS);
5463

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

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

‎packages/cli/src/commands/migrate/organization-ownership.ts‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,9 @@ async function confirm(question: string): Promise<boolean> {
8484
* The writing mode. It asks about the backup first — the operator's act and
8585
* the precondition (D10 item 2): `--backup-taken`, or a yes at the prompt; a
8686
* non-interactive run without the flag is refused before the boot. It then
87-
* performs the fates in order — attribution, the verified id→name rewrite,
88-
* mirror deletion, column drops — through `../../utils/organization-ownership-apply.ts`,
87+
* performs the fates in order — attribution, the authored catalog conversion,
88+
* the template promotion, the settings global-rung move, the verified id→name
89+
* rewrite, mirror deletion, column drops — through `../../utils/organization-ownership-apply.ts`,
8990
* checkpointing each finished step in the deployment ledger (`sys_migration`,
9091
* row {@link ORGANIZATION_OWNERSHIP_MIGRATION_ID}) so an interrupted run
9192
* resumes where it stopped, and ends with the post-check. The ledger row is
@@ -106,7 +107,8 @@ export default class MigrateOrganizationOwnership extends Command {
106107
'The v18 organization-ownership ceremony (ADR-0131 D10). Without --apply, the read-only plan: per table, the fate, ' +
107108
'the row counts, the rows whose owner cannot be derived (by id), and which tables will receive NOT NULL, written ' +
108109
'to a file — never a row or a column changed; it refuses, naming it, any table it cannot enumerate. With --apply ' +
109-
'(and a backup): attribution, the verified id-to-name rewrite, mirror deletion and column drops, in that order, ' +
110+
'(and a backup): attribution, the authored catalog conversion, the template promotion, the settings move, the ' +
111+
'verified id-to-name rewrite, mirror deletion and column drops, in that order, ' +
110112
'resumable from a recorded checkpoint, then the post-check.';
111113

112114
static override examples = [
@@ -341,6 +343,7 @@ export default class MigrateOrganizationOwnership extends Command {
341343
const engine = kernel.getService('objectql') as Parameters<typeof recordDataMigrationRun>[0] & { registry?: unknown };
342344
const protocol = kernel.getService('protocol') as {
343345
saveMetaItem?: (request: Record<string, unknown>) => Promise<unknown>;
346+
getMetaItemsForExecution?: (request: { type: string }) => Promise<{ items?: unknown[] }>;
344347
} | undefined;
345348
const catalog = createSecurityCatalogReader({
346349
registry: engine.registry as never,
@@ -381,6 +384,29 @@ export default class MigrateOrganizationOwnership extends Command {
381384
await protocol.saveMetaItem({ type, name, item: body, actor: CEREMONY_ACTOR });
382385
},
383386
},
387+
templates: {
388+
// The environment's own row first (it is what a promotion must never
389+
// overwrite), then what the registry serves for the bundle member.
390+
find: async (name, locale) => {
391+
const stored = await executor.query(
392+
'SELECT metadata FROM sys_metadata WHERE type = ? AND name = ? AND state = ? AND organization_id IS NULL ' +
393+
'AND (bundle_member = ? OR (bundle_member IS NULL AND ? = ?))',
394+
['email_template', name, 'active', locale, locale, 'en-US'],
395+
);
396+
if (stored.length > 0) {
397+
const raw = stored[0]!.metadata;
398+
const definition = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Record<string, unknown>;
399+
return { definition, environment: true };
400+
}
401+
if (typeof protocol?.getMetaItemsForExecution !== 'function') return undefined;
402+
const listed = await protocol.getMetaItemsForExecution({ type: 'email_template' });
403+
const served = (listed?.items ?? []).find((item) => {
404+
const i = item as Record<string, unknown> | null;
405+
return !!i && i.name === name && (typeof i.locale === 'string' && i.locale ? i.locale : 'en-US') === locale;
406+
}) as Record<string, unknown> | undefined;
407+
return served ? { definition: served, environment: false } : undefined;
408+
},
409+
},
384410
columnDrops: {
385411
drop: async (tables) => {
386412
const drift = await liveStack.detectManagedDrift();

0 commit comments

Comments
 (0)