diff --git a/.changeset/22937-cli-ceremony-promotions.md b/.changeset/22937-cli-ceremony-promotions.md new file mode 100644 index 00000000000..7dd2aa630e1 --- /dev/null +++ b/.changeset/22937-cli-ceremony-promotions.md @@ -0,0 +1,22 @@ +--- +'@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 the mirror deletion keeps for it (customized, or with `customized` unknown, and every row no seeder wrote: `managed_by` NULL or other than package / platform / system, with or without an organization) becomes an environment `email_template` item through the metadata door, one per `(name, locale)`, before mirror deletion removes the rows. It runs under every posture. Seeded, uncustomized copies are not promoted. A promotion that already landed is skipped. The step stops, keeping every row of the template, in four cases: + - a conflict: rows of one `(name, locale)` that differ. Each conflict is listed in the plan and in the `--apply` report, with every candidate's row id and organization. It counts as remaining and holds back `verified_at` until the operator chooses one row with the new repeatable flag `--template-choice ROW_ID`. A choice that names no candidate, or two candidates of one conflict, is refused before anything is written. The chosen body is promoted, and the other candidates are deleted only after that promotion is verified; + - a customized or authored row with no name; + - an environment template that already exists and differs, since the environment's edit is never overwritten; + - a door refusal. +- **`settings-move`** (ADR-0131 D7). Every `sys_setting` row with `scope = 'global'` and no organization is copied into `sys_platform_setting` by the database itself (`INSERT … SELECT`), so `value` and the `value_enc` secret handle move verbatim. The destination row is read back, and only when it equals the source does the row leave `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. + +The `global-rung-move` departure is now executed, so it reads zero after the run and no longer holds back `verified_at`. The `declared-rule-mirrors` departure still counts until the declared-rule seeder is retired. + +Mirror deletion now also deletes the promoted template rows, and only those. Before its first template delete it runs the promotion pass again, so a row customized or authored between runs (on a resumed ceremony) is promoted, not deleted. It then deletes the seeded, uncustomized mirrors and, by id, the rows whose environment copy that pass verified. Any other promotion row stays and blocks. The apply report gains `movedOut` / `movedIn` per table, and row counts reconcile as before − deleted − moved out + moved in. diff --git a/.changeset/22937-core-email-template-row-definition.md b/.changeset/22937-core-email-template-row-definition.md new file mode 100644 index 00000000000..18aba45eb4e --- /dev/null +++ b/.changeset/22937-core-email-template-row-definition.md @@ -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 and no I/O. Its one import is the spec's floor-locale constant. 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 `EMAIL_TEMPLATE_FLOOR_LOCALE` (`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. diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts index 66235e2f5c4..e2a7b80c05e 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.ts @@ -29,6 +29,7 @@ import { applyOrganizationOwnership, checkpointFromDetails, ORGANIZATION_OWNERSHIP_MIGRATION_ID, + TemplateChoiceRefusal, type ApplyExecutor, type CeremonyLedgerPort, type ColumnDropOutcome, @@ -84,8 +85,9 @@ async function confirm(question: string): Promise { * The writing mode. It asks about the backup first — the operator's act and * the precondition (D10 item 2): `--backup-taken`, or a yes at the prompt; a * non-interactive run without the flag is refused before the boot. It then - * performs the fates in order — attribution, the verified id→name rewrite, - * mirror deletion, column drops — through `../../utils/organization-ownership-apply.ts`, + * performs the fates in order — attribution, the authored catalog conversion, + * the template promotion, the settings global-rung move, the verified id→name + * rewrite, mirror deletion, column drops — through `../../utils/organization-ownership-apply.ts`, * checkpointing each finished step in the deployment ledger (`sys_migration`, * row {@link ORGANIZATION_OWNERSHIP_MIGRATION_ID}) so an interrupted run * resumes where it stopped, and ends with the post-check. The ledger row is @@ -106,7 +108,8 @@ export default class MigrateOrganizationOwnership extends Command { 'The v18 organization-ownership ceremony (ADR-0131 D10). Without --apply, the read-only plan: per table, the fate, ' + 'the row counts, the rows whose owner cannot be derived (by id), and which tables will receive NOT NULL, written ' + 'to a file — never a row or a column changed; it refuses, naming it, any table it cannot enumerate. With --apply ' + - '(and a backup): attribution, the verified id-to-name rewrite, mirror deletion and column drops, in that order, ' + + '(and a backup): attribution, the authored catalog conversion, the template promotion, the settings move, the ' + + 'verified id-to-name rewrite, mirror deletion and column drops, in that order, ' + 'resumable from a recorded checkpoint, then the post-check.'; static override examples = [ @@ -140,6 +143,13 @@ export default class MigrateOrganizationOwnership extends Command { 'none; without this flag the run asks, and a non-interactive run is refused', default: false, }), + 'template-choice': Flags.string({ + description: + 'With --apply: the sys_email_template row id the environment keeps for a listed template conflict ' + + '(rows of one name and locale that differ). Repeat once per conflict; the other rows of that conflict are ' + + 'deleted only after the chosen body is promoted and verified', + multiple: true, + }), }; async run(): Promise { @@ -172,6 +182,10 @@ export default class MigrateOrganizationOwnership extends Command { await this.runApply(flags, posture, refuse); return; } + if (flags['template-choice']?.length) { + await refuse({ reason: 'flag-without-apply', detail: '--template-choice is only meaningful with --apply' }, '--template-choice is only meaningful with --apply.'); + return; + } if (flags['backup-taken']) { await refuse({ reason: 'flag-without-apply', detail: '--backup-taken is only meaningful with --apply' }, '--backup-taken is only meaningful with --apply.'); return; @@ -261,6 +275,15 @@ export default class MigrateOrganizationOwnership extends Command { `${table.notNull.willReceive ? chalk.green(' NOT NULL') : ''}`, ); } + for (const table of plan.tables) { + for (const conflict of table.templateConflicts ?? []) { + printWarning( + `Template conflict ${conflict.name} (${conflict.locale}): ` + + conflict.candidates.map((c) => `${c.id} (organization ${c.organizationId ?? 'none'})`).join(', ') + + ' differ. --apply blocks on it until one row is chosen with --template-choice ROW_ID.', + ); + } + } console.log(''); const { summary } = plan; printInfo( @@ -276,7 +299,7 @@ export default class MigrateOrganizationOwnership extends Command { /** `--apply`: the backup precondition, the boot, the ceremony, the report file. */ private async runApply( - flags: { 'database-url'?: string; out?: string; json?: boolean; 'backup-taken'?: boolean }, + flags: { 'database-url'?: string; out?: string; json?: boolean; 'backup-taken'?: boolean; 'template-choice'?: string[] }, posture: string, refuse: (payload: Record, message: string) => Promise, ): Promise { @@ -342,6 +365,7 @@ export default class MigrateOrganizationOwnership extends Command { const engine = kernel.getService('objectql') as Parameters[0] & { registry?: unknown }; const protocol = kernel.getService('protocol') as { saveMetaItem?: (request: Record) => Promise; + getMetaItemsForExecution?: (request: { type: string }) => Promise<{ items?: unknown[] }>; } | undefined; const catalog = createSecurityCatalogReader({ registry: engine.registry as never, @@ -371,6 +395,7 @@ export default class MigrateOrganizationOwnership extends Command { const liveStack = stack; report = await applyOrganizationOwnership({ executor, + ...(flags['template-choice']?.length ? { templateChoices: flags['template-choice'] } : {}), posture, database: stack.dbLabel, catalog, @@ -382,6 +407,29 @@ export default class MigrateOrganizationOwnership extends Command { await protocol.saveMetaItem({ type, name, item: body, actor: CEREMONY_ACTOR }); }, }, + templates: { + // The environment's own row first (it is what a promotion must never + // overwrite), then what the registry serves for the bundle member. + find: async (name, locale) => { + const stored = await executor.query( + 'SELECT metadata FROM sys_metadata WHERE type = ? AND name = ? AND state = ? AND organization_id IS NULL ' + + 'AND (bundle_member = ? OR (bundle_member IS NULL AND ? = ?))', + ['email_template', name, 'active', locale, locale, 'en-US'], + ); + if (stored.length > 0) { + const raw = stored[0]!.metadata; + const definition = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Record; + return { definition, environment: true }; + } + if (typeof protocol?.getMetaItemsForExecution !== 'function') return undefined; + const listed = await protocol.getMetaItemsForExecution({ type: 'email_template' }); + const served = (listed?.items ?? []).find((item) => { + const i = item as Record | null; + return !!i && i.name === name && (typeof i.locale === 'string' && i.locale ? i.locale : 'en-US') === locale; + }) as Record | undefined; + return served ? { definition: served, environment: false } : undefined; + }, + }, columnDrops: { drop: async (tables) => { const drift = await liveStack.detectManagedDrift(); @@ -411,6 +459,10 @@ export default class MigrateOrganizationOwnership extends Command { }); } catch (error: unknown) { if (isExitSignal(error)) throw error; + if (error instanceof TemplateChoiceRefusal) { + await refuse({ reason: 'template-choice-refused', detail: error.message }, `Refused: ${error.message}`); + return; + } if (error instanceof OrganizationOwnershipPlanRefusal) { await refuse( { reason: error.reason, ...(error.table ? { table: error.table } : {}), detail: error.message }, @@ -454,16 +506,25 @@ export default class MigrateOrganizationOwnership extends Command { console.log(''); for (const table of report.tables) { if (!table.before) continue; - if (table.attributed + table.deleted + table.unattributable.length + table.remaining + table.departures.length === 0 && !table.columnDropped) continue; + if (table.attributed + table.deleted + table.movedOut + table.movedIn + table.unattributable.length + table.remaining + table.departures.length === 0 && !table.columnDropped) continue; console.log( ` ${chalk.white(table.object.padEnd(34))} ${table.fate.padEnd(16)} attributed ${String(table.attributed).padStart(6)} ` + - `deleted ${String(table.deleted).padStart(6)} ${table.columnDropped ? chalk.green('column dropped ') : ''}` + + `deleted ${String(table.deleted).padStart(6)} ` + + `${table.movedOut ? `moved out ${table.movedOut} ` : ''}${table.movedIn ? `moved in ${table.movedIn} ` : ''}` + + `${table.columnDropped ? chalk.green('column dropped ') : ''}` + `${table.unattributable.length ? chalk.yellow(`reported ${table.unattributable.length} `) : ''}` + `${table.remaining ? chalk.red(`remaining ${table.remaining}`) : ''}`, ); for (const departure of table.departures) { console.log(chalk.yellow(` ${departure.rows} row(s) still to leave by a step not yet run — ${departure.label}`)); } + for (const conflict of table.templateConflicts ?? []) { + console.log(chalk.yellow( + ` template conflict ${conflict.name} (${conflict.locale}): ` + + conflict.candidates.map((c) => `${c.id} (organization ${c.organizationId ?? 'none'})`).join(', ') + + ' — choose one with --template-choice ROW_ID', + )); + } } console.log(''); if (report.advisory > 0) { diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index 11608ee836d..388fad9ee3d 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -24,6 +24,7 @@ import { join } from 'node:path'; import { permissionSetDefinitionFromRow } from '@objectstack/core'; import { applyOrganizationOwnership, + TemplateChoiceRefusal, type ApplyExecutor, type CatalogPort, type CeremonyCheckpoint, @@ -31,6 +32,7 @@ import { type ColumnDropPort, type MetadataDoorPort, type OrganizationOwnershipApplyReport, + type TemplatePort, } from './organization-ownership-apply.js'; let dir: string; @@ -62,15 +64,31 @@ function executor(opts: { failOnceOn?: RegExp } = {}): ApplyExecutor { * Like the registry, it serves a saved definition only from the next boot on * ({@link boot}), never the moment it is saved. */ -function catalog(): CatalogPort & MetadataDoorPort & { saved: Map>; boot(): void } { +function catalog(): CatalogPort & MetadataDoorPort & TemplatePort & { + saved: Map>; + templates: Map>; + boot(): void; +} { const declared = new Map>([ ['permission|member_default', permissionSetDefinitionFromRow(MEMBER_DEFAULT_ROW)], ['position|org_admin', { name: 'org_admin', label: 'Org Admin', delegatable: false, permissionSets: ['member_default'] }], ]); const saved = new Map>(); + /** Environment `email_template` items, keyed `name|locale`; a package ships `auth.reset_password`. */ + const templates = new Map>(); + const packaged = new Map>([ + ['auth.reset_password|en-US', { name: 'auth.reset_password', label: 'Reset', locale: 'en-US', subject: 'Reset', bodyHtml: '

code

' }], + ]); let hydrated = new Map>(); return { saved, + templates, + async find(name, locale) { + const own = templates.get(`${name}|${locale}`); + if (own) return { definition: own, environment: true }; + const shipped = packaged.get(`${name}|${locale}`); + return shipped ? { definition: shipped, environment: false } : undefined; + }, boot() { hydrated = new Map(saved); }, @@ -79,6 +97,11 @@ function catalog(): CatalogPort & MetadataDoorPort & { saved: Maphi

', 1, '[]', 0, 'platform'), + ('tpl_customized', NULL, 'auth.reset_password', 'Reset', 'en-US', 'Reset your password', '

our code

', 1, '[]', 1, 'platform'), + ('tpl_org', 'org_b', 'auth.welcome', 'Welcome', 'en-US', 'Hi', '

hi

', 1, '[]', 0, 'platform'), + ('tpl_admin', 'org_b', 'crm.renewal', 'Renewal', 'zh-CN', '续约', '

续约

', 1, '[]', 0, 'admin'), + ('tpl_admin_env', NULL, 'crm.note', 'Note', 'en-US', 'A note', '

note

', 1, '[]', 0, 'admin'), + ('tpl_unknown', NULL, 'crm.memo', 'Memo', 'en-US', 'A memo', '

memo

', 1, '[]', 0, NULL); + + -- the settings global rung, still in sys_setting, and its new home + CREATE TABLE sys_setting (id TEXT PRIMARY KEY, organization_id TEXT, namespace TEXT, key TEXT, scope TEXT, user_id TEXT, + value TEXT, encrypted INTEGER, locked INTEGER, locked_reason TEXT, value_enc TEXT); + INSERT INTO sys_setting VALUES + ('set_global', NULL, 'mail', 'transport', 'global', NULL, '"smtp"', 0, 1, 'ops', NULL), + ('set_secret', NULL, 'ai', 'api_key', 'global', NULL, NULL, 1, 0, NULL, 'secret:h_1'), + ('set_tenant', 'org_b', 'mail', 'from', 'tenant', NULL, '"b@x.test"', 0, 0, NULL, NULL); + CREATE TABLE sys_platform_setting (id TEXT PRIMARY KEY, namespace TEXT, key TEXT, value TEXT, encrypted INTEGER, + locked INTEGER, locked_reason TEXT, value_enc TEXT); -- the references: grants by id (the rewrite names them) and assignments by name CREATE TABLE sys_user_permission_set (id TEXT PRIMARY KEY, organization_id TEXT, user_id TEXT, @@ -248,21 +285,42 @@ async function accessAfter(c: ReturnType): Promise { const apply = ( c: ReturnType, l: ReturnType, - opts: { posture?: string; executor?: ApplyExecutor } = {}, + opts: { posture?: string; executor?: ApplyExecutor; templateChoices?: string[] } = {}, ): Promise => { c.boot(); return applyOrganizationOwnership({ executor: opts.executor ?? executor(), + ...(opts.templateChoices ? { templateChoices: opts.templateChoices } : {}), posture: opts.posture ?? 'single', database: file, catalog: c, door: c, + templates: c, columnDrops, ledger: l, now: () => new Date('2026-10-12T00:00:00Z'), }); }; +/** Run `body` against a database holding nothing but the given templates, so every step can finish under any posture. */ +async function onlyTemplates(values: string, body: () => Promise): Promise { + const only = new Database(join(dir, 'templates.db')); + only.exec(` + CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT); + CREATE TABLE sys_email_template (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, locale TEXT, subject TEXT, + customized INTEGER, managed_by TEXT); + INSERT INTO sys_email_template VALUES ${values}; + `); + const saved = db; + db = only; + try { + await body(); + } finally { + db = saved; + only.close(); + } +} + const tableOf = (report: OrganizationOwnershipApplyReport, object: string) => { const found = report.tables.find((t) => t.object === object); if (!found) throw new Error(`no ${object} in the report`); @@ -294,6 +352,8 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => { expect(report.steps.map((s) => [s.step, s.status])).toEqual([ ['attribution', 'done'], ['authored-conversion', 'done'], + ['template-promotion', 'done'], + ['settings-move', 'done'], ['rewrite', 'done'], ['mirror-deletion', 'done'], ['column-drops', 'done'], @@ -316,7 +376,22 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => { expect(rows('SELECT id FROM sys_position').map((r) => r.id)).toEqual(['pos_auth']); expect(rows('SELECT id FROM sys_position_permission_set')).toEqual([]); expect(rows('SELECT id FROM sys_capability').map((r) => r.id)).toEqual(['cap_authored']); - expect(rows('SELECT id FROM sys_email_template ORDER BY id').map((r) => r.id)).toEqual(['tpl_admin', 'tpl_customized', 'tpl_org']); + expect(rows('SELECT id FROM sys_email_template')).toEqual([]); + + // Ruling C: the customized, the admin-authored and the unknown-provenance + // templates became environment items before any row went; the seeded + // copies were not promoted. + expect([...c.templates.keys()].sort()).toEqual([ + 'auth.reset_password|en-US', 'crm.memo|en-US', 'crm.note|en-US', 'crm.renewal|zh-CN', + ]); + expect(c.templates.get('auth.reset_password|en-US')).toMatchObject({ subject: 'Reset your password', bodyHtml: '

our code

' }); + + // The global rung moved to sys_platform_setting, value and secret handle verbatim; the tenant row stayed. + expect(rows('SELECT id, scope FROM sys_setting')).toEqual([{ id: 'set_tenant', scope: 'tenant' }]); + expect(rows('SELECT * FROM sys_platform_setting ORDER BY id')).toEqual([ + { id: 'set_global', namespace: 'mail', key: 'transport', value: '"smtp"', encrypted: 0, locked: 1, locked_reason: 'ops', value_enc: null }, + { id: 'set_secret', namespace: 'ai', key: 'api_key', value: null, encrypted: 1, locked: 0, locked_reason: null, value_enc: 'secret:h_1' }, + ]); // Every grant names its set; the id column is still there (C7b's later drop). expect(rows('SELECT id, permission_set, permission_set_id FROM sys_user_permission_set ORDER BY id')).toEqual([ @@ -344,13 +419,16 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => { const countsAfter = counts(); for (const table of report.tables) { if (!table.before || !table.after) continue; - expect(table.before.total - table.deleted, table.object).toBe(table.after.total); + expect(table.before.total - table.deleted - table.movedOut + table.movedIn, table.object).toBe(table.after.total); expect(countsAfter[table.object], table.object).toBe(table.after.total); expect(countsBefore[table.object], table.object).toBe(table.before.total); if (table.fate !== 'mirror-deletion') expect(table.deleted, table.object).toBe(0); } + expect(tableOf(report, 'sys_setting').movedOut).toBe(2); + expect(tableOf(report, 'sys_platform_setting').movedIn).toBe(2); + // Every delete is fate 2's, or the moved rows leaving sys_setting. expect(writes.filter((w) => /^DELETE/.test(w)).every((w) => - /sys_permission_set|sys_position|sys_position_permission_set|sys_capability|sys_email_template/.test(w))).toBe(true); + /sys_permission_set|sys_position|sys_position_permission_set|sys_capability|sys_email_template|"sys_setting" WHERE \(\("scope" = \?\) AND "organization_id" IS NULL\)/.test(w))).toBe(true); }); it('positive control: a grant the rewrite would lose reddens the effective-access pin', async () => { @@ -376,7 +454,9 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { const again = await apply(c, l); - expect(again.resumedFrom).toEqual(['attribution', 'authored-conversion', 'rewrite', 'mirror-deletion', 'column-drops']); + expect(again.resumedFrom).toEqual([ + 'attribution', 'authored-conversion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion', 'column-drops', + ]); expect(again.steps.every((s) => s.status === 'checkpointed')).toBe(true); expect(again.complete).toBe(true); expect(writes).toEqual([]); @@ -386,15 +466,15 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { it('a run killed inside mirror deletion resumes there, never re-deriving an owner it already wrote', async () => { const c = catalog(); const l = ledger(); - await expect(apply(c, l, { executor: executor({ failOnceOn: /^DELETE/ }) })).rejects.toThrow(/killed mid-step/); - expect(Object.keys(l.checkpoint!.completed)).toEqual(['attribution', 'authored-conversion', 'rewrite']); + await expect(apply(c, l, { executor: executor({ failOnceOn: /^DELETE FROM "sys_capability"/ }) })).rejects.toThrow(/killed mid-step/); + expect(Object.keys(l.checkpoint!.completed)).toEqual(['attribution', 'authored-conversion', 'template-promotion', 'settings-move', 'rewrite']); expect(rows('SELECT COUNT(*) AS n FROM sys_position_permission_set')).toEqual([{ n: 3 }]); writes = []; const resumed = await apply(c, l); - expect(resumed.resumedFrom).toEqual(['attribution', 'authored-conversion', 'rewrite']); - expect(resumed.steps.map((s) => s.status)).toEqual(['checkpointed', 'checkpointed', 'checkpointed', 'done', 'done']); + expect(resumed.resumedFrom).toEqual(['attribution', 'authored-conversion', 'template-promotion', 'settings-move', 'rewrite']); + expect(resumed.steps.map((s) => s.status)).toEqual(['checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'done', 'done']); expect(writes.some((w) => /^UPDATE/.test(w))).toBe(false); expect(resumed.complete).toBe(true); expect(rows('SELECT COUNT(*) AS n FROM sys_position_permission_set')).toEqual([{ n: 0 }]); @@ -403,7 +483,7 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { it('reads the gate again before the first delete: a grant written between runs keeps every mirror', async () => { const c = catalog(); const l = ledger(); - await expect(apply(c, l, { executor: executor({ failOnceOn: /^DELETE/ }) })).rejects.toThrow(/killed mid-step/); + await expect(apply(c, l, { executor: executor({ failOnceOn: /^DELETE FROM "sys_capability"/ }) })).rejects.toThrow(/killed mid-step/); db.exec(`INSERT INTO sys_user_permission_set VALUES ('grant_late', 'org_default', 'usr_8', 'ps_gone', NULL)`); const countsBefore = counts(); @@ -416,6 +496,66 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { expect(resumed.complete).toBe(false); }); + it('promotes again before deleting a template: a row customized between runs is promoted, never deleted unpromoted', async () => { + const c = catalog(); + const l = ledger(); + await expect(apply(c, l, { executor: executor({ failOnceOn: /^DELETE FROM "sys_capability"/ }) })).rejects.toThrow(/killed mid-step/); + expect(l.checkpoint!.completed['template-promotion']).toBeDefined(); + db.exec(`INSERT INTO sys_email_template VALUES + ('tpl_late', NULL, 'crm.late', 'Late', 'en-US', 'Edited late', '

late

', 1, '[]', 1, 'platform')`); + + const resumed = await apply(c, l); + + expect(c.templates.get('crm.late|en-US')).toMatchObject({ subject: 'Edited late' }); + expect(rows(`SELECT id FROM sys_email_template`)).toEqual([]); + expect(resumed.complete).toBe(true); + expect(resumed.blocking).toBe(0); + }); + + it('a customized template with no name blocks: kept, named, and verified_at stays null', async () => { + db.exec(`INSERT INTO sys_email_template VALUES + ('tpl_nameless', NULL, NULL, 'Untitled', 'en-US', 'S', '

s

', 1, '[]', 1, 'platform')`); + const l = ledger(); + const report = await apply(catalog(), l); + + const promotion = report.steps.find((s) => s.step === 'template-promotion')!; + expect(promotion.status).toBe('blocked'); + expect(promotion.blockers.join('\n')).toMatch(/tpl_nameless/); + expect(rows(`SELECT id FROM sys_email_template WHERE id = 'tpl_nameless'`)).toHaveLength(1); + expect(report.complete).toBe(false); + expect(l.verdicts.at(-1)).toMatchObject({ complete: false }); + }); + + it('moves no setting over a different value the new table already serves, and deletes nothing for it', async () => { + db.exec(`INSERT INTO sys_platform_setting VALUES ('ps_1', 'mail', 'transport', '"ses"', 0, 0, NULL, NULL)`); + const report = await apply(catalog(), ledger()); + + const move = report.steps.find((s) => s.step === 'settings-move')!; + expect(move.status).toBe('blocked'); + expect(move.blockers.join('\n')).toMatch(/mail\.transport/); + expect(rows(`SELECT id FROM sys_setting WHERE scope = 'global' ORDER BY id`).map((r) => r.id)).toEqual(['set_global']); + expect(rows(`SELECT value FROM sys_platform_setting WHERE key = 'transport'`)).toEqual([{ value: '"ses"' }]); + expect(report.steps.find((s) => s.step === 'mirror-deletion')!.status).toBe('not-reached'); + }); + + it('never overwrites a template the environment already holds, keeps every template row, and certifies nothing', async () => { + const c = catalog(); + c.templates.set('crm.renewal|zh-CN', { name: 'crm.renewal', label: 'Renewal', locale: 'zh-CN', subject: '新的', bodyHtml: '

新的

' }); + const l = ledger(); + const report = await apply(c, l); + + // A blocked promotion: the ceremony stops there, verified_at stays null. + expect(report.complete).toBe(false); + expect(report.blocking).toBeGreaterThan(0); + expect(l.verdicts.at(-1)).toMatchObject({ complete: false }); + + const promotion = report.steps.find((s) => s.step === 'template-promotion')!; + expect(promotion.status).toBe('blocked'); + expect(promotion.blockers.join('\n')).toMatch(/crm\.renewal \(zh-CN\)/); + expect(c.templates.get('crm.renewal|zh-CN')).toMatchObject({ subject: '新的' }); + expect(rows('SELECT COUNT(*) AS n FROM sys_email_template')).toEqual([{ n: 6 }]); + }); + it('deletes nothing while a reference does not resolve by name — the gate of fate 2', async () => { // Another organization's set row: a name carries no organization, so it is never written. db.exec(` @@ -431,14 +571,17 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { expect(rewrite.status).toBe('blocked'); expect(rewrite.blockers.join('\n')).toMatch(/grant_x/); expect(report.steps.find((s) => s.step === 'mirror-deletion')!.status).toBe('not-reached'); - expect(writes.filter((w) => /^DELETE/.test(w))).toEqual([]); - expect(counts()).toEqual(countsBefore); + // The only rows that left are the settings rung, which moved before the rewrite. + expect(writes.filter((w) => /^DELETE/.test(w) && !/^DELETE FROM "sys_setting"/.test(w))).toEqual([]); + const { sys_setting: _s, sys_platform_setting: _p, ...after } = counts(); + const { sys_setting: _sb, sys_platform_setting: _pb, ...before } = countsBefore; + expect(after).toEqual(before); expect(report.complete).toBe(false); expect(report.blocking).toBeGreaterThan(0); expect(l.verdicts.at(-1)!.complete).toBe(false); }); - it('under a wall: an anchorless row is reported, never assigned, and authored rows are not converted', async () => { + it('under a wall: an anchorless row is reported, never assigned, authored rows are not converted, templates are promoted', async () => { const c = catalog(); const report = await apply(c, ledger(), { posture: 'isolated' }); @@ -448,24 +591,105 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { expect(c.saved.size).toBe(0); expect(report.steps.find((s) => s.step === 'mirror-deletion')!.status).toBe('not-reached'); expect(rows('SELECT COUNT(*) AS n FROM sys_permission_set')).toEqual([{ n: 3 }]); + // The promotion runs under every posture; nothing is deleted before mirror deletion. + expect([...c.templates.keys()].sort()).toEqual(['auth.reset_password|en-US', 'crm.memo|en-US', 'crm.note|en-US', 'crm.renewal|zh-CN']); + expect(rows('SELECT COUNT(*) AS n FROM sys_email_template')).toEqual([{ n: 6 }]); }); - it('does not certify a ceremony whose departures have not left: the global settings rung keeps verified_at null', async () => { + it('does not certify a ceremony whose departures have not left: the declared sharing-rule mirrors keep verified_at null', async () => { db.exec(` - CREATE TABLE sys_setting (id TEXT PRIMARY KEY, organization_id TEXT, namespace TEXT, key TEXT, scope TEXT); - INSERT INTO sys_setting VALUES ('set_global', NULL, 'mail', 'transport', 'global'), ('set_tenant', 'org_b', 'mail', 'from', 'tenant'); + CREATE TABLE sys_sharing_rule (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, managed_by TEXT, customized INTEGER); + INSERT INTO sys_sharing_rule VALUES ('rule_declared', NULL, 'share_deals', 'package', 0), ('rule_org', 'org_b', 'mine', 'admin', 0); `); const l = ledger(); const report = await apply(catalog(), l); + // Every step ran, and the moved settings rung reads zero; the rule mirror + // is D2's to remove, so it still counts and is named. expect(report.complete).toBe(true); expect(report.blocking).toBe(1); expect(l.verdicts.at(-1)).toMatchObject({ complete: true, blocking: 1 }); - const setting = tableOf(report, 'sys_setting'); - expect(setting.remaining).toBe(1); - expect(setting.departures).toEqual([expect.objectContaining({ id: 'global-rung-move', as: 'moved', rows: 1 })]); - expect(setting.notNull.willReceive).toBe(false); - expect(rows(`SELECT id FROM sys_setting ORDER BY id`).map((r) => r.id)).toEqual(['set_global', 'set_tenant']); + expect(tableOf(report, 'sys_setting').departures).toEqual([]); + const rule = tableOf(report, 'sys_sharing_rule'); + expect(rule.remaining).toBe(1); + expect(rule.departures).toEqual([expect.objectContaining({ id: 'declared-rule-mirrors', as: 'mirror-deletion', rows: 1 })]); + expect(rule.notNull.willReceive).toBe(false); + expect(rows(`SELECT id FROM sys_sharing_rule ORDER BY id`).map((r) => r.id)).toEqual(['rule_declared', 'rule_org']); + }); + + it('promotes under a wall too: a template with no conflict ends verified, its seeded mirror and its row gone', async () => { + await onlyTemplates( + `('tpl_seeded', NULL, 'auth.welcome', 'en-US', 'Hi', 0, 'platform'), ('tpl_admin', 'org_b', 'crm.note', 'en-US', 'A note', 0, 'admin')`, + async () => { + const c = catalog(); + const l = ledger(); + const report = await apply(c, l, { posture: 'isolated' }); + expect(report.complete).toBe(true); + expect(report.blocking).toBe(0); + expect(l.verdicts.at(-1)).toMatchObject({ complete: true, blocking: 0 }); + expect(c.templates.get('crm.note|en-US')).toMatchObject({ name: 'crm.note', subject: 'A note' }); + expect(tableOf(report, 'sys_email_template').departures).toEqual([]); + expect(rows('SELECT id FROM sys_email_template')).toEqual([]); + }, + ); + }); + + it('under a wall, rows of one template that differ are a conflict: listed with each candidate, kept, and blocking', async () => { + await onlyTemplates( + `('tpl_a', 'org_a', 'crm.note', 'en-US', 'From A', 1, 'platform'), ('tpl_b', 'org_b', 'crm.note', 'en-US', 'From B', 1, 'platform')`, + async () => { + const c = catalog(); + const l = ledger(); + const report = await apply(c, l, { posture: 'group' }); + const promotion = report.steps.find((s) => s.step === 'template-promotion')!; + expect(promotion.status).toBe('blocked'); + expect(promotion.summary).toMatchObject({ conflicts: 1, promoted: 0 }); + expect(promotion.blockers.join('\n')).toMatch(/crm\.note \(en-US\).*tpl_a \(organization org_a\), tpl_b \(organization org_b\).*--template-choice/); + expect(tableOf(report, 'sys_email_template').templateConflicts).toEqual([ + { name: 'crm.note', locale: 'en-US', candidates: [{ id: 'tpl_a', organizationId: 'org_a' }, { id: 'tpl_b', organizationId: 'org_b' }] }, + ]); + expect(report.complete).toBe(false); + expect(l.verdicts.at(-1)).toMatchObject({ complete: false }); + expect(c.templates.size).toBe(0); + expect(rows('SELECT id FROM sys_email_template ORDER BY id').map((r) => r.id)).toEqual(['tpl_a', 'tpl_b']); + }, + ); + }); + + it('a chosen conflict promotes the chosen body, verifies, and only then deletes every candidate', async () => { + await onlyTemplates( + `('tpl_a', 'org_a', 'crm.note', 'en-US', 'From A', 1, 'platform'), ('tpl_b', 'org_b', 'crm.note', 'en-US', 'From B', 1, 'platform')`, + async () => { + const c = catalog(); + const l = ledger(); + const report = await apply(c, l, { posture: 'isolated', templateChoices: ['tpl_b'] }); + expect(report.complete).toBe(true); + expect(report.blocking).toBe(0); + expect(l.verdicts.at(-1)).toMatchObject({ complete: true, blocking: 0 }); + expect(c.templates.get('crm.note|en-US')).toMatchObject({ subject: 'From B' }); + expect(rows('SELECT id FROM sys_email_template')).toEqual([]); + expect(tableOf(report, 'sys_email_template').deleted).toBe(2); + }, + ); + }); + + it('refuses a template choice that names no candidate, or two candidates of one conflict, before anything is written', async () => { + await onlyTemplates( + `('tpl_a', 'org_a', 'crm.note', 'en-US', 'From A', 1, 'platform'), ('tpl_b', 'org_b', 'crm.note', 'en-US', 'From B', 1, 'platform')`, + async () => { + const c = catalog(); + await expect(apply(c, ledger(), { posture: 'isolated', templateChoices: ['tpl_nope'] })).rejects.toThrow( + new TemplateChoiceRefusal( + '--template-choice tpl_nope names no candidate row of a listed template conflict. The conflicts are: crm.note (en-US): tpl_a, tpl_b.', + ), + ); + await expect(apply(c, ledger(), { posture: 'isolated', templateChoices: ['tpl_a', 'tpl_b'] })).rejects.toThrow( + /tpl_a and tpl_b both choose for the template crm\.note \(en-US\)/, + ); + expect(writes).toEqual([]); + expect(c.templates.size).toBe(0); + }, + ); }); it('holds a column whose rows another stage owns, and reports it', async () => { diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index d4fa3986d13..5805c2d00e2 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -13,11 +13,17 @@ import { import { buildOrganizationOwnershipPlan, columnListSql, + definitionKey, + groupTemplateRows, + templatePromotionSql, + SEEDED_BY, + TEMPLATE_OBJECT, columnNameOf, dialectOf, predicateSql, Sql, type AttributionSink, + type TemplateConflict, type OrganizationOwnershipPlan, type PlanReader, type TablePlan, @@ -42,6 +48,19 @@ import { * (`convertPositionBindingRows`). Only under `single` (ADR-0131 D3: a * Setup catalog write is an environment metadata write under `single` and * refused under a wall); under a wall the rows are reported. + * 2b. `template-promotion` — ruling C on ADR-0131 §6 Q1: every + * `sys_email_template` row an organization customized or authored becomes + * an environment `email_template` item through the metadata door (core's + * `emailTemplateDefinitionFromRow`), so the deletion that follows loses no + * customer edit, under every posture. One `(name, locale)` held by rows + * that differ is a conflict: listed, kept, and blocking until the operator + * chooses one row with `--template-choice ROW_ID`; a nameless row blocks. + * 2c. `settings-move` — the inventory's `global-rung-move` departure, + * executed (#15207's ruling: "C7's ceremony moving the existing rows"): + * each `sys_setting` row of `scope = 'global'` with no organization is + * copied, by the database itself, into `sys_platform_setting`, read back, + * and only then removed from `sys_setting`. + * A key the new table already holds with a different value is a blocker. * 3. `rewrite` — the verified id→name rewrite (D4): * `sys_user_permission_set.permission_set` is named from the set row its * `permission_set_id` points at, then EVERY grant is verified to name a set @@ -49,7 +68,9 @@ import { * 4. `mirror-deletion` (fate 2) — gated on the rewrite's verification, which * is read again immediately before the first delete. Only rows of a * `mirror-deletion` object that match its mirror selector and none of its - * named categories are deleted. + * named categories are deleted. Templates: the promotion runs again first, + * and beyond the seeded mirrors only the rows whose environment copy that + * pass verified are deleted, by id. * 5. `column-drops` (fate 1) — `organization_id` leaves the D7 objects * through the schema-drift ceremony ({@link ColumnDropPort}), never a hand * `ALTER`. @@ -77,7 +98,15 @@ import { /** The `sys_migration` row the ceremony checkpoints into and completes. */ export const ORGANIZATION_OWNERSHIP_MIGRATION_ID = 'adr-0131-organization-ownership'; -export const CEREMONY_STEPS = ['attribution', 'authored-conversion', 'rewrite', 'mirror-deletion', 'column-drops'] as const; +export const CEREMONY_STEPS = [ + 'attribution', + 'authored-conversion', + 'template-promotion', + 'settings-move', + 'rewrite', + 'mirror-deletion', + 'column-drops', +] as const; export type CeremonyStep = (typeof CEREMONY_STEPS)[number]; /** The raw-SQL seam: the plan's reader plus the one write verb. */ @@ -92,7 +121,12 @@ export interface CatalogPort { /** The metadata door: an environment metadata save. Throws when the door refuses. */ export interface MetadataDoorPort { - save(type: 'position' | 'permission', name: string, body: Record): Promise; + save(type: 'position' | 'permission' | 'email_template', name: string, body: Record): Promise; +} + +/** The `email_template` item the registry serves for one `(name, locale)` bundle member. */ +export interface TemplatePort { + find(name: string, locale: string): Promise<{ definition: Readonly>; environment: boolean } | undefined>; } /** One table's column drop, as the schema-drift ceremony answered it. */ @@ -138,6 +172,9 @@ export interface TableOutcome { after: { total: number; organizationNull: number } | null; attributed: number; deleted: number; + /** Rows a `moved` departure carried out of this table, and into it. */ + movedOut: number; + movedIn: number; /** Fate 1: the column is gone after this run. */ columnDropped: boolean; /** Rows still NULL and listed by id — reported, never guessed. */ @@ -148,6 +185,8 @@ export interface TableOutcome { * until it is gone, and its label is the remedy. */ departures: Array<{ id: string; label: string; as: 'mirror-deletion' | 'moved'; rows: number }>; + /** Templates: each `(name, locale)` whose rows still differ, with every candidate row and its organization. */ + templateConflicts?: TemplateConflict[]; /** Post-check: what is left for this table's fate, `0` when the fate is done. */ remaining: number; notNull: TablePlan['notNull']; @@ -179,9 +218,16 @@ export interface ApplyOptions { database: string; catalog: CatalogPort; door: MetadataDoorPort; + templates: TemplatePort; columnDrops: ColumnDropPort; ledger: CeremonyLedgerPort; inventory?: readonly InventoryEntry[]; + /** + * The operator's choice for each template conflict: one candidate row id per + * `(name, locale)` whose customized or authored rows differ. Validated before + * anything is written ({@link TemplateChoiceRefusal}). + */ + templateChoices?: readonly string[]; now?: () => Date; /** One line per milestone, for the command's human face. */ onProgress?: (message: string) => void; @@ -197,6 +243,19 @@ const SETS = 'sys_permission_set'; const POSITIONS = 'sys_position'; const BINDINGS = 'sys_position_permission_set'; const CAPABILITIES = 'sys_capability'; +const TEMPLATES = TEMPLATE_OBJECT; +const SETTINGS = 'sys_setting'; +const PLATFORM_SETTINGS = 'sys_platform_setting'; + +/** The `moved` departures the ceremony executes, by departure id. */ +const MOVES: Readonly> = { + 'global-rung-move': { + from: SETTINGS, + to: PLATFORM_SETTINGS, + key: ['namespace', 'key'], + compare: ['value', 'value_enc', 'encrypted', 'locked', 'locked_reason'], + }, +}; const text = (value: unknown): string | null => value === null || value === undefined || String(value) === '' ? null : String(value); @@ -209,12 +268,28 @@ interface Run { columns: Map | null>; attributed: Map; deleted: Map; + movedOut: Map; + movedIn: Map; /** * What the door accepted this run. The registry hydrates environment * metadata at boot, so a definition saved a moment ago is not in it until * the next one; the door's acceptance is what makes it resolve now. */ saved: Map>>; + /** + * Template rows whose environment copy this run verified (promoted, or found + * equal): only these, and seeded mirrors, may be deleted. Rebuilt by the + * promotion pass mirror deletion re-runs, so a resumed run verifies afresh. + */ + verifiedTemplateIds: Set; +} + +/** A `--template-choice` that names no candidate of a listed conflict, or two of one. Nothing is written. */ +export class TemplateChoiceRefusal extends Error { + constructor(message: string) { + super(message); + this.name = 'TemplateChoiceRefusal'; + } } /** The catalog read, then what the door accepted this run. */ @@ -350,8 +425,7 @@ function authoredWhere(object: string, inventory: readonly InventoryEntry[] | un const rowIsOff = (value: unknown): boolean => value === false || value === 0 || value === '0' || value === 'false'; /** The JSON of a definition's own keys, for comparing two bodies. */ -const bodyKey = (body: Readonly>, keys: readonly string[]): string => - JSON.stringify(keys.map((k) => [k, body[k] ?? null])); +const bodyKey = definitionKey; interface CatalogRows { positions: Record[]; @@ -541,6 +615,195 @@ async function runAuthoredConversion(run: Run): Promise { return { step: 'authored-conversion', status: blockers.length ? 'blocked' : 'done', summary, blockers, notes }; } +// ── Step 2b: template promotion (ruling C) ────────────────────────────────── + +async function runTemplatePromotion(run: Run): Promise { + const summary: Record = { promoted: 0, alreadyPromoted: 0, conflicts: 0 }; + const blockers: string[] = []; + const notes: string[] = []; + run.verifiedTemplateIds.clear(); + const cols = await columnsOf(run, TEMPLATES); + if (!cols) return { step: 'template-promotion', status: 'done', summary, blockers, notes }; + const rows = await readAll(run, TEMPLATES, [...cols], templatePromotionSql(run.sql, cols)); + const { groups, nameless } = groupTemplateRows(rows); + if (nameless.length) { + blockers.push( + `${nameless.length} customized or authored ${TEMPLATES} row(s) carry no name (${nameless.join(', ')}), so no ` + + 'environment template can hold them, and they are never deleted unpromoted. Name each row, or delete it, then re-run.', + ); + } + const choices = new Set(run.opts.templateChoices ?? []); + for (const group of groups) { + const { name, locale } = group; + let body = group.bodies[0]!; + if (group.conflict) { + summary.conflicts++; + const chosen = group.rows.findIndex((r) => choices.has(String(r.id))); + if (chosen < 0) { + blockers.push( + `The template ${name} (${locale}) is held by ${group.rows.length} customized or authored rows that differ: ` + + group.rows.map((r) => `${String(r.id)} (organization ${text(r.organization_id) ?? 'none'})`).join(', ') + + '. Which one the environment keeps is never guessed: choose one with --template-choice ROW_ID, then re-run.', + ); + continue; + } + body = group.bodies[chosen]!; + } + const existing = await run.opts.templates.find(name, locale); + const saved = run.saved.get(`email_template|${name}|${locale}`); + const current = saved ?? existing?.definition; + if (current && bodyKey(current, Object.keys(body)) === bodyKey(body, Object.keys(body))) { + summary.alreadyPromoted++; + for (const r of group.rows) run.verifiedTemplateIds.add(String(r.id)); + continue; + } + if (existing?.environment && !saved) { + blockers.push( + `The environment already holds its own template ${name} (${locale}), and it differs from the customized row(s) ` + + `${group.rows.map((r) => String(r.id)).join(', ')}. The environment's edit is never overwritten: carry the ` + + 'row\'s change into it in Studio (or delete the row), then re-run.', + ); + continue; + } + try { + await run.opts.door.save('email_template', name, body); + run.saved.set(`email_template|${name}|${locale}`, body); + for (const r of group.rows) run.verifiedTemplateIds.add(String(r.id)); + summary.promoted++; + run.opts.onProgress?.(`promoted the template ${name} (${locale}) to environment metadata`); + } catch (error) { + blockers.push( + `The metadata door refused the template ${name} (${locale}): ${error instanceof Error ? error.message : String(error)}. ` + + 'Correct the row so the definition validates, then re-run.', + ); + } + } + return { step: 'template-promotion', status: blockers.length ? 'blocked' : 'done', summary, blockers, notes }; +} + +/** + * Refuse, before anything is written, a `--template-choice` that names no + * candidate row of a listed conflict, or two candidates of one conflict. + */ +function validateTemplateChoices(run: Run): void { + const choices = run.opts.templateChoices ?? []; + if (choices.length === 0) return; + const conflicts = run.plan.tables.find((t) => t.object === TEMPLATES)?.templateConflicts ?? []; + const groupOf = new Map(); + for (const c of conflicts) for (const cand of c.candidates) groupOf.set(cand.id, `${c.name} (${c.locale})`); + const listed = conflicts.length + ? conflicts.map((c) => `${c.name} (${c.locale}): ${c.candidates.map((cand) => cand.id).join(', ')}`).join('; ') + : 'none'; + const seen = new Map(); + for (const id of choices) { + const group = groupOf.get(id); + if (!group) { + throw new TemplateChoiceRefusal( + `--template-choice ${id} names no candidate row of a listed template conflict. The conflicts are: ${listed}.`, + ); + } + const other = seen.get(group); + if (other !== undefined && other !== id) { + throw new TemplateChoiceRefusal( + `--template-choice ${other} and ${id} both choose for the template ${group}; choose exactly one row per conflict.`, + ); + } + seen.set(group, id); + } +} + +// ── Step 2c: the settings global rung moves (D7) ──────────────────────────── + +async function runSettingsMove(run: Run): Promise { + const summary: Record = { moved: 0, alreadyMoved: 0 }; + const blockers: string[] = []; + for (const entry of run.opts.inventory ?? [inventoryEntryFor(SETTINGS)!]) { + for (const departure of entry?.departures ?? []) { + const move = MOVES[departure.id]; + if (departure.as !== 'moved' || !move || entry.object !== move.from) continue; + const fromCols = await columnsOf(run, move.from); + if (!fromCols) continue; + const selector = predicateSql(run.sql, move.from, fromCols, departure.where); + // Only the environment's own rung moves: an organization's row is not global. + const where = fromCols.has('organization_id') + ? { text: `(${selector.text}) AND ${run.sql.q('organization_id', move.from)} IS NULL`, params: selector.params } + : selector; + const rows = await readAll(run, move.from, [...move.key, ...move.compare].filter((c) => fromCols.has(c)), where); + if (rows.length === 0) continue; + const toCols = await columnsOf(run, move.to); + if (!toCols) { + blockers.push( + `${rows.length} ${move.from} row(s) move to ${move.to}, which this database does not have. Boot this release ` + + 'against it once (its schema sync creates the table), then re-run.', + ); + continue; + } + const same = (a: Record, b: Record): boolean => + move.compare.every((c) => JSON.stringify(a[c] ?? null) === JSON.stringify(b[c] ?? null)); + const keyOf = (r: Record): string => move.key.map((k) => String(r[k])).join('.'); + const byKey = new Map[]>(); + for (const row of rows) byKey.set(keyOf(row), [...(byKey.get(keyOf(row)) ?? []), row]); + const shared = [...fromCols].filter((c) => toCols.has(c)); + const sql = run.sql; + for (const [key, group] of byKey) { + if (group.some((r) => !same(r, group[0]!))) { + blockers.push( + `${move.from} holds ${group.length} differing ${departure.id} rows for ${key} (${group.map((r) => String(r.id)).join(', ')}). ` + + 'Which value the deployment keeps is never guessed: delete all but one, then re-run.', + ); + continue; + } + const readTarget = () => + readAll(run, move.to, move.compare.filter((c) => toCols.has(c)), { + text: move.key.map((k) => `${sql.q(k, move.to)} = ?`).join(' AND '), + params: move.key.map((k) => group[0]![k]), + }); + const target = await readTarget(); + if (target.length > 0 && !same(target[0]!, group[0]!)) { + blockers.push( + `${move.to} already holds ${key} with a different value — the one the runtime serves — and the ${move.from} ` + + `row(s) ${group.map((r) => String(r.id)).join(', ')} hold another. Delete the stale ${move.from} row(s) or ` + + 'align them, then re-run.', + ); + continue; + } + if (target.length === 0) { + const list = shared.map((c) => sql.q(c, move.to)).join(', '); + await execute( + run, + move.to, + `INSERT INTO ${sql.q(move.to, move.to)} (${list}) SELECT ${shared.map((c) => sql.q(c, move.from)).join(', ')} ` + + `FROM ${sql.q(move.from, move.from)} WHERE ${sql.q('id', move.from)} = ?`, + [group[0]!.id], + ); + // The source leaves only once its destination row reads back equal. + const landed = await readTarget(); + if (landed.length !== 1 || !same(landed[0]!, group[0]!)) { + throw new Error( + `The move of ${key} into ${move.to} did not read back equal; the ${move.from} row(s) ` + + `${group.map((r) => String(r.id)).join(', ')} were kept.`, + ); + } + summary.moved++; + run.movedIn.set(move.to, (run.movedIn.get(move.to) ?? 0) + 1); + } else { + summary.alreadyMoved++; + } + const ids = group.map((r) => r.id); + await execute( + run, + move.from, + `DELETE FROM ${sql.q(move.from, move.from)} WHERE (${where.text}) AND ${sql.q('id', move.from)} IN (${ids.map(() => '?').join(', ')})`, + [...where.params, ...ids], + ); + run.movedOut.set(move.from, (run.movedOut.get(move.from) ?? 0) + ids.length); + } + if (summary.moved) run.opts.onProgress?.(`settings move: ${summary.moved} ${departure.id} key(s) now in ${move.to}`); + } + } + return { step: 'settings-move', status: blockers.length ? 'blocked' : 'done', summary, blockers, notes: [] }; +} + // ── Step 3: the verified id→name rewrite (D4) ─────────────────────────────── interface Verification { @@ -692,9 +955,6 @@ async function runRewrite(run: Run): Promise { // ── Step 4: mirror deletion (fate 2) ───────────────────────────────────────── -/** Provenance the seeders stamped: only such a row can be a copy of code. */ -const SEEDED_BY = ['package', 'platform', 'system']; - /** * The rows of a fate-2 object that are deleted: its mirror selector (every * row when it names none), minus every row a claim keeps — @@ -721,6 +981,13 @@ function deletableWhere(run: Run, table: string, cols: ReadonlySet, entr parts.push(`(CASE WHEN ${claimed.text} THEN 1 WHEN NOT (${claimed.text}) THEN 0 ELSE 1 END) = 0`); params.push(...claimed.params, ...claimed.params); }; + // Templates: a mirror is a seeded, uncustomized copy (with an organization + // or without); every promotion row is kept here, and is deleted only by id + // once this run has verified its environment copy (`runMirrorDeletion`). + if (entry.object === TEMPLATES) { + keep(templatePromotionSql(run.sql, cols)); + return { text: parts.join(' AND '), params }; + } for (const category of entry.categories ?? []) { if (!category.where || category.conflictBy) continue; keep(predicateSql(run.sql, table, cols, category.where)); @@ -750,6 +1017,21 @@ async function runMirrorDeletion(run: Run): Promise { notes: [], }; } + // The promotion, run again now: on a resumed run its checkpoint says nothing + // about a row customized or authored since, and only a row whose copy this + // pass verifies may be deleted. It is idempotent: an equal copy is found. + if (await columnsOf(run, TEMPLATES)) { + const pass = await runTemplatePromotion(run); + if (pass.status !== 'done') { + return { + step: 'mirror-deletion', + status: 'blocked', + summary: { deleted: 0 }, + blockers: ['Mirror deletion re-runs the template promotion first, and it no longer completes:', ...pass.blockers], + notes: [], + }; + } + } const notes: string[] = []; let total = 0; for (const table of run.plan.tables) { @@ -761,6 +1043,7 @@ async function runMirrorDeletion(run: Run): Promise { const where = deletableWhere(run, physical, cols, entry); const before = await countWhere(run, physical, where); if (before === 0) continue; + await execute(run, physical, `DELETE FROM ${run.sql.q(physical, physical)} WHERE ${where.text}`, where.params); const left = await countWhere(run, physical, where); if (left !== 0) { @@ -770,12 +1053,36 @@ async function runMirrorDeletion(run: Run): Promise { total += before; run.opts.onProgress?.(`mirror deletion: ${before} row(s) of ${physical}`); } + if (table.object === TEMPLATES) total += await deletePromotedTemplates(run, table.tables); const kept = Math.max(0, table.rows!.total - (run.deleted.get(table.object) ?? 0)); if (kept > 0) notes.push(`${table.object}: ${kept} row(s) are not mirrors (a named category or their non-seeded provenance claims them) and stay.`); } return { step: 'mirror-deletion', status: 'done', summary: { deleted: total }, blockers: [], notes }; } +/** Delete, by id, the promotion rows whose environment copy this run verified; nothing else. */ +async function deletePromotedTemplates(run: Run, tables: readonly string[]): Promise { + const ids = [...run.verifiedTemplateIds]; + let deleted = 0; + for (const physical of tables) { + const id = run.sql.q('id', physical); + for (let i = 0; i < ids.length; i += IN_CHUNK) { + const chunk = ids.slice(i, i + IN_CHUNK); + const where = { text: `${id} IN (${chunk.map(() => '?').join(', ')})`, params: chunk as unknown[] }; + const before = await countWhere(run, physical, where); + if (before === 0) continue; + await execute(run, physical, `DELETE FROM ${run.sql.q(physical, physical)} WHERE ${where.text}`, where.params); + if ((await countWhere(run, physical, where)) !== 0) { + throw new Error(`The delete of promoted template rows on ${physical} left rows behind; nothing past this table was deleted.`); + } + deleted += before; + } + } + run.deleted.set(TEMPLATES, (run.deleted.get(TEMPLATES) ?? 0) + deleted); + if (deleted) run.opts.onProgress?.(`mirror deletion: ${deleted} promoted template row(s), each with its environment copy`); + return deleted; +} + // ── Step 5: column drops (fate 1) ──────────────────────────────────────────── async function runColumnDrops(run: Run): Promise { @@ -818,6 +1125,8 @@ async function runColumnDrops(run: Run): Promise { const RUNNERS: Record Promise> = { attribution: runAttribution, 'authored-conversion': runAuthoredConversion, + 'template-promotion': runTemplatePromotion, + 'settings-move': runSettingsMove, rewrite: runRewrite, 'mirror-deletion': runMirrorDeletion, 'column-drops': runColumnDrops, @@ -839,7 +1148,13 @@ async function remainingFor(run: Run, table: TablePlan, complete: boolean): Prom if (!entry) return 0; let left = 0; for (const physical of table.tables) { - left += await countWhere(run, physical, deletableWhere(run, physical, (await columnsOf(run, physical))!, entry)); + const cols = (await columnsOf(run, physical))!; + left += await countWhere(run, physical, deletableWhere(run, physical, cols, entry)); + // A promotion row leaves only once its environment copy is verified: + // until then it remains, under every posture. + if (table.object === TEMPLATES) { + left += await countWhere(run, physical, templatePromotionSql(run.sql, cols)); + } } return left; } @@ -866,10 +1181,14 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise d.rows > 0).map((d) => ({ id: d.id, label: d.label, as: d.as, rows: d.rows })); + if (a?.physical === 'present' && object === TEMPLATES) { + let unpromoted = 0; + for (const physical of a.tables) { + unpromoted += await countWhere(run, physical, templatePromotionSql(run.sql, (await columnsOf(run, physical))!)); + } + if (unpromoted > 0) { + departures.push({ + id: 'template-promotion', + label: 'customized or admin-authored templates not yet promoted (a conflict, a nameless row or a blocker names each)', + as: 'moved', + rows: unpromoted, + }); + } + } + const waiting = departures.reduce((n, d) => n + d.rows, 0); tables.push({ object, fate: ref.fate, @@ -927,12 +1262,15 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise d.rows > 0).map((d) => ({ id: d.id, label: d.label, as: d.as, rows: d.rows })), + departures, + ...(a?.templateConflicts?.length ? { templateConflicts: a.templateConflicts } : {}), remaining, - notNull: a && departingRows(a) > 0 - ? { willReceive: false, reason: `${departingRows(a)} row(s) still wait for a step not yet run: ${a.departures.filter((d) => d.rows > 0).map((d) => d.label).join('; ')}` } + notNull: waiting > 0 + ? { willReceive: false, reason: `${waiting} row(s) still wait for a step not yet run: ${departures.map((d) => d.label).join('; ')}` } : ref.notNull, }); } diff --git a/packages/cli/src/utils/organization-ownership-plan.ts b/packages/cli/src/utils/organization-ownership-plan.ts index f3a384ebd8d..0a3a60ac918 100644 --- a/packages/cli/src/utils/organization-ownership-plan.ts +++ b/packages/cli/src/utils/organization-ownership-plan.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { createHash } from 'node:crypto'; +import { emailTemplateDefinitionFromRow } from '@objectstack/core'; import { hasPlatformObjectPrefix } from '@objectstack/spec/system'; import { APPLICATION_OBJECT_FATE, @@ -147,6 +148,100 @@ export interface TablePlan { cloudCarried?: true; external?: true; note?: string; + /** + * `sys_email_template` only: the `(name, locale)` groups of the promotion + * population whose candidate rows define the template differently. Fate 4 + * until the operator chooses one row per group; nothing is guessed. + */ + templateConflicts?: TemplateConflict[]; +} + +/** One `(name, locale)` the promotion cannot carry without the operator's choice. */ +export interface TemplateConflict { + name: string; + locale: string; + candidates: Array<{ id: string; organizationId: string | null }>; +} + +/** The table whose customized and authored rows the ceremony promotes (ruling C on ADR-0131 §6 Q1). */ +export const TEMPLATE_OBJECT = 'sys_email_template'; + +/** Provenance the seeders stamped: only such a row can be a copy of code. */ +export const SEEDED_BY: readonly string[] = ['package', 'platform', 'system']; + +/** + * The `sys_email_template` rows the promotion carries: a customized row (or one + * whose `customized` is unknown), and every row no seeder wrote (`managed_by` + * NULL or outside package / platform / system), with or without an + * organization. Every other row — seeded and uncustomized, NULL residue or an + * organization's copy — is a copy of code: a mirror, never promoted. + */ +export function templatePromotionSql(sql: Sql, cols: ReadonlySet): { text: string; params: unknown[] } { + const t = TEMPLATE_OBJECT; + const c = sql.q('customized', t); + const customized = cols.has('customized') ? `${c} = ? OR ${c} IS NULL` : '1 = 0'; + const params: unknown[] = cols.has('customized') ? [sql.bind(true)] : []; + if (!cols.has('managed_by')) return { text: '1 = 1', params: [] }; + const managed = sql.q('managed_by', t); + return { + text: `${customized} OR ${managed} IS NULL OR ${managed} NOT IN (${SEEDED_BY.map(() => '?').join(', ')})`, + params: [...params, ...SEEDED_BY], + }; +} + +/** The JSON of a definition's own keys, for comparing two bodies. */ +export const definitionKey = (body: Readonly>, keys: readonly string[]): string => + JSON.stringify(keys.map((k) => [k, body[k] ?? null])); + +/** One `(name, locale)` of the promotion population, its rows and the bodies they define. */ +export interface TemplateGroup { + name: string; + locale: string; + rows: Record[]; + bodies: Record[]; + /** The rows define the template differently. */ + conflict: boolean; +} + +/** Group population rows by `(name, locale)`; a row whose mapped body carries no name is listed apart. */ +export function groupTemplateRows(rows: readonly Record[]): { groups: TemplateGroup[]; nameless: string[] } { + const byKey = new Map(); + const nameless: string[] = []; + for (const row of rows) { + const body = emailTemplateDefinitionFromRow(row); + const name = typeof body.name === 'string' && body.name !== '' ? body.name : null; + if (name === null) { + nameless.push(String(row.id)); + continue; + } + const locale = String(body.locale); + const key = `${name}\u0000${locale}`; + const group = byKey.get(key) ?? { name, locale, rows: [], bodies: [], conflict: false }; + group.rows.push(row); + group.bodies.push(body); + byKey.set(key, group); + } + for (const group of byKey.values()) { + const keys = [...new Set(group.bodies.flatMap(Object.keys))]; + group.conflict = new Set(group.bodies.map((b) => definitionKey(b, keys))).size > 1; + } + return { groups: [...byKey.values()], nameless }; +} + +const nonEmptyId = (value: unknown): string | null => + value === null || value === undefined || String(value) === '' ? null : String(value); + +/** The conflicts among the promotion population of one physical template table. */ +async function readTemplateConflicts(sql: Sql, table: string, columns: ReadonlySet): Promise { + const where = templatePromotionSql(sql, columns); + const rows = await sql.read(table, `SELECT * FROM ${sql.q(table, table)} WHERE ${where.text} ORDER BY ${sql.q('id', table)}`, where.params); + return groupTemplateRows(rows) + .groups.filter((g) => g.conflict) + .map((g) => ({ + name: g.name, + locale: g.locale, + candidates: g.rows.map((r) => ({ id: String(r.id), organizationId: nonEmptyId(r[ORG]) })), + })); } export interface OrganizationOwnershipPlan { @@ -622,6 +717,11 @@ async function planPresentTable(ctx: PlanContext, entry: InventoryEntry, source: } plan.categories.push({ id: category.id, label: category.label, citation: category.citation, rows, ...(groups !== undefined ? { groups } : {}) }); } + if (entry.object === TEMPLATE_OBJECT) { + const conflicts: TemplateConflict[] = []; + for (const table of tables) conflicts.push(...(await readTemplateConflicts(sql, table, ctx.catalog.columns.get(table)!))); + plan.templateConflicts = conflicts; + } const rows = plan.rows!; const departing = plan.departures.reduce((n, d) => n + d.rows, 0); diff --git a/packages/core/src/security/email-template-row-definition.test.ts b/packages/core/src/security/email-template-row-definition.test.ts new file mode 100644 index 00000000000..ece43a94487 --- /dev/null +++ b/packages/core/src/security/email-template-row-definition.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D6/D10, ruling C] The pin of `emailTemplateDefinitionFromRow` — the + * mapping the upgrade ceremony applies to promote a customized + * `sys_email_template` row to an environment `email_template` item through the + * metadata door. The expected bodies are the outputs the module doc states, + * written out by hand; the schema is read only to hold the body to it. + */ + +import { describe, expect, it } from 'vitest'; +import { EMAIL_TEMPLATE_FLOOR_LOCALE, EmailTemplateDefinitionSchema } from '@objectstack/spec/system'; +import { emailTemplateDefinitionFromRow } from './email-template-row-definition.js'; + +/** A row as `sys_email_template` stores it, its state and provenance riding along. */ +const storedRow = { + id: 'tpl_01', + name: 'crm.welcome', + label: 'Welcome', + category: 'notification', + locale: 'zh-CN', + description: 'Sent on sign-up', + subject: 'Welcome, {{user.name}}', + body_html: '

Hello {{user.name}}

', + body_text: 'Hello {{user.name}}', + from_name: 'Acme Support', + from_address: 'support@acme.test', + reply_to: 'help@acme.test', + active: 0, + is_system: 1, + variables_json: '[{"name":"user.name","type":"string","required":true}]', + managed_by: 'package', + customized: 1, + organization_id: 'org_1', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-02T00:00:00.000Z', +}; + +const schemaKeys = (): Set => + new Set(Object.keys((EmailTemplateDefinitionSchema as unknown as { shape: Record }).shape)); + +describe('emailTemplateDefinitionFromRow', () => { + it('answers the definition the row carries, in order, with no state or provenance column', () => { + expect(emailTemplateDefinitionFromRow(storedRow)).toStrictEqual({ + name: 'crm.welcome', + label: 'Welcome', + locale: 'zh-CN', + subject: 'Welcome, {{user.name}}', + bodyHtml: '

Hello {{user.name}}

', + bodyText: 'Hello {{user.name}}', + variables: [{ name: 'user.name', type: 'string', required: true }], + fromOverride: { name: 'Acme Support', address: 'support@acme.test' }, + replyTo: 'help@acme.test', + active: false, + description: 'Sent on sign-up', + }); + }); + + it('answers a body the email_template schema accepts whole, with no key the schema does not declare', () => { + const body = emailTemplateDefinitionFromRow(storedRow); + const keys = schemaKeys(); + expect(Object.keys(body).filter((k) => !keys.has(k))).toEqual([]); + expect(EmailTemplateDefinitionSchema.safeParse(body).success).toBe(true); + }); + + it('falls back for the sparse row: label to name, locale to en-US, bodies to empty, active to true', () => { + const body = emailTemplateDefinitionFromRow({ + name: 'auth.reset', + label: '', + locale: null, + subject: null, + body_html: null, + body_text: '', + from_name: 'Nobody', + from_address: '', + reply_to: null, + variables_json: 'not json', + description: null, + }); + expect(body).toStrictEqual({ + name: 'auth.reset', + label: 'auth.reset', + locale: EMAIL_TEMPLATE_FLOOR_LOCALE, + subject: '', + bodyHtml: '', + variables: [], + active: true, + }); + expect(EmailTemplateDefinitionSchema.safeParse(body).success).toBe(true); + }); + + it('reads active the way the stored spellings mean it, and variables already parsed', () => { + for (const off of [false, 0, '0', 'false']) { + expect(emailTemplateDefinitionFromRow({ name: 'a', active: off }).active).toBe(false); + } + for (const on of [true, 1, '1', 'true', undefined, null]) { + expect(emailTemplateDefinitionFromRow({ name: 'a', active: on }).active).toBe(true); + } + expect(emailTemplateDefinitionFromRow({ name: 'a', variables_json: [{ name: 'x' }] }).variables).toEqual([{ name: 'x' }]); + expect(emailTemplateDefinitionFromRow({ name: 'a', variables_json: '{"name":"x"}' }).variables).toEqual([]); + }); + + it('is deterministic: the same row answers the same serialization', () => { + expect(JSON.stringify(emailTemplateDefinitionFromRow({ ...storedRow }))).toBe( + JSON.stringify(emailTemplateDefinitionFromRow(storedRow)), + ); + }); +}); diff --git a/packages/core/src/security/email-template-row-definition.ts b/packages/core/src/security/email-template-row-definition.ts new file mode 100644 index 00000000000..97a3c3b4aa5 --- /dev/null +++ b/packages/core/src/security/email-template-row-definition.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D6/D10, ruling C on §6 Q1] A `sys_email_template` row, turned into + * the `email_template` DEFINITION it carries. + * + * Templates resolve from the registry (`email_template` metadata); the rows are + * no longer read on the send path. A row an organization customized before + * that door closed is the one copy of the customer's edit, and ruling C + * promotes it to an environment template before the row is deleted. The + * upgrade ceremony (`os migrate`, C7b) reads the row, calls this function and + * saves the answer through the metadata door. ⛔ Never at boot. + * + * The function is pure: one row in, one body out — no engine, no I/O. Its one + * import is the spec's floor-locale constant, a plain string. The mapping is the + * inverse of the row shape the retired projector wrote, which is also the + * render input the email service reads (`EmailTemplateRow`): + * + * - `name` → `name`; `label` → `label`, falling back to the name when the + * column is empty or absent; + * - `locale` → `locale`, falling back to `EMAIL_TEMPLATE_FLOOR_LOCALE` (the + * schema's default and the bundle floor) when empty or absent; + * - `subject` → `subject` and `body_html` → `bodyHtml`, each `''` when empty + * or absent (both are required strings); + * - `body_text` → `bodyText`, `reply_to` → `replyTo` and `description` → + * `description`, each only when a non-empty string; + * - `from_address` → `fromOverride.address`, with `from_name` → + * `fromOverride.name` when non-empty; no `fromOverride` without an address; + * - `variables_json` → `variables` when it is JSON text (or an already + * parsed value) holding an array, else `[]`; + * - `active` → `active`: `false` for a stored `false`, `0`, `'0'` or + * `'false'`, `true` for anything else, an absent column included (the + * schema's default). + * + * The retired keys (`category`, `is_system`) and every state or provenance + * column (`managed_by`, `customized`, `organization_id`, the timestamps) are + * not a definition and never travel into one. + * + * ⛔ It is not validation. A name outside the schema's dotted snake_case still + * answers a body; the metadata door refuses it, and reporting that refusal is + * the ceremony's. + */ + +import { EMAIL_TEMPLATE_FLOOR_LOCALE } from '@objectstack/spec/system'; + +/** + * One `sys_email_template` row, as the mapping reads it. Every other column a + * row carries is accepted and ignored. + */ +export interface EmailTemplateRowForDefinition { + readonly name?: unknown; + readonly label?: unknown; + readonly locale?: unknown; + readonly description?: unknown; + readonly subject?: unknown; + readonly body_html?: unknown; + readonly body_text?: unknown; + readonly from_name?: unknown; + readonly from_address?: unknown; + readonly reply_to?: unknown; + readonly active?: unknown; + readonly variables_json?: unknown; + readonly [column: string]: unknown; +} + +const filled = (value: unknown): string | undefined => + typeof value === 'string' && value !== '' ? value : undefined; + +const variablesOf = (value: unknown): unknown[] => { + let parsed = value; + if (typeof value === 'string') { + try { + parsed = JSON.parse(value); + } catch { + return []; + } + } + return Array.isArray(parsed) ? parsed : []; +}; + +/** + * The `email_template` definition one `sys_email_template` row carries (module + * doc). Deterministic: the same row answers the same body, key for key and in + * the same key order. + */ +export function emailTemplateDefinitionFromRow(row: EmailTemplateRowForDefinition): Record { + const body: Record = { + name: row.name, + label: filled(row.label) ?? row.name, + locale: filled(row.locale) ?? EMAIL_TEMPLATE_FLOOR_LOCALE, + subject: filled(row.subject) ?? '', + bodyHtml: filled(row.body_html) ?? '', + }; + const bodyText = filled(row.body_text); + if (bodyText !== undefined) body.bodyText = bodyText; + body.variables = variablesOf(row.variables_json); + const address = filled(row.from_address); + if (address !== undefined) { + const name = filled(row.from_name); + body.fromOverride = name === undefined ? { address } : { name, address }; + } + const replyTo = filled(row.reply_to); + if (replyTo !== undefined) body.replyTo = replyTo; + body.active = !(row.active === false || row.active === 0 || row.active === '0' || row.active === 'false'); + const description = filled(row.description); + if (description !== undefined) body.description = description; + return body; +} diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index f998710d887..65b9d3e1a7d 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -310,6 +310,10 @@ export { positionDefinitionFromRow, type PositionRowForDefinition, } from './position-row-definition.js'; +export { + emailTemplateDefinitionFromRow, + type EmailTemplateRowForDefinition, +} from './email-template-row-definition.js'; // [commit f8eb73601] The measured read surface of the administrator derivation — the // single source `plugin-auth`'s break-glass standing-key lists correspond to.