|
45 | 45 | * boot-only bridge would leave a Studio save inert until the next restart — the |
46 | 46 | * same bug, half-fixed. {@link upsertDeclaredEmailTemplate} is exported for the |
47 | 47 | * 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}. |
48 | 55 | */ |
49 | 56 |
|
50 | 57 | import type { IDataEngine } from '@objectstack/spec/contracts'; |
| 58 | +import type { GetMetaItemsRequest, GetMetaItemsResponse } from '@objectstack/spec/api'; |
| 59 | +import { stripReadDecorations } from '@objectstack/spec/kernel'; |
51 | 60 | import { |
52 | 61 | EmailTemplateDefinitionSchema, |
53 | 62 | type EmailTemplateDefinition, |
@@ -102,9 +111,68 @@ function uid(prefix: string): string { |
102 | 111 | } |
103 | 112 |
|
104 | 113 | /** |
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. |
108 | 176 | * |
109 | 177 | * ## [#8378] Why there is no `i?.content ?? i` here any more |
110 | 178 | * |
@@ -150,7 +218,30 @@ function uid(prefix: string): string { |
150 | 218 | * then died at the `filter(Boolean)` below — the template was dropped with |
151 | 219 | * no warning, no count, nothing (the ADR-0078 silent-loss shape). |
152 | 220 | */ |
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 | + } |
154 | 245 | try { |
155 | 246 | const reg = engine?._registry; |
156 | 247 | if (reg?.listItems) { |
@@ -272,17 +363,48 @@ export async function deactivateDeclaredEmailTemplate( |
272 | 363 | } |
273 | 364 |
|
274 | 365 | /** |
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. |
277 | 373 | */ |
278 | 374 | export async function bootstrapDeclaredEmailTemplates( |
279 | 375 | engine: IDataEngine, |
280 | 376 | metadataService: any, |
281 | 377 | logger?: Logger, |
282 | 378 | object = EMAIL_TEMPLATE_OBJECT, |
283 | 379 | ): 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 }; |
286 | 408 |
|
287 | 409 | let seeded = 0; |
288 | 410 | let skipped = 0; |
|
0 commit comments