Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/31677-package-scoped-email-templates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/plugin-email': patch
---

feat: an email template's name resolves by (package, name) through the shared package-scoped resolver (ADR-0150 D3/D4)

Clause-②: no
Metadata-protocol: none

Which email template a send or a render uses is now decided by `resolvePackageScopedName` from `@objectstack/objectql`, never by list order. A send names no package (`SendTemplateInput` carries none), so the loader asks as a caller with no package: the environment's own template, else the unique package holder (the email service's built-in templates rank below an app's, and a dependent package's added locale is no second holder of its base's name), else a refusal that names every candidate. Within the answer, the per-locale pick is unchanged.

Who is affected: a deployment that held one email template name in two packages from before phase 1's one-holder refusal (the cold boot warns about it as a "Bare-name conflict at boot"). Its sends were rendered from whichever package's bundle listed first; they now fail with a `409 RESOURCE_CONFLICT` naming both packages. Rename one of the two (the boot warning names the rename). First-party count of such clashes: 0. Every other name renders exactly as before, since phase 1 still refuses a new clash at install.

`@objectstack/plugin-email` now depends on `@objectstack/objectql` at runtime (it was a dev dependency).
2 changes: 1 addition & 1 deletion packages/plugins/plugin-email/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/formula": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/platform-objects": "workspace:*",
"@objectstack/spec": "workspace:*",
"nodemailer": "^10.1.4"
},
"devDependencies": {
"@objectstack/objectql": "workspace:*",
"@objectstack/service-queue": "workspace:*",
"@objectstack/service-settings": "workspace:*",
"@types/node": "^26.6.5",
Expand Down
44 changes: 44 additions & 0 deletions packages/plugins/plugin-email/src/template-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@
*/

import { describe, expect, it } from 'vitest';
import { SchemaRegistry } from '@objectstack/objectql';
import {
BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID,
EmailTemplateAmbiguousError,
createRegistryEmailTemplateLoader,
} from './template-loader.js';

Expand Down Expand Up @@ -284,3 +286,45 @@ describe('createRegistryEmailTemplateLoader — a malformed item is refused, nev
await expect(loader.load('ops.digest', 'en-US')).rejects.toThrow(/ops\.digest/);
});
});

describe('createRegistryEmailTemplateLoader — the name resolves by (package, name) (ADR-0150 D3)', () => {
const OTHER_PACKAGE = 'com.example.other';

/** A real registry: each id installed as a code package, shipping `items` in its composite slot. */
function registryShipping(shipped: Array<[string, AnyRecord]>): SchemaRegistry {
const reg = new SchemaRegistry();
reg.logLevel = 'silent';
for (const [id, item] of shipped) {
if (!reg.getPackage(id)) {
reg.installPackage({ id, name: id, version: '1.0.0', type: 'app', namespace: id.split('.').pop() } as never, 'code');
}
const { _packageId: _drop, ...body } = item;
reg.registerItem('email_template', body, 'name' as never, id);
}
return reg;
}

it('one package holder beside the built-in: resolves, and the layered list answers as before', async () => {
const reg = registryShipping([[BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID, builtin()], [APP_PACKAGE, declared()]]);
const loader = createRegistryEmailTemplateLoader({ protocol: layeredProtocol([builtin(), declared()]), registry: reg });
expect((await loader.load('auth.password_reset', 'en-US'))?.subject).toBe(AUTHORED);
});

it('two package holders: the send is refused with the 409 conflict naming both — never the first listed', async () => {
const other = declared({ subject: 'Other wording', _packageId: OTHER_PACKAGE });
const reg = registryShipping([[APP_PACKAGE, declared()], [OTHER_PACKAGE, other]]);
const loader = createRegistryEmailTemplateLoader({ protocol: layeredProtocol([declared(), other]), registry: reg });

const err = await loader.load('auth.password_reset', 'en-US').then(() => undefined, (e: unknown) => e);
expect(err).toBeInstanceOf(EmailTemplateAmbiguousError);
expect(err).toMatchObject({ code: 'RESOURCE_CONFLICT', status: 409, template: 'auth.password_reset' });
expect((err as EmailTemplateAmbiguousError).candidates).toEqual([
{ kind: 'package', packageId: APP_PACKAGE },
{ kind: 'package', packageId: OTHER_PACKAGE },
]);
expect((err as Error).message).toContain(`"${APP_PACKAGE}"`);
expect((err as Error).message).toContain(`"${OTHER_PACKAGE}"`);
// No locale escapes it: the name is refused before any bundle is read.
await expect(loader.load('auth.password_reset', undefined)).rejects.toBeInstanceOf(EmailTemplateAmbiguousError);
});
});
75 changes: 69 additions & 6 deletions packages/plugins/plugin-email/src/template-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,28 @@
* Language-only prefix matching (`zh` → `zh-CN`) is deliberately NOT here: no
* contract declares it, and inventing it would put a resolution rule in the
* code that no spec, doc or form describes.
*
* ## The name rule: `(package, name)` (ADR-0150 D3/D4)
*
* An email template is addressed by `(package, name)`. Before a bundle is
* read, the name is resolved by the ONE package-scoped resolver
* (`resolvePackageScopedName`, `@objectstack/objectql`) over the ObjectQL
* registry, as a caller with no package: `SendTemplateInput` names no
* package, so every send is context-free — the environment's own item, else
* the unique package holder (the built-ins rank below it, ADR-0150 D3 (b)),
* else a refusal naming every candidate, which fails the send
* ({@link EmailTemplateAmbiguousError}). ⛔ Never a first-listed pick. While
* phase 1's one-holder refusal stands (`email_template` is one of its types)
* no two packages hold a name, so every name resolves as before, and the
* layered list below answers the bundle exactly as it did.
*/

import {
resolvePackageScopedName,
type PackageScopedHolder,
type PackageScopedRefusal,
type PackageScopedRegistry,
} from '@objectstack/objectql';
import { stripReadDecorations } from '@objectstack/spec/kernel';
import { EmailTemplateDefinitionSchema, type EmailTemplateDefinitionParsed } from '@objectstack/spec/system';
import { DEFAULT_TEMPLATE_LOCALE, TEMPLATE_BUNDLE_LOCALES, type EmailTemplateRow, type TemplateLoader, type TemplateBundleLocales } from './email-service.js';
Expand All @@ -42,7 +62,7 @@ import { DEFAULT_TEMPLATE_LOCALE, TEMPLATE_BUNDLE_LOCALES, type EmailTemplateRow
export const BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID = 'com.objectstack.service.email';

/** The metadata type the loader reads. */
const EMAIL_TEMPLATE_TYPE = 'email_template';
const EMAIL_TEMPLATE_TYPE = 'email_template' as const;

/**
* The reads {@link createRegistryEmailTemplateLoader} resolves through, looked
Expand All @@ -58,12 +78,47 @@ export interface EmailTemplateRegistrySources {
*/
protocol?: { getMetaItemsForExecution(request: { type: string }): Promise<{ items?: unknown[] }> };
/**
* The ObjectQL `SchemaRegistry`. Read ONLY when no `protocol` is
* registered: such a host has no metadata door, so no environment item can
* exist and the code declarations the registry holds are already the
* effective set.
* The ObjectQL `SchemaRegistry`. Its items are read ONLY when no `protocol`
* is registered: such a host has no metadata door, so no environment item
* can exist and the code declarations the registry holds are already the
* effective set. When it carries the package-scoped slice
* ({@link PackageScopedRegistry}), every name is resolved over it first
* (module doc, "The name rule").
*/
registry?: { listItems(type: string): unknown[] };
registry?: { listItems(type: string): unknown[] } & Partial<PackageScopedRegistry>;
}

/**
* [ADR-0150 D3, epic #31527 red line 2] A template name that resolves to no
* single template: two packages ship it, and nothing picks one. `code` +
* `status` are the conflict envelope (409 `RESOURCE_CONFLICT`, the status
* ADR-0150 D5 gives an ambiguous bare name); `candidates` names every holder,
* and so does the message.
*/
export class EmailTemplateAmbiguousError extends Error {
readonly code = 'RESOURCE_CONFLICT';
readonly status = 409;
/** The template name. */
readonly template: string;
/** Every holder of the name, in the resolver's order. */
readonly candidates: readonly PackageScopedHolder[];
constructor(refusal: PackageScopedRefusal) {
super(`email template '${refusal.name}' cannot be sent: ${refusal.message}`);
this.name = 'EmailTemplateAmbiguousError';
this.template = refusal.name;
this.candidates = refusal.candidates;
}
}

/** The registry, when it carries the slice the package-scoped resolver reads. */
function packageScopedRegistryOf(registry: EmailTemplateRegistrySources['registry']): PackageScopedRegistry | undefined {
return typeof registry?.packageScopedHoldersOf === 'function'
&& typeof registry.getPackageScopedItem === 'function'
&& typeof registry.getEnvironmentScopedItem === 'function'
&& typeof registry.getPackage === 'function'
&& typeof registry.getAllPackages === 'function'
? (registry as PackageScopedRegistry)
: undefined;
}

/** An item's locale as the bundle key reads it: the schema default when it declares none. */
Expand Down Expand Up @@ -138,6 +193,14 @@ export function createRegistryEmailTemplateLoader(

const readBundle = async (name: string): Promise<Record<string, unknown>[]> => {
const { protocol, registry } = resolveSources() ?? {};
// [ADR-0150 D3] The name first: a send names no package, so it resolves
// as a caller with no package. A refusal fails the send, naming every
// candidate; any other answer leaves the bundle to the read below.
const scoped = packageScopedRegistryOf(registry);
if (scoped) {
const resolved = resolvePackageScopedName(scoped, { type: EMAIL_TEMPLATE_TYPE, name });
if (resolved.status === 'refused') throw new EmailTemplateAmbiguousError(resolved);
}
let items: unknown[];
if (typeof protocol?.getMetaItemsForExecution === 'function') {
const listed = await protocol.getMetaItemsForExecution({ type: EMAIL_TEMPLATE_TYPE });
Expand Down
25 changes: 23 additions & 2 deletions packages/plugins/plugin-email/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,25 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

// This package needs nothing beyond the shared preset; see its header.
export { default } from '../../../vitest.preset.mts';
import { defineConfig } from 'vitest/config';

// The shared preset's one setting, plus this package's own.
// `check-console-intercept-disarm.mjs` reads this file's source, so the
// setting is spelled here instead of merged in from `vitest.preset.mts`.
export default defineConfig({
test: {
// A late console.* must not redden a green suite (#10374): vitest's worker
// forwards console output over RPC and discards the promise, and a write
// landing after teardown's rpcDone() snapshot is rejected into an unhandled
// error — a fully green run that exits 1. Disarming removes the mechanism.
// Mechanism + measured costs: examples/app-showcase/vitest.config.ts.
// Enforced repo-wide by scripts/check-console-intercept-disarm.mjs.
disableConsoleIntercept: true,
// #13517: quiet the registry's per-item registration chatter — the
// engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped
// default. Enforced by scripts/check-registry-log-declared.mjs, which
// reads this suite as engine-booting since `template-loader.test.ts`
// resolves email template names through a real `SchemaRegistry`
// (ADR-0150 D3).
env: { OS_REGISTRY_LOG: 'warn' },
},
});
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading