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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/22937-cli-ceremony-promotions.md
Original file line number Diff line number Diff line change
@@ -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.
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 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.
73 changes: 67 additions & 6 deletions packages/cli/src/commands/migrate/organization-ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
applyOrganizationOwnership,
checkpointFromDetails,
ORGANIZATION_OWNERSHIP_MIGRATION_ID,
TemplateChoiceRefusal,
type ApplyExecutor,
type CeremonyLedgerPort,
type ColumnDropOutcome,
Expand Down Expand Up @@ -84,8 +85,9 @@ async function confirm(question: string): Promise<boolean> {
* 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
Expand All @@ -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 = [
Expand Down Expand Up @@ -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<void> {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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<string, unknown>, message: string) => Promise<void>,
): Promise<void> {
Expand Down Expand Up @@ -342,6 +365,7 @@ export default class MigrateOrganizationOwnership extends Command {
const engine = kernel.getService('objectql') as Parameters<typeof recordDataMigrationRun>[0] & { registry?: unknown };
const protocol = kernel.getService('protocol') as {
saveMetaItem?: (request: Record<string, unknown>) => Promise<unknown>;
getMetaItemsForExecution?: (request: { type: string }) => Promise<{ items?: unknown[] }>;
} | undefined;
const catalog = createSecurityCatalogReader({
registry: engine.registry as never,
Expand Down Expand Up @@ -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,
Expand All @@ -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<string, unknown>;
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<string, unknown> | null;
return !!i && i.name === name && (typeof i.locale === 'string' && i.locale ? i.locale : 'en-US') === locale;
}) as Record<string, unknown> | undefined;
return served ? { definition: served, environment: false } : undefined;
},
},
columnDrops: {
drop: async (tables) => {
const drift = await liveStack.detectManagedDrift();
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading