Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-email): materialize a runtime email_template write without a restart (#7733) - #7878
Merged
huangyiirene merged 1 commit intoAug 12, 2026
Conversation
… 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 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 <noreply@anthropic.com>
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
huangyiirene
marked this pull request as ready for review
August 12, 2026 01:56
Uh oh!
There was an error while loading. Please reload this page.
huangyiirene
deleted the
claude/issue-7733-email-template-runtime-write
branch
August 12, 2026 02:08
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#7733
Premise: confirmed, and the cross-lane fork is NOT forced
The card's own analysis placed the gap "upstream of the callback — the metadata service does not deliver an
added/changedevent for theemail_templatenamespace on the runtime-write path". Measurement says the runtime-write path never reaches the metadata service at all, so there is nothing forpackages/metadata*to fix:PUT /api/v1/meta/:type/:name(packages/rest/src/rest-server.ts:5753) callsprotocol.saveMetaItem.saveMetaItem(packages/metadata-protocol/src/protocol.ts:9678) persists tosys_metadatavia the overlay repo, callsapplyRegistryWriteThrough— that is the[Registry] Registered email_templateline the QA run saw, frompackages/objectql/src/registry.ts:2006, the SchemaRegistry, not the MetadataManager — then announces on its own seam:runMutationProjector(awaited, ADR-0094) thenemitMetadataMutation(fire-and-forget, Authored (Studio) hooks never execute their body — no bodyRunner on the metadata-service bind path #2588).MetadataManager.register(). AndnotifyWatchers— the sole producer ofmetadataService.subscribecallbacks — has zero callers outsidemetadata-manager.ts.So the plugin's watcher was armed against an event the authoring door does not emit. The boot log "subscribed to email_template metadata changes" was true; it just named the other door. This is the same shape as the sibling precedent #7742 / PR #7847 (connectors), and it takes the same services-side answer: subscribe to a source the runtime write actually refreshes. No
packages/metadata*diff.The fix
EmailServicePluginnow bridges both doors through one shared materializer:metadataService.subscribe('email_template', …)— package ingest / artifact reload (unchanged behaviour).PUT /meta, the Studio save behind it, publish, delete.The awaited ADR-0094
registerMutationProjectoris preferred, exactly as plugin-security's permission projection prefers it: the write itself carries the materialization, so the QA repro'sPUT→ querysys_email_templateis consistent with no race window, and a failure is reported on the save's ownprojectionAppliedinstead of vanishing into a log.onMetadataMutationis the fallback for protocol implementations predating the projector. A kernel with no protocol service is a silent no-op — it has no runtime-authoring door, and the boot sweep still covers it.Draft saves stay inert (ADR-0005 staging buffer). Both seams landing the same write is harmless and deliberately un-deduped:
upsertDeclaredEmailTemplateis keyed on(name, locale)and idempotent.Two things found while wiring the delete path
DELETE /meta/:type/:namediscards a customization overlay, so on an artifact-backed template it resets to the packaged declaration. Deactivating there would silently retire a template the package still ships. The bridge re-reads the effective item and re-materializes the revealed baseline; rows are deactivated 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._diagnosticsfromdecorateMetadataItem,_packageId/_provenance), andEmailTemplateDefinitionSchemais astrictObjectdeclaring no underscore key — so an unstripped body would reject the very baseline the reset exists to restore. Stripped before the upsert.dispose()disarms the bridge explicitly: the projector seam is a per-type Map slot with no unregister verb, so without it a torn-down plugin would keep writing through an engine whose provenance hook is already unbound.#7731 invariant preserved
The read side is untouched, and the write side still goes through
mapTemplateToRow, so a row'slocalecolumn holds the tag the loader queries it by. Pinned explicitly in the first new test.Reverse-verification
The new suite was run against origin/main in a separate worktree: 9 of the 14 new cases fail, exactly the way the QA run observed. The other 5 are never-regress guards that pass on both sides and are meant to — draft inert, other metadata types ignored, admin-authored rows never clobbered, boots without a protocol service, detaches on dispose. The 21 pre-existing test files are green on main, so the only red is the new file.
Gates
pnpm --filter @objectstack/plugin-email... build(dep closure)pnpm --filter @objectstack/plugin-email testpnpm --filter @objectstack/plugin-email typecheckpnpm --filter @objectstack/cli typecheck(downstream consumer)pnpm check:docs-audit-scope.changeset/email-template-runtime-write.mdupdateroutes throughassertEngineUpdateDispatchpackages/spec/src/**is not touched, so no schema/docs regeneration applies. The full lint.yml farm was deliberately not run locally.