Skip to content

Commit 08adfea

Browse files
fix(plugin-email): a metadata-door email template edit survives the next boot (#21818)
Fixes #21785 Clause-②: no ## What was broken An email template edited through `PUT /api/v1/meta/email_template/:name` (the Studio editor's door) went back to the package wording in `sys_email_template` on the next boot. `GET /meta` kept serving the admin's wording, so the admin saw one thing and the mail went out with another. ## Mechanism, measured I measured on the real showcase, two cold boots on one database file, with a throwaway probe that is not committed. The admin's session shape decides the outcome: | Admin session | Where the PUT lands | Registry at boot 2 | Sending row after boot 2 | |---|---|---|---| | no organization (`orgContext: false`, the harness default) | env-wide overlay | package entry, then the hydrated overlay | admin's wording, kept | | Default Organization (`orgContext: true`, plugin-auth's production default) | org-scoped overlay (`organization_id = org_…`) | package entry only | package wording: the defect | - `email_template` is `allowOrgOverride: true`. On a deployment with a Default Organization, every Studio save is therefore an org-scoped overlay. `loadMetaFromDb` hydrates env-wide rows only, so that overlay never reaches the registry `readDeclared` read (H1). - The save-time projection had already written the admin's wording into the sending row (H3: the row read the admin's wording before the restart, with `projectionApplied: { success: true }`). The boot sweep then read the package entry and wrote the package wording back. - `protocol.getMetaItem` with no organization answers the package wording. With the overlay's organization it answers the admin's wording. Reusing the door's effective reader (H2) therefore works only once an organization is named, and the sweep has none of its own. - At `kernel:ready` on boot 2, the moment the sweep runs, `getMetaItems({ type, organizationId })` answered the admin's wording and `getMetaItems({ type })` the package wording (H4). The effective item is available there; it only needs the organization. ## The change `readDeclared` now reads `protocol.getMetaItems`, the layered list `GET /meta/email_template` serves: org overlay over env-wide overlay over package, one item per `(name, locale)` slot. It reads in `tenancy.defaultOrgId()`'s organization: - under `single` that is the Default Organization (ADR-0131: there the organization is the environment), so the admin's overlay is projected; - under any walled posture it is `null`, and the read is env-wide. The organization comes from the platform's existing rule for an org-less reader of org-overridable metadata. The anonymous form doors read a form in `defaultOrgId()`'s organization (`anonymousFormOrganization` in `@objectstack/rest`, precedent: #21331). The sending row stays organization-agnostic: template resolution keys on `(name, locale)` only, and per-organization template rows are a capability no ruling has opened. - Served items go through the shared `stripReadDecorations`, because the strict schema refuses `_diagnostics`. The plugin's single-item effective read already does the same. - A failed effective read projects nothing that boot and warns. It never puts the registry's package layer in its place. - A host with no `protocol` service keeps the registry read. It has no metadata door, so nothing can overlay a declaration there. - Seed-not-clobber is untouched (H5): `upsertDeclaredEmailTemplate` is unchanged. This adds no second "customized" marker and no `packages/spec` edit. - `bootstrapDeclaredEmailTemplates` takes an optional fifth argument `{ protocol, tenancy }` (`EffectiveEmailTemplateSources`, now exported). Every existing caller is unaffected; the only caller is this plugin. ## Tests Unit, dogfood and typecheck ran at `3dcc8cd0`. The head `94479dd1` adds only the changeset, so the code is byte-identical. The gates ran at `94479dd1`. - `pnpm --filter @objectstack/plugin-email exec vitest run --maxWorkers=2`: 31 files, 514 tests passed. Four new pins under `bootstrapDeclaredEmailTemplates — the effective template (#21785)`: - the org-scoped overlay is projected, read in the default organization; - the read is env-wide when `defaultOrgId()` is `null`; - a failed read projects nothing and never the package layer; - seed-not-clobber holds over the effective read. - `pnpm --filter @objectstack/plugin-email typecheck`: exit 0. That covers `tsc --noEmit` and `check:test-typecheck`; the test layer compiles under `tsconfig.test.json`. - New dogfood pin `packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts`. It runs the real showcase with `orgContext: true` on one database file through three cold boots: - precondition: the PUT lands org-scoped and is projected at once; - after a restart, the sending row, `GET /meta` and a real `sendTemplate` render all carry the admin's wording; - control: a data-door `PATCH` (stamped `customized: true`) survives the next restart. - Dogfood run with `test/email-template-materialization.dogfood.test.ts` beside it: 2 files, 5 tests passed. `pnpm --filter @objectstack/dogfood typecheck`: exit 0. - Before the fix (plugin-email `dist/` built at the base `18c2ddc1`, fix marker count 0): the new dogfood pin went 1 failed, 2 passed. Its failure is the card's symptom: the row read `✅ Task done: {{title}}` instead of the admin's wording after the restart. ## Ablation Both legs ran on the committed tree through `scripts/ablation-replace.mjs`, each with a restore trap. - **A: the sweep reads the package layer again.** The effective branch was made always-false with a marker literal. On disk the anchor went 1 to 0 and the blob `b0f455bd` to `cc6d6a3c`. After a rebuild, `ablation-dist-preflight.mjs` found the marker in `dist/index.js` and `dist/index.mjs`. - Unit: all four `#21785` pins red (4 failed, 17 passed). - Dogfood: the cold-boot pin red, with `"subject": "✅ Task done: {{title}}"` received. - Restored: blob `b0f455bd` equals HEAD and `git diff HEAD` is empty. After the rebuild, the `--absent` preflight passed. - **B: a failed effective read falls back to the registry.** Unit: the failed-read pin went red (`{ seeded: 1 }` received, `{ seeded: 0 }` expected), 1 failed and 20 passed. Restored: blob equals HEAD, `git diff HEAD` is empty. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 68 commands at `94479dd1` against the merge base `18c2ddc1`. - 67 exited 0 on the first run. `pnpm check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`: eight packages outside this diff had no `dist/`). After building them it exited 0: 106 require entry points across 66 packages load. - `--ran`: `68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN`. - Lint, narrowed and proven: - population, read from eslint's own config: the five changed code files are linted, and `--print-config` on the changeset answers `undefined`; - counts, from `--format json`: 5 files, 0 errors, 0 warnings; - invariance: the config never enables type-aware linting (no `parserOptions.project`), its rule plugins are per-file AST rules, and its only file reads at config load are two baseline JSONs this diff does not touch. The diff cannot move a verdict on any untouched file. ## Acceptance notes - Before this change, the env-wide overlay shape kept the admin's wording only because the registry lists the hydrated overlay after the package entry. The sweep wrote the package wording and then the overlay wording on every boot. It now writes the effective item once. - [Redacted by the seat (domain:services#1, session_011K3zqE8Pv1Evw5hc8tZCnN) at 2026-10-05T05:05Z: an adjacent class outside this card, held by the seat for measurement. Detail is withheld from public surfaces.] - `packages/objectql/src/registry-i18n-bundle-key.test.ts` has a comment saying the materializer reads `registry.listItems('email_template')`. It stays true in substance, because that listing is `getMetaItems`' base layer, so it is not edited (outside this PR's surface). - Docs: no `content/docs` sentence became false. `email-templates.mdx` already says "A reworded transactional mail survives your next deploy", which now holds for metadata-door edits too. ## Patch round 1 (head bae6303) _Appended by the seat (`domain:services#1`, `session_011K3zqE8Pv1Evw5hc8tZCnN`) from the dev's patch-round report `5989088536`, after seat review `5988470640`._ - **The published surface is unchanged.** The effective boot sweep is now the module-internal `bootstrapEffectiveEmailTemplates`, which `EmailServicePlugin`'s boot wiring calls with the `protocol` and `tenancy` services. The exported `bootstrapDeclaredEmailTemplates` keeps its published four-parameter signature and delegates with no sources, so a caller outside the plugin reads the registry exactly as before. `EffectiveEmailTemplateSources` is no longer re-exported. It is one sweep body with two entry points. - **Measured on the built artifact at `bae6303d`:** - `src/index.ts` is byte-identical to the base; - `dist/index.d.ts` names neither new identifier; - the ESM and CJS export lists hold the same 93 keys; - the `exports` map names only `.`, so the module file is not separately addressable. - The changeset stays `patch` with `Clause-②: no`. - Merged `origin/main` (`18c7dfd2`) with no conflict. - **Re-verified at `bae6303d`:** - plugin-email: 31 files, 514 tests passed; typecheck green; - dogfood: 2 files, 5 tests passed; typecheck green; - eslint: 5 files, 0 errors, 0 warnings. - **Ablations:** - A (the sweep reads the package layer) turned the four unit pins and the cold-boot dogfood pin red. - B (a failed read falls back to the registry) turned its pin red. - Both restores are proven. - **Gates:** `dispatch-gates --commands` lists 68 commands, all exit 0. `--ran`: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e1790fd commit 08adfea

5 files changed

Lines changed: 456 additions & 10 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/plugin-email": patch
3+
---
4+
5+
An email template edited through `PUT /api/v1/meta/email_template/:name` (the Studio editor's door) now keeps the admin's wording in `sys_email_template` across a restart. Before, the boot sweep wrote the package wording back over the sending row while `GET /meta` kept serving the admin's, so mail went out with the package wording after every boot.
6+
7+
Clause-②: no
8+
9+
- The cause: on a deployment with a Default Organization the admin's save is an org-scoped overlay, and boot hydration keeps org-scoped overlays out of the registry the sweep read. An env-wide overlay was already kept.
10+
- `EmailServicePlugin`'s boot sweep now projects the effective template: the layered list `protocol.getMetaItems` serves, read in the organization `tenancy.defaultOrgId()` names. That is the Default Organization under the `single` posture. A host without a `protocol` service reads the registry as before.
11+
- A failed effective read projects nothing for that boot, so the rows keep their last projection. It does not fall back to the package wording.
12+
- Seed-not-clobber is unchanged. A row an admin created (`managed_by: 'admin'`) or edited through the data API (`customized: true`) is still never overwritten.
13+
- The published API is unchanged. The exported `bootstrapDeclaredEmailTemplates` keeps its signature and still reads the registry, so a caller outside the plugin sees the same behaviour as before.

‎packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import { describe, expect, it, vi } from 'vitest';
1313
import {
1414
bootstrapDeclaredEmailTemplates,
15+
bootstrapEffectiveEmailTemplates,
1516
upsertDeclaredEmailTemplate,
1617
deactivateDeclaredEmailTemplate,
1718
mapTemplateToRow,
@@ -438,3 +439,137 @@ describe('declared email templates carrying the `content` alias spelling (#8378)
438439
expect(warn.mock.calls[0][1].name).toBe('ops.digest');
439440
});
440441
});
442+
443+
// ---------------------------------------------------------------------------
444+
// [#21785] The boot sweep projects the EFFECTIVE template
445+
// ---------------------------------------------------------------------------
446+
447+
/**
448+
* A `protocol.getMetaItems` stand-in that answers the way the layered list
449+
* does for ONE org-scoped overlay: read in the overlay's organization, the
450+
* overlay wins its slot; read env-wide (no organization), the declaration is
451+
* served. Each item carries the `_diagnostics` read decoration the real
452+
* served list carries, which the strict schema refuses unless it is stripped.
453+
* Every request is recorded, so the organization the sweep read in is
454+
* asserted rather than assumed.
455+
*/
456+
function layeredProtocol(declared: any[], overlay?: { organizationId: string; items: any[] }) {
457+
const requests: Array<{ type: string; organizationId?: string }> = [];
458+
return {
459+
requests,
460+
async getMetaItems(request: { type: string; organizationId?: string }) {
461+
requests.push({ ...request });
462+
const items = overlay && request.organizationId === overlay.organizationId ? overlay.items : declared;
463+
return { type: request.type, items: items.map((i) => ({ ...i, _diagnostics: { valid: true } })) };
464+
},
465+
};
466+
}
467+
468+
const ORG = 'org_default';
469+
const PACKAGE_WORDING = 'Reset your password, {{user.name}}';
470+
const OVERLAY_WORDING = 'Admin reworded: reset for {{user.name}}';
471+
472+
/** The row as the live path leaves it after `PUT /meta`: package provenance, overlay wording, NOT customized. */
473+
function rowProjectedFromOverlay(over: Record<string, any> = {}): any {
474+
return {
475+
id: 'etpl_seeded',
476+
name: 'auth.password_reset',
477+
locale: 'en-US',
478+
subject: OVERLAY_WORDING,
479+
managed_by: 'package',
480+
customized: false,
481+
...over,
482+
};
483+
}
484+
485+
describe('bootstrapDeclaredEmailTemplates — the effective template (#21785)', () => {
486+
it('projects the org-scoped overlay the metadata door serves, read in the default organization', async () => {
487+
// The registry holds ONLY the declaration: boot hydration leaves an
488+
// org-scoped overlay out of it. Reading it is the reverted-on-restart defect.
489+
const engine = new FakeEngine({
490+
rows: { [TABLE]: [rowProjectedFromOverlay()] },
491+
declared: { email_template: [declaredTemplate()] },
492+
});
493+
const protocol = layeredProtocol(
494+
[declaredTemplate()],
495+
{ organizationId: ORG, items: [declaredTemplate({ subject: OVERLAY_WORDING })] },
496+
);
497+
498+
const result = await bootstrapEffectiveEmailTemplates(engine as any, undefined, {
499+
protocol,
500+
tenancy: { defaultOrgId: async () => ORG },
501+
});
502+
503+
expect(protocol.requests).toEqual([{ type: 'email_template', organizationId: ORG }]);
504+
expect(result).toEqual({ seeded: 1, skipped: 0 });
505+
expect(rowsOf(engine)).toHaveLength(1);
506+
expect(rowsOf(engine)[0].subject).toBe(OVERLAY_WORDING);
507+
});
508+
509+
it('reads env-wide when the tenancy service names no organization (a walled posture never guesses one)', async () => {
510+
const engine = new FakeEngine({ rows: { [TABLE]: [rowProjectedFromOverlay()] } });
511+
const protocol = layeredProtocol(
512+
[declaredTemplate()],
513+
{ organizationId: ORG, items: [declaredTemplate({ subject: OVERLAY_WORDING })] },
514+
);
515+
516+
await bootstrapEffectiveEmailTemplates(engine as any, undefined, {
517+
protocol,
518+
tenancy: { defaultOrgId: async () => null },
519+
});
520+
521+
// No organization on the request: the tenancy contract named none, so the
522+
// read is env-wide and the declaration is what the row carries.
523+
expect(protocol.requests).toEqual([{ type: 'email_template' }]);
524+
expect(rowsOf(engine)[0].subject).toBe(PACKAGE_WORDING);
525+
});
526+
527+
it('projects nothing on a failed effective read, never the package layer in its place', async () => {
528+
const engine = new FakeEngine({
529+
rows: { [TABLE]: [rowProjectedFromOverlay()] },
530+
declared: { email_template: [declaredTemplate()] },
531+
});
532+
const warn = vi.fn();
533+
const protocol = {
534+
async getMetaItems(): Promise<never> { throw new Error('sys_metadata read failed'); },
535+
};
536+
537+
const result = await bootstrapEffectiveEmailTemplates(engine as any, undefined, {
538+
protocol,
539+
tenancy: { defaultOrgId: async () => ORG },
540+
}, { warn });
541+
542+
expect(result).toEqual({ seeded: 0, skipped: 0 });
543+
expect(rowsOf(engine)[0].subject).toBe(OVERLAY_WORDING);
544+
expect(warn).toHaveBeenCalledTimes(1);
545+
expect(warn.mock.calls[0][1]).toEqual({ error: 'sys_metadata read failed' });
546+
});
547+
548+
it('keeps seed-not-clobber over the effective read: admin-authored and customized rows are skipped', async () => {
549+
const engine = new FakeEngine({
550+
rows: {
551+
[TABLE]: [
552+
{ id: 'a', name: 'ops.digest', locale: 'en-US', subject: 'Admin original', managed_by: 'admin' },
553+
rowProjectedFromOverlay({ subject: 'Data-door wording', customized: true }),
554+
],
555+
},
556+
});
557+
const warn = vi.fn();
558+
const protocol = layeredProtocol([], {
559+
organizationId: ORG,
560+
items: [
561+
declaredTemplate({ name: 'ops.digest', category: 'notification', subject: 'Overlay digest' }),
562+
declaredTemplate({ subject: OVERLAY_WORDING }),
563+
],
564+
});
565+
566+
const result = await bootstrapEffectiveEmailTemplates(engine as any, undefined, {
567+
protocol,
568+
tenancy: { defaultOrgId: async () => ORG },
569+
}, { warn });
570+
571+
expect(result).toEqual({ seeded: 0, skipped: 2 });
572+
expect(rowsOf(engine).map((r) => r.subject)).toEqual(['Admin original', 'Data-door wording']);
573+
expect(warn).toHaveBeenCalledTimes(1);
574+
});
575+
});

‎packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts‎

Lines changed: 130 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,18 @@
4545
* boot-only bridge would leave a Studio save inert until the next restart — the
4646
* same bug, half-fixed. {@link upsertDeclaredEmailTemplate} is exported for the
4747
* live `metadata.subscribe('email_template', …)` path in EmailServicePlugin.
48+
*
49+
* ## What the boot sweep projects (#21785)
50+
* The EFFECTIVE template — what the metadata door serves, a Studio overlay
51+
* included — not the package's declaration. The live path already projects
52+
* the overlay when the admin saves it, so a boot sweep reading the package
53+
* layer reverted the sending row on every restart while `GET /meta` kept
54+
* serving the admin's wording. See {@link readDeclared}.
4855
*/
4956

5057
import type { IDataEngine } from '@objectstack/spec/contracts';
58+
import type { GetMetaItemsRequest, GetMetaItemsResponse } from '@objectstack/spec/api';
59+
import { stripReadDecorations } from '@objectstack/spec/kernel';
5160
import {
5261
EmailTemplateDefinitionSchema,
5362
type EmailTemplateDefinition,
@@ -102,9 +111,68 @@ function uid(prefix: string): string {
102111
}
103112

104113
/**
105-
* Read declared `email_template` items from the ObjectQL registry (where the
106-
* manifest decomposition parks `stack.emailTemplates`), falling back to the
107-
* metadata service. Both reads hand back the authoring document itself.
114+
* The two kernel services the boot sweep reads the EFFECTIVE templates through
115+
* (#21785). Both are optional: a host that registers no `protocol` has no
116+
* metadata door, so nothing can overlay a declaration there and the registry
117+
* read below is already the effective one.
118+
*
119+
* Module-internal: exported for EmailServicePlugin's boot wiring only and
120+
* deliberately NOT re-exported from the package entry — see
121+
* {@link bootstrapEffectiveEmailTemplates}.
122+
*/
123+
export interface EffectiveEmailTemplateSources {
124+
/** The `protocol` service. `getMetaItems` is the layered list `GET /meta/email_template` serves. */
125+
protocol?: { getMetaItems(request: GetMetaItemsRequest): Promise<GetMetaItemsResponse> };
126+
/** The `tenancy` service. `defaultOrgId()` is the organization an org-less read resolves in. */
127+
tenancy?: { defaultOrgId(): Promise<string | null> };
128+
}
129+
130+
/** {@link readDeclared}'s answer when the effective read did not happen. */
131+
const EFFECTIVE_READ_FAILED = Symbol('email-template-effective-read-failed');
132+
133+
/**
134+
* Read the `email_template` items the boot sweep projects: the EFFECTIVE
135+
* items, as the metadata door serves them, when a `protocol` is registered;
136+
* otherwise the declared items from the ObjectQL registry (where the manifest
137+
* decomposition parks `stack.emailTemplates`), falling back to the metadata
138+
* service. Every read hands back the authoring document itself.
139+
*
140+
* ## [#21785] Why the effective read, and in which organization
141+
*
142+
* The registry holds the package's declaration and only the ENV-WIDE overlays
143+
* boot hydration (`loadMetaFromDb`) registers. `email_template` is
144+
* `allowOrgOverride: true`, so an admin saving through `PUT /meta` with an
145+
* active organization — every Studio save on a `single`-posture deployment,
146+
* where the Default Organization is bootstrapped — writes an ORG-SCOPED
147+
* overlay, which hydration deliberately leaves out of the process-wide
148+
* registry. The live path projected that overlay into the sending row at save
149+
* time; this sweep then read the package layer and wrote the package wording
150+
* back on every boot, while `GET /meta/email_template/:name` kept serving the
151+
* admin's wording. Measured on the showcase before the fix: the env-wide
152+
* overlay survived (the registry lists it after the package entry), the
153+
* org-scoped one reverted.
154+
*
155+
* So the sweep reads what the door reads — `protocol.getMetaItems`, the
156+
* layered list (org overlay over env-wide overlay over package), one item per
157+
* `(name, locale)` slot — and resolves the organization the way every other
158+
* org-less reader of org-overridable metadata does: `tenancy.defaultOrgId()`,
159+
* as the anonymous form doors read a form (`@objectstack/rest`). That answers
160+
* the Default Organization under `single` (ADR-0131: the organization IS the
161+
* environment there) and `null` whenever a walled posture was requested (the
162+
* tenancy contract never guesses an organization there), where the read is
163+
* env-wide. The sending row stays org-agnostic: template resolution keys on
164+
* `(name, locale)` only, and per-organization template rows are a capability
165+
* no ruling has opened.
166+
*
167+
* The served items carry read decorations (`_diagnostics`) the strict schema
168+
* refuses, so each is passed through the shared `stripReadDecorations` — the
169+
* same treatment the plugin's single-item effective read applies.
170+
*
171+
* A failed effective read is NOT answered from the registry. The registry
172+
* holds the package wording, so falling back would revert every overlay
173+
* projection on a transient storage error — this defect again, by a second
174+
* route. It answers {@link EFFECTIVE_READ_FAILED} and the sweep projects
175+
* nothing; every row keeps its last projection until the next boot.
108176
*
109177
* ## [#8378] Why there is no `i?.content ?? i` here any more
110178
*
@@ -150,7 +218,30 @@ function uid(prefix: string): string {
150218
* then died at the `filter(Boolean)` below — the template was dropped with
151219
* no warning, no count, nothing (the ADR-0078 silent-loss shape).
152220
*/
153-
function readDeclared(engine: any, metadataService: any, type: string): any[] {
221+
async function readDeclared(
222+
engine: any,
223+
metadataService: any,
224+
type: string,
225+
sources: EffectiveEmailTemplateSources | undefined,
226+
logger: Logger | undefined,
227+
): Promise<unknown[] | typeof EFFECTIVE_READ_FAILED> {
228+
const protocol = sources?.protocol;
229+
if (typeof protocol?.getMetaItems === 'function') {
230+
try {
231+
const organizationId = typeof sources?.tenancy?.defaultOrgId === 'function'
232+
? await sources.tenancy.defaultOrgId()
233+
: null;
234+
const listed = await protocol.getMetaItems({ type, ...(organizationId ? { organizationId } : {}) });
235+
return listed.items.filter(Boolean).map(stripReadDecorations);
236+
} catch (err: any) {
237+
logger?.warn?.(
238+
'[email] effective email-template read failed — no declared template re-materialized this boot; '
239+
+ 'every sys_email_template row keeps its last projection',
240+
{ error: err?.message ?? String(err) },
241+
);
242+
return EFFECTIVE_READ_FAILED;
243+
}
244+
}
154245
try {
155246
const reg = engine?._registry;
156247
if (reg?.listItems) {
@@ -272,17 +363,48 @@ export async function deactivateDeclaredEmailTemplate(
272363
}
273364

274365
/**
275-
* Materialize every declared email template into `sys_email_template`.
276-
* Idempotent and safe to run on every boot.
366+
* Materialize every declared email template into `sys_email_template` from the
367+
* ObjectQL registry (falling back to the metadata service). Idempotent and safe
368+
* to run on every boot.
369+
*
370+
* The published signature, unchanged: an external caller reads the registry
371+
* exactly as before — the declarations plus the env-wide overlays boot
372+
* hydration registered.
277373
*/
278374
export async function bootstrapDeclaredEmailTemplates(
279375
engine: IDataEngine,
280376
metadataService: any,
281377
logger?: Logger,
282378
object = EMAIL_TEMPLATE_OBJECT,
283379
): Promise<BootstrapDeclaredEmailTemplatesResult> {
284-
const declared = readDeclared(engine, metadataService, 'email_template');
285-
if (declared.length === 0) return { seeded: 0, skipped: 0 };
380+
// [#21785] The one sweep body, with no sources. The plugin's own boot wiring
381+
// calls the effective form directly. Said here, not in the docblock above:
382+
// the docblock ships in the published `.d.ts`, and the module-internal name
383+
// is not part of that surface.
384+
return bootstrapEffectiveEmailTemplates(engine, metadataService, undefined, logger, object);
385+
}
386+
387+
/**
388+
* [#21785] The boot sweep EmailServicePlugin runs: materialize every
389+
* `email_template` into `sys_email_template` as the metadata door serves it —
390+
* an overlay of the declaration included — when `sources.protocol` is given,
391+
* and from the registry otherwise (see {@link readDeclared}). The one sweep
392+
* body; {@link bootstrapDeclaredEmailTemplates} is this with no sources.
393+
*
394+
* Module-internal: NOT re-exported from the package entry (`src/index.ts`),
395+
* and the package's `exports` map names only that entry, so this function and
396+
* {@link EffectiveEmailTemplateSources} add nothing to the published surface.
397+
* No caller outside this package needs them.
398+
*/
399+
export async function bootstrapEffectiveEmailTemplates(
400+
engine: IDataEngine,
401+
metadataService: any,
402+
sources: EffectiveEmailTemplateSources | undefined,
403+
logger?: Logger,
404+
object = EMAIL_TEMPLATE_OBJECT,
405+
): Promise<BootstrapDeclaredEmailTemplatesResult> {
406+
const declared = await readDeclared(engine, metadataService, 'email_template', sources, logger);
407+
if (declared === EFFECTIVE_READ_FAILED || declared.length === 0) return { seeded: 0, skipped: 0 };
286408

287409
let seeded = 0;
288410
let skipped = 0;

‎packages/plugins/plugin-email/src/email-plugin.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,11 @@ import type {
3636
SettingsUnsubscribe,
3737
} from '@objectstack/spec/system';
3838
import {
39-
bootstrapDeclaredEmailTemplates,
39+
bootstrapEffectiveEmailTemplates,
4040
upsertDeclaredEmailTemplate,
4141
deactivateDeclaredEmailTemplate,
4242
mapTemplateToRow,
43+
type EffectiveEmailTemplateSources,
4344
} from './bootstrap-declared-email-templates.js';
4445
import {
4546
bindEmailTemplateProvenanceStamp,
@@ -1091,8 +1092,17 @@ export class EmailServicePlugin implements Plugin {
10911092
let metadataService: IMetadataService | undefined;
10921093
try { metadataService = ctx.getService<IMetadataService>('metadata'); } catch { /* optional */ }
10931094

1095+
// [#21785] The sweep projects the EFFECTIVE template — what `GET /meta`
1096+
// serves, an org-scoped Studio overlay included — through the protocol's
1097+
// layered list, read in `tenancy.defaultOrgId()`'s organization. Both are
1098+
// optional: a host without them has no metadata door to overlay through.
1099+
let protocol: EffectiveEmailTemplateSources['protocol'];
1100+
try { protocol = ctx.getService('protocol'); } catch { /* optional */ }
1101+
let tenancy: EffectiveEmailTemplateSources['tenancy'];
1102+
try { tenancy = ctx.getService('tenancy'); } catch { /* optional */ }
1103+
10941104
try {
1095-
await bootstrapDeclaredEmailTemplates(engine, metadataService, ctx.logger as any);
1105+
await bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol, tenancy }, ctx.logger as any);
10961106
} catch (err: any) {
10971107
ctx.logger.warn(
10981108
'EmailServicePlugin: declared email-template bootstrap failed (built-in templates still serve): '

0 commit comments

Comments
 (0)