From 3dcc8cd0bdf3bf1fd7209a4448b902d1a8af88f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:23:09 +0000 Subject: [PATCH 1/4] fix(plugin-email): the boot sweep projects the effective email template, overlay included An email template edited through PUT /meta/email_template is stored as an org-scoped overlay on a deployment with a Default Organization, and boot hydration keeps org-scoped overlays out of the registry. The declared-template sweep read the registry, so it wrote the package wording back over the sending row on every boot while GET /meta kept serving the admin's wording. readDeclared now reads protocol.getMetaItems (the layered list the metadata door serves) in tenancy.defaultOrgId()'s organization: the Default Organization under single, none under a walled posture. A failed effective read projects nothing instead of falling back to the package layer. Seed-not-clobber is unchanged. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- ...bootstrap-declared-email-templates.test.ts | 134 ++++++++++++++ .../src/bootstrap-declared-email-templates.ts | 106 ++++++++++- .../plugins/plugin-email/src/email-plugin.ts | 12 +- packages/plugins/plugin-email/src/index.ts | 1 + ...late-overlay-survives-boot.dogfood.test.ts | 166 ++++++++++++++++++ 5 files changed, 410 insertions(+), 9 deletions(-) create mode 100644 packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts diff --git a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts index 12fb6002d3f..1d44a19bba5 100644 --- a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts +++ b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts @@ -438,3 +438,137 @@ describe('declared email templates carrying the `content` alias spelling (#8378) expect(warn.mock.calls[0][1].name).toBe('ops.digest'); }); }); + +// --------------------------------------------------------------------------- +// [#21785] The boot sweep projects the EFFECTIVE template +// --------------------------------------------------------------------------- + +/** + * A `protocol.getMetaItems` stand-in that answers the way the layered list + * does for ONE org-scoped overlay: read in the overlay's organization, the + * overlay wins its slot; read env-wide (no organization), the declaration is + * served. Each item carries the `_diagnostics` read decoration the real + * served list carries, which the strict schema refuses unless it is stripped. + * Every request is recorded, so the organization the sweep read in is + * asserted rather than assumed. + */ +function layeredProtocol(declared: any[], overlay?: { organizationId: string; items: any[] }) { + const requests: Array<{ type: string; organizationId?: string }> = []; + return { + requests, + async getMetaItems(request: { type: string; organizationId?: string }) { + requests.push({ ...request }); + const items = overlay && request.organizationId === overlay.organizationId ? overlay.items : declared; + return { type: request.type, items: items.map((i) => ({ ...i, _diagnostics: { valid: true } })) }; + }, + }; +} + +const ORG = 'org_default'; +const PACKAGE_WORDING = 'Reset your password, {{user.name}}'; +const OVERLAY_WORDING = 'Admin reworded: reset for {{user.name}}'; + +/** The row as the live path leaves it after `PUT /meta`: package provenance, overlay wording, NOT customized. */ +function rowProjectedFromOverlay(over: Record = {}): any { + return { + id: 'etpl_seeded', + name: 'auth.password_reset', + locale: 'en-US', + subject: OVERLAY_WORDING, + managed_by: 'package', + customized: false, + ...over, + }; +} + +describe('bootstrapDeclaredEmailTemplates — the effective template (#21785)', () => { + it('projects the org-scoped overlay the metadata door serves, read in the default organization', async () => { + // The registry holds ONLY the declaration: boot hydration leaves an + // org-scoped overlay out of it. Reading it is the reverted-on-restart defect. + const engine = new FakeEngine({ + rows: { [TABLE]: [rowProjectedFromOverlay()] }, + declared: { email_template: [declaredTemplate()] }, + }); + const protocol = layeredProtocol( + [declaredTemplate()], + { organizationId: ORG, items: [declaredTemplate({ subject: OVERLAY_WORDING })] }, + ); + + const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined, undefined, undefined, { + protocol, + tenancy: { defaultOrgId: async () => ORG }, + }); + + expect(protocol.requests).toEqual([{ type: 'email_template', organizationId: ORG }]); + expect(result).toEqual({ seeded: 1, skipped: 0 }); + expect(rowsOf(engine)).toHaveLength(1); + expect(rowsOf(engine)[0].subject).toBe(OVERLAY_WORDING); + }); + + it('reads env-wide when the tenancy service names no organization (a walled posture never guesses one)', async () => { + const engine = new FakeEngine({ rows: { [TABLE]: [rowProjectedFromOverlay()] } }); + const protocol = layeredProtocol( + [declaredTemplate()], + { organizationId: ORG, items: [declaredTemplate({ subject: OVERLAY_WORDING })] }, + ); + + await bootstrapDeclaredEmailTemplates(engine as any, undefined, undefined, undefined, { + protocol, + tenancy: { defaultOrgId: async () => null }, + }); + + // No organization on the request: one plant's overlay is never what the + // org-agnostic sending row carries. + expect(protocol.requests).toEqual([{ type: 'email_template' }]); + expect(rowsOf(engine)[0].subject).toBe(PACKAGE_WORDING); + }); + + it('projects nothing on a failed effective read, never the package layer in its place', async () => { + const engine = new FakeEngine({ + rows: { [TABLE]: [rowProjectedFromOverlay()] }, + declared: { email_template: [declaredTemplate()] }, + }); + const warn = vi.fn(); + const protocol = { + async getMetaItems(): Promise { throw new Error('sys_metadata read failed'); }, + }; + + const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined, { warn }, undefined, { + protocol, + tenancy: { defaultOrgId: async () => ORG }, + }); + + expect(result).toEqual({ seeded: 0, skipped: 0 }); + expect(rowsOf(engine)[0].subject).toBe(OVERLAY_WORDING); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][1]).toEqual({ error: 'sys_metadata read failed' }); + }); + + it('keeps seed-not-clobber over the effective read: admin-authored and customized rows are skipped', async () => { + const engine = new FakeEngine({ + rows: { + [TABLE]: [ + { id: 'a', name: 'ops.digest', locale: 'en-US', subject: 'Admin original', managed_by: 'admin' }, + rowProjectedFromOverlay({ subject: 'Data-door wording', customized: true }), + ], + }, + }); + const warn = vi.fn(); + const protocol = layeredProtocol([], { + organizationId: ORG, + items: [ + declaredTemplate({ name: 'ops.digest', category: 'notification', subject: 'Overlay digest' }), + declaredTemplate({ subject: OVERLAY_WORDING }), + ], + }); + + const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined, { warn }, undefined, { + protocol, + tenancy: { defaultOrgId: async () => ORG }, + }); + + expect(result).toEqual({ seeded: 0, skipped: 2 }); + expect(rowsOf(engine).map((r) => r.subject)).toEqual(['Admin original', 'Data-door wording']); + expect(warn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts index 278eaa66ec0..b0f455bd198 100644 --- a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts +++ b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts @@ -45,9 +45,18 @@ * boot-only bridge would leave a Studio save inert until the next restart — the * same bug, half-fixed. {@link upsertDeclaredEmailTemplate} is exported for the * live `metadata.subscribe('email_template', …)` path in EmailServicePlugin. + * + * ## What the boot sweep projects (#21785) + * The EFFECTIVE template — what the metadata door serves, a Studio overlay + * included — not the package's declaration. The live path already projects + * the overlay when the admin saves it, so a boot sweep reading the package + * layer reverted the sending row on every restart while `GET /meta` kept + * serving the admin's wording. See {@link readDeclared}. */ import type { IDataEngine } from '@objectstack/spec/contracts'; +import type { GetMetaItemsRequest, GetMetaItemsResponse } from '@objectstack/spec/api'; +import { stripReadDecorations } from '@objectstack/spec/kernel'; import { EmailTemplateDefinitionSchema, type EmailTemplateDefinition, @@ -102,9 +111,64 @@ function uid(prefix: string): string { } /** - * Read declared `email_template` items from the ObjectQL registry (where the - * manifest decomposition parks `stack.emailTemplates`), falling back to the - * metadata service. Both reads hand back the authoring document itself. + * The two kernel services the boot sweep reads the EFFECTIVE templates through + * (#21785). Both are optional: a host that registers no `protocol` has no + * metadata door, so nothing can overlay a declaration there and the registry + * read below is already the effective one. + */ +export interface EffectiveEmailTemplateSources { + /** The `protocol` service. `getMetaItems` is the layered list `GET /meta/email_template` serves. */ + protocol?: { getMetaItems(request: GetMetaItemsRequest): Promise }; + /** The `tenancy` service. `defaultOrgId()` is the organization an org-less read resolves in. */ + tenancy?: { defaultOrgId(): Promise }; +} + +/** {@link readDeclared}'s answer when the effective read did not happen. */ +const EFFECTIVE_READ_FAILED = Symbol('email-template-effective-read-failed'); + +/** + * Read the `email_template` items the boot sweep projects: the EFFECTIVE + * items, as the metadata door serves them, when a `protocol` is registered; + * otherwise the declared items from the ObjectQL registry (where the manifest + * decomposition parks `stack.emailTemplates`), falling back to the metadata + * service. Every read hands back the authoring document itself. + * + * ## [#21785] Why the effective read, and in which organization + * + * The registry holds the package's declaration and only the ENV-WIDE overlays + * boot hydration (`loadMetaFromDb`) registers. `email_template` is + * `allowOrgOverride: true`, so an admin saving through `PUT /meta` with an + * active organization — every Studio save on a `single`-posture deployment, + * where the Default Organization is bootstrapped — writes an ORG-SCOPED + * overlay, which hydration deliberately leaves out of the process-wide + * registry. The live path projected that overlay into the sending row at save + * time; this sweep then read the package layer and wrote the package wording + * back on every boot, while `GET /meta/email_template/:name` kept serving the + * admin's wording. Measured on the showcase before the fix: the env-wide + * overlay survived (the registry lists it after the package entry), the + * org-scoped one reverted. + * + * So the sweep reads what the door reads — `protocol.getMetaItems`, the + * layered list (org overlay over env-wide overlay over package), one item per + * `(name, locale)` slot — and resolves the organization the way every other + * org-less reader of org-overridable metadata does: `tenancy.defaultOrgId()`, + * as the anonymous form doors read a form (`@objectstack/rest`). That answers + * the Default Organization under `single` (ADR-0131: the organization IS the + * environment there) and `null` whenever a walled posture was requested, where + * the read is env-wide and no plant's overlay becomes everyone's mail. The + * sending row stays org-agnostic: template resolution keys on + * `(name, locale)` only, and per-organization template rows are a capability + * no ruling has opened. + * + * The served items carry read decorations (`_diagnostics`) the strict schema + * refuses, so each is passed through the shared `stripReadDecorations` — the + * same treatment the plugin's single-item effective read applies. + * + * A failed effective read is NOT answered from the registry. The registry + * holds the package wording, so falling back would revert every overlay + * projection on a transient storage error — this defect again, by a second + * route. It answers {@link EFFECTIVE_READ_FAILED} and the sweep projects + * nothing; every row keeps its last projection until the next boot. * * ## [#8378] Why there is no `i?.content ?? i` here any more * @@ -150,7 +214,30 @@ function uid(prefix: string): string { * then died at the `filter(Boolean)` below — the template was dropped with * no warning, no count, nothing (the ADR-0078 silent-loss shape). */ -function readDeclared(engine: any, metadataService: any, type: string): any[] { +async function readDeclared( + engine: any, + metadataService: any, + type: string, + sources: EffectiveEmailTemplateSources | undefined, + logger: Logger | undefined, +): Promise { + const protocol = sources?.protocol; + if (typeof protocol?.getMetaItems === 'function') { + try { + const organizationId = typeof sources?.tenancy?.defaultOrgId === 'function' + ? await sources.tenancy.defaultOrgId() + : null; + const listed = await protocol.getMetaItems({ type, ...(organizationId ? { organizationId } : {}) }); + return listed.items.filter(Boolean).map(stripReadDecorations); + } catch (err: any) { + logger?.warn?.( + '[email] effective email-template read failed — no declared template re-materialized this boot; ' + + 'every sys_email_template row keeps its last projection', + { error: err?.message ?? String(err) }, + ); + return EFFECTIVE_READ_FAILED; + } + } try { const reg = engine?._registry; if (reg?.listItems) { @@ -272,17 +359,20 @@ export async function deactivateDeclaredEmailTemplate( } /** - * Materialize every declared email template into `sys_email_template`. - * Idempotent and safe to run on every boot. + * Materialize every declared email template into `sys_email_template`, as the + * metadata door serves it (an overlay of the declaration included) when + * `sources.protocol` is given — see {@link readDeclared}. Idempotent and safe + * to run on every boot. */ export async function bootstrapDeclaredEmailTemplates( engine: IDataEngine, metadataService: any, logger?: Logger, object = EMAIL_TEMPLATE_OBJECT, + sources?: EffectiveEmailTemplateSources, ): Promise { - const declared = readDeclared(engine, metadataService, 'email_template'); - if (declared.length === 0) return { seeded: 0, skipped: 0 }; + const declared = await readDeclared(engine, metadataService, 'email_template', sources, logger); + if (declared === EFFECTIVE_READ_FAILED || declared.length === 0) return { seeded: 0, skipped: 0 }; let seeded = 0; let skipped = 0; diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index d5293255349..597b462669e 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -40,6 +40,7 @@ import { upsertDeclaredEmailTemplate, deactivateDeclaredEmailTemplate, mapTemplateToRow, + type EffectiveEmailTemplateSources, } from './bootstrap-declared-email-templates.js'; import { bindEmailTemplateProvenanceStamp, @@ -1091,8 +1092,17 @@ export class EmailServicePlugin implements Plugin { let metadataService: IMetadataService | undefined; try { metadataService = ctx.getService('metadata'); } catch { /* optional */ } + // [#21785] The sweep projects the EFFECTIVE template — what `GET /meta` + // serves, an org-scoped Studio overlay included — through the protocol's + // layered list, read in `tenancy.defaultOrgId()`'s organization. Both are + // optional: a host without them has no metadata door to overlay through. + let protocol: EffectiveEmailTemplateSources['protocol']; + try { protocol = ctx.getService('protocol'); } catch { /* optional */ } + let tenancy: EffectiveEmailTemplateSources['tenancy']; + try { tenancy = ctx.getService('tenancy'); } catch { /* optional */ } + try { - await bootstrapDeclaredEmailTemplates(engine, metadataService, ctx.logger as any); + await bootstrapDeclaredEmailTemplates(engine, metadataService, ctx.logger as any, undefined, { protocol, tenancy }); } catch (err: any) { ctx.logger.warn( 'EmailServicePlugin: declared email-template bootstrap failed (built-in templates still serve): ' diff --git a/packages/plugins/plugin-email/src/index.ts b/packages/plugins/plugin-email/src/index.ts index 50aca8df53b..852baf16e18 100644 --- a/packages/plugins/plugin-email/src/index.ts +++ b/packages/plugins/plugin-email/src/index.ts @@ -105,6 +105,7 @@ export { mapTemplateToRow, EMAIL_TEMPLATE_OBJECT, type BootstrapDeclaredEmailTemplatesResult, + type EffectiveEmailTemplateSources, } from './bootstrap-declared-email-templates.js'; export { sweepStrandedOutbox, diff --git a/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts b/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts new file mode 100644 index 00000000000..063fdce697a --- /dev/null +++ b/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#21785] An email template edited through the METADATA door keeps the +// admin's wording in the row the mailer sends from, across a cold boot on one +// database file — over the real showcase composition. +// +// ## What was broken +// +// `PUT /api/v1/meta/email_template/:name` (the Studio editor's door) stores an +// overlay, and the live projector writes its wording into `sys_email_template` +// at once. On the next boot the declared-template sweep re-projected the +// PACKAGE wording over that row, while `GET /meta` kept serving the admin's — +// the metadata the admin sees and the row the mailer sends diverged silently. +// Measured on `origin/main` before the fix, through this file's own steps: the +// row read `✅ Task done: {{title}}` after the restart. +// +// ## Why `orgContext: true` +// +// It is the shape the defect lives in. A real deployment bootstraps the +// Default Organization (`autoDefaultOrganization` is plugin-auth's default), +// so the admin's save lands as an ORG-SCOPED overlay, which boot hydration +// leaves out of the registry the sweep used to read. The harness default is an +// org-less admin, whose save lands env-wide — and that shape kept the admin's +// wording before the fix as well (measured), so a pin booted that way would +// pass against the defect. The first case asserts the overlay really is +// org-scoped, so this file cannot drift into the vacuous shape unnoticed. +// +// ## What each case pins +// +// - the metadata-door edit is org-scoped and projected at once (preconditions); +// - after a cold boot the sending row, `GET /meta` and a real `sendTemplate` +// all carry the admin's wording; +// - control: a data-door edit (`customized: true`) still survives the next +// cold boot — seed-not-clobber is unchanged, and the overlay projection does +// not override a row the admin edited directly. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { EmailServicePlugin } from '@objectstack/plugin-email'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ +const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); +const SYS = { isSystem: true, positions: [], permissions: [] }; + +/** The one template the showcase package declares (`com.example.showcase`). */ +const NAME = 'showcase_task_done_email'; +const PACKAGE_SUBJECT = '✅ Task done: {{title}}'; +const ADMIN_SUBJECT = 'Done (reworded by the admin): {{title}}'; +const DATA_DOOR_SUBJECT = 'Done (edited on the record): {{title}}'; + +/** What reached the wire — `sendTemplate` returns a delivery result, not the rendered message. */ +const sent: Array<{ subject?: string }> = []; +const emailPlugin = () => new EmailServicePlugin({ + transport: { async send(message: { subject?: string }) { sent.push({ subject: message.subject }); return { messageId: `captured-${sent.length}` }; } } as never, + defaultFrom: { address: 'no-reply@example.test', name: 'Overlay Fixture' }, +}); + +const boot = (databaseFile: string) => + bootStack(showcaseStack, { databaseFile, orgContext: true, extraPlugins: [emailPlugin()] }); + +describe('[#21785] a metadata-door email template edit survives a cold boot (showcase)', () => { + let prevCwd: string; + let dir: string; + let dbFile: string; + let stack: VerifyStack | undefined; + let token: string; + + const call = async (method: string, path: string, body?: unknown) => { + const res = await stack!.apiAs(token, method, path, body); + return { status: res.status, json: (await res.json().catch(() => ({}))) as any }; + }; + /** The sending rows for the template — what `sendTemplate` resolves from. */ + const sendingRows = async () => { + const ql: any = await stack!.kernel.getServiceAsync('objectql'); + return ql.find('sys_email_template', { where: { name: NAME }, context: SYS }); + }; + /** `GET /meta/email_template/:name` — the served document, unwrapped from its envelope. */ + const metaSubject = async () => { + const read = await call('GET', `/meta/email_template/${NAME}`); + const doc = read.json?.data ?? read.json?.item ?? read.json; + return { status: read.status, subject: (doc?.item ?? doc)?.subject }; + }; + const restart = async () => { + await stack!.stop(); + stack = await boot(dbFile); + token = await stack.signIn(); + }; + + beforeAll(async () => { + prevCwd = process.cwd(); + process.chdir(SHOWCASE_DIR); + dir = mkdtempSync(join(tmpdir(), 'dogfood-21785-')); + dbFile = join(dir, 'showcase.db'); + stack = await boot(dbFile); + token = await stack.signIn(); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + if (prevCwd) process.chdir(prevCwd); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('precondition: the metadata-door edit lands org-scoped and is projected into the sending row at once', async () => { + const [seeded] = await sendingRows(); + expect({ subject: seeded?.subject, managed_by: seeded?.managed_by, customized: seeded?.customized }) + .toEqual({ subject: PACKAGE_SUBJECT, managed_by: 'package', customized: false }); + + const served = await call('GET', `/meta/email_template/${NAME}`); + expect(served.status).toBe(200); + const doc = served.json?.data ?? served.json?.item ?? served.json; + const body: Record = {}; + for (const [k, v] of Object.entries((doc?.item ?? doc) as Record)) { + if (!k.startsWith('_')) body[k] = v; + } + const put = await call('PUT', `/meta/email_template/${NAME}`, { ...body, subject: ADMIN_SUBJECT }); + expect(put.status, JSON.stringify(put.json)).toBe(200); + expect(put.json?.projectionApplied).toEqual({ success: true }); + + // ⛔ Without this the restart below proves nothing: an env-wide overlay + // survived the restart before the fix too. + const ql: any = await stack!.kernel.getServiceAsync('objectql'); + const stored = await ql.find('sys_metadata', { where: { type: 'email_template', name: NAME }, context: SYS }); + expect(stored).toHaveLength(1); + expect(stored[0].organization_id, 'the overlay is org-scoped').toEqual(expect.any(String)); + + expect((await sendingRows()).map((r: any) => r.subject)).toEqual([ADMIN_SUBJECT]); + }); + + it('after a cold boot the sending row, the metadata door and the rendered mail all carry the admin\'s wording', async () => { + await restart(); + + const rows = await sendingRows(); + expect(rows.map((r: any) => ({ subject: r.subject, customized: r.customized }))) + .toEqual([{ subject: ADMIN_SUBJECT, customized: false }]); + expect(await metaSubject()).toEqual({ status: 200, subject: ADMIN_SUBJECT }); + + const email: any = await stack!.kernel.getServiceAsync('email'); + const result = await email.sendTemplate({ + to: 'someone@example.com', + template: NAME, + data: { title: 'Ship it', project: 'Apollo' }, + }); + expect(result.status, `sendTemplate failed: ${result.error ?? ''}`).not.toBe('failed'); + expect(sent.at(-1)?.subject).toBe('Done (reworded by the admin): Ship it'); + }, 180_000); + + it('control: a data-door edit is stamped customized and survives the next cold boot', async () => { + const [row] = await sendingRows(); + const patched = await call('PATCH', `/data/sys_email_template/${row.id}`, { subject: DATA_DOOR_SUBJECT }); + expect(patched.status, JSON.stringify(patched.json)).toBe(200); + const [edited] = await sendingRows(); + expect({ subject: edited.subject, customized: edited.customized }) + .toEqual({ subject: DATA_DOOR_SUBJECT, customized: true }); + + await restart(); + + expect((await sendingRows()).map((r: any) => ({ subject: r.subject, customized: r.customized }))) + .toEqual([{ subject: DATA_DOOR_SUBJECT, customized: true }]); + }, 180_000); +}); From 94479dd123ae119c888507fa3b2f46748f7502f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:33:59 +0000 Subject: [PATCH 2/4] chore(changeset): plugin-email patch for the effective-template boot sweep Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- .../21785-email-template-overlay-survives-boot.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/21785-email-template-overlay-survives-boot.md diff --git a/.changeset/21785-email-template-overlay-survives-boot.md b/.changeset/21785-email-template-overlay-survives-boot.md new file mode 100644 index 00000000000..a588ebe1705 --- /dev/null +++ b/.changeset/21785-email-template-overlay-survives-boot.md @@ -0,0 +1,13 @@ +--- +"@objectstack/plugin-email": patch +--- + +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. + +Clause-②: no + +- 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. +- `bootstrapDeclaredEmailTemplates` now projects the effective template, the layered list `protocol.getMetaItems` serves, read in `tenancy.defaultOrgId()`'s organization. That is the Default Organization under the `single` posture, and env-wide (no organization) whenever a walled posture is requested. A host without a `protocol` service reads the registry as before. +- 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. +- 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. +- `bootstrapDeclaredEmailTemplates` takes an optional fifth argument, `{ protocol, tenancy }` (`EffectiveEmailTemplateSources`, now exported). Existing callers are unaffected. From 7293048b419be27ae6c0bc0e9fbefcb368e286dc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:09:03 +0000 Subject: [PATCH 3/4] fix(plugin-email): keep the published sweep signature; the plugin calls an internal effective sweep The effective-template boot sweep moves to a 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, reading the registry as before. The package entry exports no new symbol: EffectiveEmailTemplateSources is no longer re-exported. One sweep body, two entry points. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- ...85-email-template-overlay-survives-boot.md | 4 +- ...bootstrap-declared-email-templates.test.ts | 17 +++---- .../src/bootstrap-declared-email-templates.ts | 44 ++++++++++++++++--- .../plugins/plugin-email/src/email-plugin.ts | 4 +- packages/plugins/plugin-email/src/index.ts | 1 - 5 files changed, 50 insertions(+), 20 deletions(-) diff --git a/.changeset/21785-email-template-overlay-survives-boot.md b/.changeset/21785-email-template-overlay-survives-boot.md index a588ebe1705..2f499999aec 100644 --- a/.changeset/21785-email-template-overlay-survives-boot.md +++ b/.changeset/21785-email-template-overlay-survives-boot.md @@ -7,7 +7,7 @@ An email template edited through `PUT /api/v1/meta/email_template/:name` (the St Clause-②: no - 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. -- `bootstrapDeclaredEmailTemplates` now projects the effective template, the layered list `protocol.getMetaItems` serves, read in `tenancy.defaultOrgId()`'s organization. That is the Default Organization under the `single` posture, and env-wide (no organization) whenever a walled posture is requested. A host without a `protocol` service reads the registry as before. +- `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. - 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. - 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. -- `bootstrapDeclaredEmailTemplates` takes an optional fifth argument, `{ protocol, tenancy }` (`EffectiveEmailTemplateSources`, now exported). Existing callers are unaffected. +- 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. diff --git a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts index 1d44a19bba5..5108328b6dd 100644 --- a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts +++ b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts @@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest'; import { bootstrapDeclaredEmailTemplates, + bootstrapEffectiveEmailTemplates, upsertDeclaredEmailTemplate, deactivateDeclaredEmailTemplate, mapTemplateToRow, @@ -494,7 +495,7 @@ describe('bootstrapDeclaredEmailTemplates — the effective template (#21785)', { organizationId: ORG, items: [declaredTemplate({ subject: OVERLAY_WORDING })] }, ); - const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined, undefined, undefined, { + const result = await bootstrapEffectiveEmailTemplates(engine as any, undefined, { protocol, tenancy: { defaultOrgId: async () => ORG }, }); @@ -512,13 +513,13 @@ describe('bootstrapDeclaredEmailTemplates — the effective template (#21785)', { organizationId: ORG, items: [declaredTemplate({ subject: OVERLAY_WORDING })] }, ); - await bootstrapDeclaredEmailTemplates(engine as any, undefined, undefined, undefined, { + await bootstrapEffectiveEmailTemplates(engine as any, undefined, { protocol, tenancy: { defaultOrgId: async () => null }, }); - // No organization on the request: one plant's overlay is never what the - // org-agnostic sending row carries. + // No organization on the request: the tenancy contract named none, so the + // read is env-wide and the declaration is what the row carries. expect(protocol.requests).toEqual([{ type: 'email_template' }]); expect(rowsOf(engine)[0].subject).toBe(PACKAGE_WORDING); }); @@ -533,10 +534,10 @@ describe('bootstrapDeclaredEmailTemplates — the effective template (#21785)', async getMetaItems(): Promise { throw new Error('sys_metadata read failed'); }, }; - const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined, { warn }, undefined, { + const result = await bootstrapEffectiveEmailTemplates(engine as any, undefined, { protocol, tenancy: { defaultOrgId: async () => ORG }, - }); + }, { warn }); expect(result).toEqual({ seeded: 0, skipped: 0 }); expect(rowsOf(engine)[0].subject).toBe(OVERLAY_WORDING); @@ -562,10 +563,10 @@ describe('bootstrapDeclaredEmailTemplates — the effective template (#21785)', ], }); - const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined, { warn }, undefined, { + const result = await bootstrapEffectiveEmailTemplates(engine as any, undefined, { protocol, tenancy: { defaultOrgId: async () => ORG }, - }); + }, { warn }); expect(result).toEqual({ seeded: 0, skipped: 2 }); expect(rowsOf(engine).map((r) => r.subject)).toEqual(['Admin original', 'Data-door wording']); diff --git a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts index b0f455bd198..f73fb29e3b3 100644 --- a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts +++ b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts @@ -115,6 +115,10 @@ function uid(prefix: string): string { * (#21785). Both are optional: a host that registers no `protocol` has no * metadata door, so nothing can overlay a declaration there and the registry * read below is already the effective one. + * + * Module-internal: exported for EmailServicePlugin's boot wiring only and + * deliberately NOT re-exported from the package entry — see + * {@link bootstrapEffectiveEmailTemplates}. */ export interface EffectiveEmailTemplateSources { /** The `protocol` service. `getMetaItems` is the layered list `GET /meta/email_template` serves. */ @@ -154,9 +158,9 @@ const EFFECTIVE_READ_FAILED = Symbol('email-template-effective-read-failed'); * org-less reader of org-overridable metadata does: `tenancy.defaultOrgId()`, * as the anonymous form doors read a form (`@objectstack/rest`). That answers * the Default Organization under `single` (ADR-0131: the organization IS the - * environment there) and `null` whenever a walled posture was requested, where - * the read is env-wide and no plant's overlay becomes everyone's mail. The - * sending row stays org-agnostic: template resolution keys on + * environment there) and `null` whenever a walled posture was requested (the + * tenancy contract never guesses an organization there), where the read is + * env-wide. The sending row stays org-agnostic: template resolution keys on * `(name, locale)` only, and per-organization template rows are a capability * no ruling has opened. * @@ -359,17 +363,43 @@ export async function deactivateDeclaredEmailTemplate( } /** - * Materialize every declared email template into `sys_email_template`, as the - * metadata door serves it (an overlay of the declaration included) when - * `sources.protocol` is given — see {@link readDeclared}. Idempotent and safe + * Materialize every declared email template into `sys_email_template` from the + * ObjectQL registry (falling back to the metadata service). Idempotent and safe * to run on every boot. + * + * The published signature, unchanged. It delegates to + * {@link bootstrapEffectiveEmailTemplates} with no sources, so an external + * caller reads the registry exactly as before — the declarations plus the + * env-wide overlays boot hydration registered. The plugin's own boot wiring + * calls the effective form directly. */ export async function bootstrapDeclaredEmailTemplates( engine: IDataEngine, metadataService: any, logger?: Logger, object = EMAIL_TEMPLATE_OBJECT, - sources?: EffectiveEmailTemplateSources, +): Promise { + return bootstrapEffectiveEmailTemplates(engine, metadataService, undefined, logger, object); +} + +/** + * [#21785] The boot sweep EmailServicePlugin runs: materialize every + * `email_template` into `sys_email_template` as the metadata door serves it — + * an overlay of the declaration included — when `sources.protocol` is given, + * and from the registry otherwise (see {@link readDeclared}). The one sweep + * body; {@link bootstrapDeclaredEmailTemplates} is this with no sources. + * + * Module-internal: NOT re-exported from the package entry (`src/index.ts`), + * and the package's `exports` map names only that entry, so this function and + * {@link EffectiveEmailTemplateSources} add nothing to the published surface. + * No caller outside this package needs them. + */ +export async function bootstrapEffectiveEmailTemplates( + engine: IDataEngine, + metadataService: any, + sources: EffectiveEmailTemplateSources | undefined, + logger?: Logger, + object = EMAIL_TEMPLATE_OBJECT, ): Promise { const declared = await readDeclared(engine, metadataService, 'email_template', sources, logger); if (declared === EFFECTIVE_READ_FAILED || declared.length === 0) return { seeded: 0, skipped: 0 }; diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index 597b462669e..348e0ef5c87 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -36,7 +36,7 @@ import type { SettingsUnsubscribe, } from '@objectstack/spec/system'; import { - bootstrapDeclaredEmailTemplates, + bootstrapEffectiveEmailTemplates, upsertDeclaredEmailTemplate, deactivateDeclaredEmailTemplate, mapTemplateToRow, @@ -1102,7 +1102,7 @@ export class EmailServicePlugin implements Plugin { try { tenancy = ctx.getService('tenancy'); } catch { /* optional */ } try { - await bootstrapDeclaredEmailTemplates(engine, metadataService, ctx.logger as any, undefined, { protocol, tenancy }); + await bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol, tenancy }, ctx.logger as any); } catch (err: any) { ctx.logger.warn( 'EmailServicePlugin: declared email-template bootstrap failed (built-in templates still serve): ' diff --git a/packages/plugins/plugin-email/src/index.ts b/packages/plugins/plugin-email/src/index.ts index 852baf16e18..50aca8df53b 100644 --- a/packages/plugins/plugin-email/src/index.ts +++ b/packages/plugins/plugin-email/src/index.ts @@ -105,7 +105,6 @@ export { mapTemplateToRow, EMAIL_TEMPLATE_OBJECT, type BootstrapDeclaredEmailTemplatesResult, - type EffectiveEmailTemplateSources, } from './bootstrap-declared-email-templates.js'; export { sweepStrandedOutbox, From bae6303d08b2d7fe3b257ebbd9ceac2ee9d075b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:42:03 +0000 Subject: [PATCH 4/4] docs(plugin-email): keep the internal sweep's name out of the published docblock The exported bootstrapDeclaredEmailTemplates docblock is carried into the published index.d.ts, where a link to the module-internal function named an unexported symbol. The delegation note moves into the function body. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- .../src/bootstrap-declared-email-templates.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts index f73fb29e3b3..ea634fbebbe 100644 --- a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts +++ b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts @@ -367,11 +367,9 @@ export async function deactivateDeclaredEmailTemplate( * ObjectQL registry (falling back to the metadata service). Idempotent and safe * to run on every boot. * - * The published signature, unchanged. It delegates to - * {@link bootstrapEffectiveEmailTemplates} with no sources, so an external - * caller reads the registry exactly as before — the declarations plus the - * env-wide overlays boot hydration registered. The plugin's own boot wiring - * calls the effective form directly. + * The published signature, unchanged: an external caller reads the registry + * exactly as before — the declarations plus the env-wide overlays boot + * hydration registered. */ export async function bootstrapDeclaredEmailTemplates( engine: IDataEngine, @@ -379,6 +377,10 @@ export async function bootstrapDeclaredEmailTemplates( logger?: Logger, object = EMAIL_TEMPLATE_OBJECT, ): Promise { + // [#21785] The one sweep body, with no sources. The plugin's own boot wiring + // calls the effective form directly. Said here, not in the docblock above: + // the docblock ships in the published `.d.ts`, and the module-internal name + // is not part of that surface. return bootstrapEffectiveEmailTemplates(engine, metadataService, undefined, logger, object); }