From cf336c60441b9ccf5de55687ad9b993883875817 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 01:42:17 +0000 Subject: [PATCH] fix(plugin-email): materialize a runtime email_template write without a restart (#7733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PUT /api/v1/meta/email_template/:name` returned 200 and persisted the row, but the template never reached `sys_email_template` — so it did not send 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, while the boot line "subscribed to email_template metadata changes" was present. 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 subscription registered fine, just to the other door. The gap is NOT in the metadata service's event delivery: the runtime-write path never reaches it. Both doors are now bridged through one shared 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)`, which also keeps #7731's invariant that a row's `locale` column holds the tag the 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 (stripping the underscore read decorations a served item carries, since the spec schema is a strictObject) 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. The projector seam has no unregister verb, so `dispose()` disarms the bridge explicitly rather than leaving a torn-down plugin writing through an engine whose provenance hook is already unbound. Reverse-verified: 8 of the 13 new cases fail on origin/main exactly as the QA run observed; the other 5 are never-regress guards (draft inert, other types ignored, admin rows never clobbered, boots without a protocol, detaches on dispose) that pass on both sides. Co-authored-by: Claude --- .changeset/email-template-runtime-write.md | 45 +++ ...mail-plugin.template-runtime-write.test.ts | 376 ++++++++++++++++++ .../plugins/plugin-email/src/email-plugin.ts | 308 ++++++++++++-- 3 files changed, 699 insertions(+), 30 deletions(-) create mode 100644 .changeset/email-template-runtime-write.md create mode 100644 packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts 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;