diff --git a/.changeset/email-template-runtime-write.md b/.changeset/email-template-runtime-write.md new file mode 100644 index 0000000000..f836f808ec --- /dev/null +++ b/.changeset/email-template-runtime-write.md @@ -0,0 +1,45 @@ +--- +"@objectstack/plugin-email": patch +--- + +Materialize a runtime `email_template` write without a restart (#7733) + +`PUT /api/v1/meta/email_template/:name` returned 200 and persisted the row, but +the template never reached `sys_email_template` — so sending it fell back to the +built-in default (or nothing) until the process restarted, at which point the +boot sweep picked the persisted row up and it worked. Neither of the live path's +own log lines ever fired. + +The bridge was armed against the wrong announcement. `bootDeclaredTemplates` +subscribed via `metadataService.subscribe('email_template', …)`, whose only +producer is `MetadataManager.register()` → `notifyWatchers()`. The REST save +does not go through there: it calls `protocol.saveMetaItem`, which persists to +`sys_metadata`, write-throughs to the ObjectQL SchemaRegistry (the +`[Registry] Registered email_template` line the QA run saw) and announces on its +own post-persistence seam. `notifyWatchers` has no caller outside +`MetadataManager`, so the watcher could not fire for a runtime write — the boot +log said "subscribed" and meant it, just to the other door. + +`EmailServicePlugin` now bridges both doors, sharing one materializer: + +* the existing metadata-service subscription — package ingest / artifact + reload; and +* the protocol's mutation seam — `PUT /meta`, the Studio save behind it, + publish and delete. The awaited ADR-0094 `registerMutationProjector` is + preferred, as plugin-security's permission projection prefers it, so the + write itself carries the materialization (a `PUT` followed by a read of + `sys_email_template` is consistent, with no race window) and a failure is + reported on the save's own `projectionApplied` instead of only in a log. + `onMetadataMutation` is the fallback for protocols predating the projector. + +Draft saves stay inert (the ADR-0005 staging buffer), and both seams landing the +same write is harmless — the upsert is keyed on `(name, locale)`, so the row's +`locale` column still holds the tag `sys_email_template`'s loader queries it by. + +A delete is no longer read as a withdrawal on its own. `DELETE /meta/:type/:name` +discards a *customization overlay*, so on an artifact-backed template it resets +to the packaged declaration; the bridge re-reads the effective item and +re-materializes the revealed baseline, deactivating rows only when nothing +declares the name any more. A failed read is not an answer and deactivates +nothing — a transient DB error must never be what stops a live template being +sent. diff --git a/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts b/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts new file mode 100644 index 0000000000..ba0e120e65 --- /dev/null +++ b/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts @@ -0,0 +1,376 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #7733 — a runtime `email_template` write must materialize WITHOUT a restart. +// +// The gap these pin. `bootDeclaredTemplates` wired its live path to +// `metadataService.subscribe('email_template', …)`, whose only producer is +// `MetadataManager.register()` → `notifyWatchers()`. `PUT /api/v1/meta/…` does +// NOT go through there: the REST route calls `protocol.saveMetaItem`, which +// persists to `sys_metadata` and then announces on ITS OWN seam — +// `runMutationProjector` (awaited, ADR-0094) and `onMetadataMutation` +// (fire-and-forget, #2588). `notifyWatchers` has no caller outside +// `metadata-manager.ts`, so the plugin's watcher was registered against an +// event the authoring path never emits: the save returned 200, the row never +// reached `sys_email_template`, and only a restart (which re-runs the boot +// sweep against the persisted row) materialized it. +// +// So these tests drive the plugin through the seam the PUT path actually +// announces on, and assert the OBSERVABLE effect the QA repro looked for: a +// `sys_email_template` row, right after the write, with no restart. +// +// The protocol double mirrors `ObjectStackProtocolImplementation`'s two +// registration seams and the `MetadataMutationEvent` shape it emits +// (`{ type: , name, state: 'active'|'draft'|'deleted' }`, +// plus `body` on the projector) — the same double every other consumer of this +// seam is tested against (service-i18n's authored-translation sync, +// plugin-security's permission projection). + +import { describe, it, expect, vi } from 'vitest'; +import { assertEngineUpdateDispatch } from '@objectstack/objectql'; +import { EmailServicePlugin } from './email-plugin.js'; + +const TABLE = 'sys_email_template'; + +type AnyRecord = Record; + +// ── doubles ──────────────────────────────────────────────────────────────── + +/** + * Row store with the slice of ObjectQL the template bridge and the provenance + * stamp touch. `update` routes through `assertEngineUpdateDispatch` so this + * double cannot accept a dispatch shape the real engine would refuse. + */ +function fakeEngine(seed: AnyRecord[] = []) { + const rows: AnyRecord[] = seed.map((r) => ({ ...r })); + const matches = (row: AnyRecord, cond?: AnyRecord) => + !cond || Object.entries(cond).every(([k, v]) => row[k] === v); + return { + rows, + _registry: { listItems: () => [] }, + async find(object: string, q?: AnyRecord) { + if (object !== TABLE) return []; + const out = rows.filter((r) => matches(r, q?.where ?? q?.filter)); + return typeof q?.limit === 'number' ? out.slice(0, q.limit) : out; + }, + async insert(_object: string, row: AnyRecord) { + rows.push({ ...row }); + return { id: row.id }; + }, + async update(_object: string, data: AnyRecord, options?: AnyRecord) { + const dispatch = assertEngineUpdateDispatch(data, options); + if (dispatch.kind !== 'by-id') throw new Error(`unexpected update dispatch: ${dispatch.kind}`); + const target = rows.find((r) => r.id === dispatch.id); + if (target) Object.assign(target, data); + return { affected: target ? 1 : 0 }; + }, + registerHook() { /* provenance stamp */ }, + unregisterHooksByPackage() { return 0; }, + }; +} + +/** + * The two seams `saveMetaItem` / `publishMetaItem` / `deleteMetaItem` announce + * on. `save`/`remove` replay what the real protocol does after persistence: + * the awaited per-type projector first (with the just-persisted body), then the + * fire-and-forget listeners. + */ +function fakeProtocol(opts: { projector?: boolean; listeners?: boolean } = {}) { + const withProjector = opts.projector !== false; + const withListeners = opts.listeners !== false; + const projectors = new Map Promise>(); + const listeners: Array<(evt: AnyRecord) => void> = []; + const p: AnyRecord = { + projectorFailures: [] as string[], + async announce(evt: AnyRecord) { + const projector = projectors.get(evt.type); + if (projector) { + // Mirrors `runMutationProjector`: a throw is caught and reported on + // the write's response, never propagated to the caller. + try { await projector(evt); } + catch (e: any) { p.projectorFailures.push(String(e?.message ?? e)); } + } + for (const l of listeners) l(evt); + await new Promise((r) => setTimeout(r, 0)); // let a fire-and-forget path settle + }, + /** A `PUT /api/v1/meta/email_template/:name` that landed. */ + save: (name: string, body: unknown, state: 'active' | 'draft' = 'active') => + p.announce({ type: 'email_template', name, state, body }), + /** A `DELETE /api/v1/meta/email_template/:name` that landed. */ + remove: (name: string) => p.announce({ type: 'email_template', name, state: 'deleted' }), + }; + if (withProjector) { + p.registerMutationProjector = (type: string, fn: (evt: AnyRecord) => Promise) => { + projectors.set(type, fn); + }; + } + if (withListeners) { + p.onMetadataMutation = (fn: (evt: AnyRecord) => void) => { + listeners.push(fn); + return () => { + const i = listeners.indexOf(fn); + if (i >= 0) listeners.splice(i, 1); + }; + }; + } + return p; +} + +/** + * A metadata service shaped like `MetadataManager`: `subscribe` registers + * cleanly (the boot log the QA run saw), and — the fact under test — the + * runtime-write path NEVER calls it back. + */ +function fakeMetadataService() { + const watchers: Array<(evt: AnyRecord) => void> = []; + return { + watchers, + list: () => [], + get: async () => undefined, + subscribe: (_type: string, cb: (evt: AnyRecord) => void) => { + watchers.push(cb); + return () => {}; + }, + }; +} + +/** Give the protocol double a layered `getMetaItem`, as the real one has. */ +function withEffectiveRead( + protocol: AnyRecord, + read: (name: string) => Promise, +): AnyRecord { + protocol.getMetaItem = async ({ name }: { name: string }) => read(name); + return protocol; +} + +function fakeCtx(services: Record) { + const hooks: Record Promise | void>> = {}; + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + return { + logger, + getService: (name: string): T => { + if (!(name in services)) throw new Error(`service '${name}' not registered`); + return services[name] as T; + }, + registerService: (name: string, svc: unknown) => { services[name] = svc; }, + hook: (name: string, fn: () => Promise | void) => { (hooks[name] ??= []).push(fn); }, + fire: async (name: string) => { for (const fn of hooks[name] ?? []) await fn(); }, + }; +} + +function template(over: AnyRecord = {}): AnyRecord { + return { + name: 'auth.password_reset', + label: 'Password Reset', + category: 'auth', + locale: 'en-US', + subject: 'Reset your password, {{user.name}}', + bodyHtml: '

Click here

', + ...over, + }; +} + +/** Boot the plugin to `kernel:ready`, as the kernel does. */ +async function boot(over: { engine?: any; protocol?: any; metadata?: any } = {}) { + const engine = over.engine ?? fakeEngine(); + const protocol = 'protocol' in over ? over.protocol : fakeProtocol(); + const metadata = over.metadata ?? fakeMetadataService(); + const services: Record = { + manifest: { register: () => {} }, + objectql: engine, + metadata, + }; + if (protocol) services.protocol = protocol; + const ctx = fakeCtx(services); + const plugin = new EmailServicePlugin({ seedTemplates: false }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + await ctx.fire('kernel:ready'); + return { plugin, ctx, engine, protocol, metadata }; +} + +const rowsOf = (engine: any, name: string) => + engine.rows.filter((r: AnyRecord) => r.name === name); + +// ── tests ────────────────────────────────────────────────────────────────── + +describe('#7733 runtime email_template write materializes without a restart', () => { + it('materializes a PUT /meta save into sys_email_template on the protocol seam', async () => { + const { engine, protocol, ctx } = await boot(); + + await protocol.save('auth.password_reset', template()); + + const rows = rowsOf(engine, 'auth.password_reset'); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + name: 'auth.password_reset', + // #7731 invariant — the loader queries by `(name, locale)`, so the row's + // `locale` column MUST hold the tag it is queried by. + locale: 'en-US', + subject: 'Reset your password, {{user.name}}', + body_html: '

Click here

', + managed_by: 'package', + }); + expect(protocol.projectorFailures).toEqual([]); + expect(ctx.logger.info).toHaveBeenCalledWith( + expect.stringContaining('materialized from a runtime write'), + ); + }); + + it('re-materializes an edit in place rather than adding a second row', async () => { + const { engine, protocol } = await boot(); + + await protocol.save('auth.password_reset', template()); + await protocol.save('auth.password_reset', template({ subject: 'Reset it, {{user.name}}' })); + + const rows = rowsOf(engine, 'auth.password_reset'); + expect(rows).toHaveLength(1); + expect(rows[0].subject).toBe('Reset it, {{user.name}}'); + }); + + it('deactivates the materialized rows when the item is genuinely withdrawn', async () => { + const { engine, protocol } = await boot(); + + await protocol.save('auth.password_reset', template()); + await protocol.remove('auth.password_reset'); + + expect(rowsOf(engine, 'auth.password_reset')[0].active).toBe(false); + }); + + it('re-materializes the packaged baseline a DELETE of an overlay reveals', async () => { + // `DELETE /meta/email_template/:name` discards a CUSTOMIZATION overlay + // (ADR-0005). On an artifact-backed template the packaged declaration is + // still shipping, so the reset must restore it — not retire it. + const protocol = withEffectiveRead(fakeProtocol(), async (name) => + name === 'auth.password_reset' + ? { item: template({ subject: 'The packaged subject' }) } + : undefined); + const { engine } = await boot({ protocol }); + + await protocol.save('auth.password_reset', template({ subject: 'An operator override' })); + await protocol.remove('auth.password_reset'); + + const rows = rowsOf(engine, 'auth.password_reset'); + expect(rows).toHaveLength(1); + expect(rows[0].subject).toBe('The packaged subject'); + expect(rows[0].active).not.toBe(false); + }); + + it('restores a baseline served WITH read decorations on it', async () => { + // `getMetaItem` returns a DECORATED item (`_diagnostics` from + // `decorateMetadataItem`, `_packageId` / `_provenance` from the registry + // and the overlay row). `EmailTemplateDefinitionSchema` is a strictObject + // that declares no underscore key, so an unstripped body would reject the + // very baseline the reset exists to restore. + const protocol = withEffectiveRead(fakeProtocol(), async () => ({ + type: 'email_template', + name: 'auth.password_reset', + item: { + ...template({ subject: 'The packaged subject' }), + _diagnostics: { valid: true }, + _packageId: 'com.objectstack.auth', + _provenance: { source: 'code' }, + }, + })); + const { engine } = await boot({ protocol }); + + await protocol.save('auth.password_reset', template({ subject: 'An operator override' })); + await protocol.remove('auth.password_reset'); + + const rows = rowsOf(engine, 'auth.password_reset'); + expect(rows).toHaveLength(1); + expect(rows[0].subject).toBe('The packaged subject'); + expect(rows[0].active).not.toBe(false); + }); + + it('deactivates nothing when the effective read fails — a DB blip is not a withdrawal', async () => { + const protocol = withEffectiveRead(fakeProtocol(), async () => { throw new Error('db gone'); }); + const { engine, metadata } = await boot({ protocol }); + // No second source can answer either. + metadata.get = async () => { throw new Error('db gone'); }; + + await protocol.save('auth.password_reset', template()); + await protocol.remove('auth.password_reset'); + + expect(rowsOf(engine, 'auth.password_reset')[0].active).not.toBe(false); + }); + + it('leaves a draft save inert — the staging buffer is not live', async () => { + const { engine, protocol } = await boot(); + + await protocol.save('auth.password_reset', template(), 'draft'); + + expect(engine.rows).toHaveLength(0); + }); + + it('ignores mutations of other metadata types', async () => { + const { engine, protocol } = await boot(); + + await protocol.announce({ type: 'view', name: 'v', state: 'active', body: template() }); + + expect(engine.rows).toHaveLength(0); + }); + + it('never clobbers an admin-authored row of the same name', async () => { + const engine = fakeEngine([{ + id: 'etpl_admin', + name: 'auth.password_reset', + locale: 'en-US', + subject: 'Hand-written by an operator', + managed_by: 'admin', + }]); + const { protocol } = await boot({ engine }); + + await protocol.save('auth.password_reset', template()); + + expect(rowsOf(engine, 'auth.password_reset')).toHaveLength(1); + expect(rowsOf(engine, 'auth.password_reset')[0].subject).toBe('Hand-written by an operator'); + }); + + it('reports a failed materialization on the write instead of throwing at the author', async () => { + const { protocol, ctx } = await boot(); + + // An item that fails `EmailTemplateDefinitionSchema` — the save persisted, + // the projection cannot. + await protocol.save('auth.broken', { name: 'auth.broken' }); + + expect(protocol.projectorFailures).toHaveLength(1); + expect(ctx.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('runtime email-template sync failed'), + ); + }); + + it('falls back to onMetadataMutation when the protocol has no projector seam', async () => { + const protocol = fakeProtocol({ projector: false }); + const { engine } = await boot({ protocol }); + + await protocol.save('auth.password_reset', template()); + + expect(rowsOf(engine, 'auth.password_reset')).toHaveLength(1); + }); + + it('materializes exactly once when both seams announce the same write', async () => { + const { engine, protocol } = await boot(); + + await protocol.save('auth.password_reset', template()); + + // Both seams fired (projector + listener); the upsert is keyed on + // `(name, locale)`, so the second one must find and update, not insert. + expect(rowsOf(engine, 'auth.password_reset')).toHaveLength(1); + }); + + it('boots, and keeps the boot sweep, on a kernel with no protocol service', async () => { + const { ctx, engine } = await boot({ protocol: null }); + + expect(engine.rows).toHaveLength(0); + expect(ctx.logger.error).not.toHaveBeenCalled(); + }); + + it('detaches both seams on dispose', async () => { + const { plugin, engine, protocol } = await boot(); + + await plugin.dispose(); + await protocol.save('auth.password_reset', template()); + + expect(engine.rows).toHaveLength(0); + }); +}); diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index 905ee90913..600c32e246 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -57,6 +57,30 @@ import { const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; +/** + * "No source could answer" — distinct from "nothing declares this name", which + * is a plain `undefined`. Only the latter may retire a materialized template + * row; see {@link EmailServicePlugin.readEffectiveTemplate}. + */ +const FAILED_READ = Symbol('email-template-read-failed'); + +/** + * Drop the underscore-prefixed keys a SERVED metadata item carries + * (`_diagnostics`, `_packageId`, `_provenance`, `_draft`, …). Every one of + * them is a read-time verdict the protocol attaches, never an authored field: + * `EmailTemplateDefinitionSchema` is a `strictObject` and declares no + * underscore key, so leaving them on turns a perfectly good declaration into a + * validation failure. + */ +function stripReadDecorations(item: unknown): unknown { + if (!item || typeof item !== 'object' || Array.isArray(item)) return item; + const out: Record = {}; + for (const [k, v] of Object.entries(item as Record)) { + if (!k.startsWith('_')) out[k] = v; + } + return out; +} + /** * Plugin configuration. */ @@ -246,6 +270,21 @@ export class EmailServicePlugin implements Plugin { private boundEngine?: IDataEngine; /** Live `email_template` metadata subscription — detached in dispose(). */ private unsubscribeTemplates?: () => void; + /** + * Live `email_template` protocol-mutation listener (#7733) — detached in + * dispose(). Only set on the `onMetadataMutation` fallback; the preferred + * `registerMutationProjector` seam is a per-type Map slot with no + * unregister verb, so that half is released by dropping the protocol. + */ + private unsubscribeTemplateMutations?: () => void; + /** + * Whether the live template bridge is armed. Cleared in dispose(), and read + * by the materializer because the projector seam has no unregister verb: a + * disposed plugin's projector stays in the protocol's per-type slot, and + * without this it would keep writing `sys_email_template` rows through an + * engine whose provenance hook has already been unbound. + */ + private templateBridgeArmed = false; /** SMTP transport currently in use, if any — closed in dispose(). */ private liveSmtp?: SmtpTransport; /** @@ -930,13 +969,49 @@ export class EmailServicePlugin implements Plugin { * * `email_template` is `allowRuntimeCreate: true` (unlike `webhook`), so a * boot-only sweep would leave a Studio save inert until the next restart — - * the same bug, half-fixed. The subscription re-materializes the single - * changed item; `MetadataManager.register` notifies watchers only AFTER the - * write has landed, so re-reading on the event cannot race the data. + * the same bug, half-fixed. + * + * [#7733] The live path needs BOTH announcements, because the two authoring + * doors announce on different seams and neither one covers the other: + * + * • `metadataService.subscribe('email_template', …)` — fed by + * `MetadataManager.register()` → `notifyWatchers()`. That is the artifact + * / package-ingest door. `register` announces only AFTER the write has + * landed, so re-reading on the event cannot race the data. + * + * • `protocol.registerMutationProjector` / `onMetadataMutation` — fed by + * `saveMetaItem` / `publishMetaItem` / `deleteMetaItem`, i.e. the + * RUNTIME-AUTHORING door: `PUT /api/v1/meta/email_template/:name`, the + * Studio save behind it, the AI builders, direct protocol callers. + * + * The comment this replaces claimed the first seam covered "Studio saves / + * PUT /meta" too. It does not, and nothing else made up the difference: + * `saveMetaItem` persists to `sys_metadata`, write-throughs to the ObjectQL + * SchemaRegistry (the `[Registry] Registered email_template` line the QA run + * saw) and announces on its OWN seam — it never calls `register()`, and + * `notifyWatchers` has no caller outside `MetadataManager`. So the watcher + * was armed against an event the PUT path never emits: 200 on the write, no + * row in `sys_email_template`, and only a restart — which re-runs the boot + * sweep over the persisted row — made the template send. + * + * The projector seam is preferred over the listener seam for the same reason + * plugin-security's permission projection prefers it (ADR-0094): it is + * AWAITED inside the write, so `PUT` → read `sys_email_template` is + * consistent with no race window, and a materialization failure is reported + * on the write's own response (`projectionApplied`) instead of vanishing into + * a log. `onMetadataMutation` is the fallback for protocol implementations + * that predate the projector. + * + * Both seams landing the same write is harmless and NOT guarded against: + * `upsertDeclaredEmailTemplate` is keyed on `(name, locale)` and idempotent, + * so a second delivery updates the row it just wrote. Deduping would need + * per-write state whose only job is to skip a write that costs one indexed + * lookup. */ private async bootDeclaredTemplates(ctx: PluginContext, engine: IDataEngine): Promise { // Bind the provenance stamp so an admin edit freezes a seeded row. this.boundEngine = engine; + this.templateBridgeArmed = true; try { bindEmailTemplateProvenanceStamp(engine as any, ctx.logger as any); } catch (err: any) { ctx.logger.warn('EmailServicePlugin: template provenance stamp not bound: ' + (err?.message ?? err)); @@ -954,41 +1029,214 @@ export class EmailServicePlugin implements Plugin { ); } - // Live path — Studio saves / PUT /meta land as `added`/`changed` events. - if (typeof metadataService?.subscribe !== 'function') return; - try { - this.unsubscribeTemplates = metadataService.subscribe('email_template', (event: any) => { - void (async () => { - try { - const kind = event?.type; - if (kind === 'deleted' || kind === 'unlink') { - // Delete events carry no locale — deactivate by name, and only - // rows this bridge owns. - await deactivateDeclaredEmailTemplate(engine, String(event?.name ?? ''), undefined, ctx.logger as any); - return; - } - const raw = event?.data ?? (event?.name - ? await metadataService.get?.('email_template', event.name) - : undefined); - if (!raw) return; - await upsertDeclaredEmailTemplate(engine, (raw as any)?.content ?? raw, undefined, ctx.logger as any); - ctx.logger.info(`EmailServicePlugin: email template '${event?.name}' materialized from a runtime write`); - } catch (err: any) { - ctx.logger.warn( - `EmailServicePlugin: runtime email-template sync failed for '${event?.name}': ${err?.message ?? err}`, - ); - } - })(); + // Live door 1 — package ingest / artifact reload, via the metadata service. + if (typeof metadataService?.subscribe === 'function') { + try { + this.unsubscribeTemplates = metadataService.subscribe('email_template', (event: any) => { + void this.materializeTemplateMutation(ctx, engine, { + name: event?.name, + // Watch events name the CHANGE (`added` / `changed` / `deleted` / + // `unlink`); mutation events name the resulting STATE. Fold to the + // one verb the materializer takes. + deleted: event?.type === 'deleted' || event?.type === 'unlink', + body: event?.data, + metadataService, + }).catch(() => { /* already logged */ }); + }); + ctx.logger.info('EmailServicePlugin: subscribed to email_template metadata changes'); + } catch (err: any) { + ctx.logger.warn('EmailServicePlugin: email_template subscription failed: ' + (err?.message ?? err)); + } + } + + // Live door 2 — runtime authoring (`PUT /meta`, Studio, publish, delete), + // via the protocol's post-persistence seam. [#7733] + this.wireTemplateMutationProjection(ctx, engine, metadataService); + } + + /** + * Arm the protocol-side half of the live template path (#7733). + * + * Prefers the AWAITED ADR-0094 projector so the write itself carries the + * materialization; falls back to the fire-and-forget `onMetadataMutation` + * listener (#2588) on protocol implementations that predate it. Silent no-op + * when the kernel registers no protocol at all (a data-plane-only host) — + * that deployment simply has no runtime-authoring door to bridge, and the + * boot sweep above still covers it. + */ + private wireTemplateMutationProjection( + ctx: PluginContext, + engine: IDataEngine, + metadataService: IMetadataService | undefined, + ): void { + let protocol: any; + try { protocol = ctx.getService('protocol'); } catch { return; } + if (!protocol) return; + + // `MetadataMutationEvent.state` is the row's resulting lifecycle. `draft` + // is the ADR-0005 staging buffer and is deliberately NOT live — the same + // reading every other consumer of this seam applies. + const materialize = (evt: any) => { + if (evt?.state === 'draft') return undefined; + return this.materializeTemplateMutation(ctx, engine, { + name: evt?.name, + deleted: evt?.state === 'deleted', + body: evt?.body, + metadataService, + protocol, }); - ctx.logger.info('EmailServicePlugin: subscribed to email_template metadata changes'); + }; + + try { + if (typeof protocol.registerMutationProjector === 'function') { + protocol.registerMutationProjector('email_template', async (evt: any) => { + // Throws on purpose: `runMutationProjector` catches it and reports + // `projectionApplied: { success:false, error }` on the save's own + // response, so an author whose template did not materialize learns + // it from the write instead of from a mail that never arrives. + await materialize(evt); + }); + ctx.logger.info('EmailServicePlugin: projecting email_template metadata mutations'); + return; + } + if (typeof protocol.onMetadataMutation === 'function') { + this.unsubscribeTemplateMutations = protocol.onMetadataMutation((evt: any) => { + if (evt?.type !== 'email_template') return; + void materialize(evt)?.catch(() => { /* already logged */ }); + }); + ctx.logger.info('EmailServicePlugin: subscribed to email_template metadata mutations'); + } } catch (err: any) { - ctx.logger.warn('EmailServicePlugin: email_template subscription failed: ' + (err?.message ?? err)); + ctx.logger.warn( + 'EmailServicePlugin: email_template mutation projection failed to wire: ' + (err?.message ?? err), + ); + } + } + + /** + * Apply ONE announced `email_template` change to `sys_email_template` — + * shared by both live doors, so a Studio save and a package ingest land + * through exactly the same write (and the same seed-not-clobber rules). + * + * `body` is used when the announcement carries it (the projector's + * just-persisted item, a watch event's `data`); otherwise the item is + * re-read through the metadata service. Both are re-read AFTER persistence, + * so neither can race the row it describes. + * + * A DELETE is not automatically a withdrawal. `DELETE /meta/:type/:name` + * discards a CUSTOMIZATION overlay (ADR-0005), so on an artifact-backed + * template it resets to the packaged declaration rather than removing it — + * deactivating there would silently retire a template the package still + * ships. So a delete re-reads the EFFECTIVE item (ADR-0094 names this the + * projector's job) and re-materializes the revealed baseline; only when + * nothing resolves is the template genuinely gone and the rows deactivated. + * A FAILED read is not an answer and deactivates nothing — a transient DB + * error must never be what stops a live template being sent. + * + * Rethrows after logging: the projector caller turns that into the write's + * `projectionApplied` verdict, and the fire-and-forget callers swallow it. + */ + private async materializeTemplateMutation( + ctx: PluginContext, + engine: IDataEngine, + evt: { + name: unknown; + deleted: boolean; + body: unknown; + metadataService: IMetadataService | undefined; + protocol?: any; + }, + ): Promise { + if (!this.templateBridgeArmed) return; + try { + if (evt.deleted) { + const revealed = await this.readEffectiveTemplate(ctx, evt); + if (revealed === FAILED_READ) return; + if (revealed) { + await upsertDeclaredEmailTemplate(engine, revealed, undefined, ctx.logger as any); + ctx.logger.info( + `EmailServicePlugin: email template '${evt.name}' reset to its packaged declaration`, + ); + return; + } + // Genuinely withdrawn. Delete announcements carry no locale — + // deactivate by name, and only rows this bridge owns. + await deactivateDeclaredEmailTemplate(engine, String(evt.name ?? ''), undefined, ctx.logger as any); + return; + } + const raw = evt.body ?? (evt.name + ? await evt.metadataService?.get?.('email_template', String(evt.name)) + : undefined); + if (!raw) return; + await upsertDeclaredEmailTemplate(engine, (raw as any)?.content ?? raw, undefined, ctx.logger as any); + ctx.logger.info(`EmailServicePlugin: email template '${evt.name}' materialized from a runtime write`); + } catch (err: any) { + ctx.logger.warn( + `EmailServicePlugin: runtime email-template sync failed for '${evt.name}': ${err?.message ?? err}`, + ); + throw err; + } + } + + /** + * The effective (layered) `email_template` body a delete may have revealed, + * `undefined` when nothing declares the name any more, or {@link FAILED_READ} + * when no source could answer. + * + * `protocol.getMetaItem` first — it is the layered read, so it reports the + * packaged declaration an overlay was hiding. `metadataService.get` is the + * fallback for hosts without that surface; it answers from the artifact + * registry, which for a delete is the same baseline. The three outcomes are + * kept apart on purpose: only "nothing declares it" may deactivate a row. + * + * The body is stripped of read decorations before it is returned. A served + * item carries the protocol's own underscore keys (`_diagnostics` from + * `decorateMetadataItem`, `_packageId` / `_provenance` from the registry and + * the overlay row), `EmailTemplateDefinitionSchema` is a `strictObject`, and + * it declares no underscore key — so handing the decorated body to the + * upsert would reject the very baseline this read exists to restore. This is + * the read-side twin of the strip `saveMetaItem` does on the write side. + */ + private async readEffectiveTemplate( + ctx: PluginContext, + evt: { name: unknown; metadataService: IMetadataService | undefined; protocol?: any }, + ): Promise { + const name = String(evt.name ?? ''); + if (!name) return undefined; + let answered = false; + let item: unknown; + if (typeof evt.protocol?.getMetaItem === 'function') { + try { + const res: any = await evt.protocol.getMetaItem({ type: 'email_template', name }); + answered = true; + item = res?.item ?? res?.data ?? (res?.name ? res : undefined); + } catch (err: any) { + ctx.logger.warn( + `EmailServicePlugin: effective read of email template '${name}' failed: ${err?.message ?? err}`, + ); + } + } + if (!answered && typeof evt.metadataService?.get === 'function') { + try { + item = await evt.metadataService.get('email_template', name); + answered = true; + } catch (err: any) { + ctx.logger.warn( + `EmailServicePlugin: effective read of email template '${name}' failed: ${err?.message ?? err}`, + ); + } } + if (!answered) return FAILED_READ; + if (!item) return undefined; + return stripReadDecorations((item as any)?.content ?? item); } async dispose(): Promise { + this.templateBridgeArmed = false; try { this.unsubscribeTemplates?.(); } catch { /* best effort */ } this.unsubscribeTemplates = undefined; + try { this.unsubscribeTemplateMutations?.(); } catch { /* best effort */ } + this.unsubscribeTemplateMutations = undefined; if (this.liveSmtp) { try { await this.liveSmtp.close(); } catch { /* best effort */ } this.liveSmtp = undefined;