Skip to content

Commit 289f727

Browse files
os-warrenclaude
andauthored
fix(app-showcase): author the task-done email at a locale the send ladder can reach (#10419)
* fix(app-showcase): author the task-done email at a locale the send ladder can reach `examples/app-showcase/src/system/emails/index.ts` declared `locale: 'en'` on a bare object literal. `sendTemplate`'s ladder is exact match -> `en-US` -> (no-locale calls only) the bundle's lowest tag, with deliberately no language-prefix matching, so `en` never satisfies `en-US`. Measured against the old declaration, through the real loader and the real `mapTemplateToRow` projection: loader.load(showcase_task_done_email, en-US) -> null sendTemplate locale='en-US' -> THREW TEMPLATE_NOT_FOUND: showcase_task_done_email (locale=en-US) sendTemplate no locale -> RESOLVED (rung 3, the lowest-tag fallback) `en-US` is the ladder's own second rung, so a row authored there is reachable from every call shape; any other tag is reachable from strictly fewer. The key is written out rather than left to the schema default because the example corpus is what gets copied. The literal is also now wrapped in `defineEmailTemplateDefinition(...)`, so `EmailTemplateDefinitionSchema.parse()` runs at authoring time instead of first at boot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx * fix(app-showcase): resolve plugin-email's TYPES from source too, not just its runtime The vitest source alias added alongside the locale fix closed the runtime half of the staleness exposure. The new devDependency has a second one on the TYPE path: unaliased, `@objectstack/plugin-email` contributes `packages/plugins/plugin-email/dist/*.d.ts` to this app's tsc program, so `tsc --noEmit` graded the declarations against whatever was last built rather than against the locale ladder in this checkout. Reproduced before the fix, on 7bfca1d: check-type-source-resolution FAILED ✗ @objectstack/example-showcase: NEW dist-resolved type import(s) since this entry was measured: @objectstack/plugin-email. and after: check-type-source-resolution OK — 76 packages with a tsconfig.json scanned; 51 registered as still resolving a workspace dep's types through `dist/`. The remedy is the `paths` rule the gate asks for, mirroring the existing `@objectstack/formula` entry; the shrink-only KNOWN_DIST_RESOLVED_TYPE_IMPORTS registry is untouched. Bare key, no `*` — a tsconfig `paths` key without a star is an exact match, while the `@objectstack/plugin-email*` spelling would fold every subpath onto one target and type-check green against the wrong module. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 91f303c commit 289f727

6 files changed

Lines changed: 190 additions & 8 deletions

File tree

examples/app-showcase/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"@objectstack/formula": "workspace:*",
4141
"@objectstack/objectql": "workspace:*",
4242
"@objectstack/plugin-approvals": "workspace:*",
43+
"@objectstack/plugin-email": "workspace:*",
4344
"@objectstack/service-automation": "workspace:*",
4445
"@objectstack/service-messaging": "workspace:*",
4546
"@playwright/test": "^1.62.1",
Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,49 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
/** Email template fired by the Task Completed flow. */
4-
export const TaskDoneEmail = {
3+
import { defineEmailTemplateDefinition } from '@objectstack/spec';
4+
5+
/**
6+
* Email template declared for the Task Completed flow.
7+
*
8+
* ## Why `locale` is `en-US`, and why it is spelled out
9+
*
10+
* `sendTemplate`'s ladder is **exact match → `en-US` → (no-locale calls only)
11+
* the bundle's lowest tag**, with deliberately no language-prefix matching:
12+
* `en` does not satisfy `en-US`. Because `en-US` is the ladder's own second
13+
* rung, a row authored at `en-US` is reachable from *every* call shape — an
14+
* explicit `en-US`, this app's `defaultLocale: 'en'` (which the notify path
15+
* passes as the recipient locale), and a call naming no locale at all. Any
16+
* other tag is reachable from strictly fewer: this row used to say `en`, which
17+
* made `sendTemplate({ locale: 'en-US' })` fail with `TEMPLATE_NOT_FOUND`.
18+
*
19+
* The key is written out rather than left to the schema default because the
20+
* example corpus is what gets copied: the tag is the bundle key a second
21+
* language row has to match, and `content/docs/automation/email-templates.mdx`
22+
* teaches authoring the tags your callers actually pass.
23+
*
24+
* ## Not wired to the flow yet
25+
*
26+
* `showcase_task_completed`'s notify node demonstrates the inline
27+
* `title`/`message` path, which the node schema makes mutually exclusive with
28+
* `template` — so referencing this template there is a substitution, not an
29+
* addition: it would cost the script node's `{summary}` its only consumer, and
30+
* that consumption is what the flow exists to demonstrate. Tracked as its own
31+
* decision in #10394 rather than smuggled in here.
32+
*/
33+
export const TaskDoneEmail = defineEmailTemplateDefinition({
534
name: 'showcase_task_done_email',
635
label: 'Task Done Notification',
7-
category: 'workflow' as const,
8-
locale: 'en',
36+
category: 'workflow',
37+
locale: 'en-US',
938
subject: '✅ Task done: {{title}}',
1039
bodyHtml: '<p>The task <strong>{{title}}</strong> on project {{project}} was marked done.</p>',
1140
bodyText: 'The task {{title}} on project {{project}} was marked done.',
1241
variables: [
13-
{ name: 'title', type: 'string' as const, required: true, description: 'Task title' },
14-
{ name: 'project', type: 'string' as const, required: false, description: 'Project name' },
42+
{ name: 'title', type: 'string', required: true, description: 'Task title' },
43+
{ name: 'project', type: 'string', required: false, description: 'Project name' },
1544
],
1645
active: true,
1746
isSystem: false,
18-
};
47+
});
1948

2049
export const allEmails = [TaskDoneEmail];
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The showcase's declared email templates have to be REACHABLE, and the only
4+
// way to know that is to resolve them.
5+
//
6+
// The bug this pins: `showcase_task_done_email` declared `locale: 'en'`.
7+
// `sendTemplate`'s ladder is exact match → `en-US` → (no-locale calls only) the
8+
// bundle's lowest tag, with deliberately no language-prefix matching, so `en`
9+
// never satisfies `en-US`. Measured against the old declaration, through the
10+
// same loader used below: `load(name, 'en-US')` answered `null`, and a
11+
// `sendTemplate({ locale: 'en-US' })` threw
12+
// `TEMPLATE_NOT_FOUND: showcase_task_done_email (locale=en-US)`. A no-locale
13+
// send still worked — by falling through to rung 3, the arbitrary-looking
14+
// "lowest tag in the bundle" — which is exactly why the defect was latent.
15+
//
16+
// So these assertions run the DECLARED templates through the real boot path:
17+
// the canonical schema parse, the real `mapTemplateToRow` projection into
18+
// `sys_email_template` columns, and the real `createSysEmailTemplateLoader`.
19+
// Nothing here inspects the source literal's strings; every verdict is a
20+
// resolution. The composition of rungs 1-3 itself is plugin-email's own
21+
// contract and is pinned there (`template-locale-resolution.test.ts`).
22+
23+
import { describe, it, expect } from 'vitest';
24+
import { EmailTemplateDefinitionSchema } from '@objectstack/spec/system';
25+
import {
26+
createSysEmailTemplateLoader,
27+
mapTemplateToRow,
28+
EMAIL_TEMPLATE_OBJECT,
29+
DEFAULT_TEMPLATE_LOCALE,
30+
} from '@objectstack/plugin-email';
31+
import { allEmails, TaskDoneEmail } from '../src/system/emails/index.js';
32+
33+
type Row = Record<string, unknown> & { id: string };
34+
35+
/** What the boot seeder writes: schema parse, then the shared column mapping. */
36+
function materialize(templates: readonly unknown[]): Row[] {
37+
return templates.map((t, i) => ({
38+
id: `row-${i}`,
39+
...mapTemplateToRow(EmailTemplateDefinitionSchema.parse(t) as never),
40+
}));
41+
}
42+
43+
/**
44+
* A driver-ish engine over the materialized rows — filters by `where`, honours
45+
* `orderBy`, then `limit`. Mirrors the fake plugin-email's own resolution
46+
* suite uses, so the loader is exercised the way a real store exercises it.
47+
*/
48+
function engine(rows: Row[]) {
49+
return {
50+
async find(object: string, query: Record<string, unknown>) {
51+
expect(object).toBe(EMAIL_TEMPLATE_OBJECT);
52+
const where = (query.where ?? {}) as Record<string, unknown>;
53+
let out = rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
54+
const orderBy = query.orderBy as Array<{ field: string; order?: string }> | undefined;
55+
if (Array.isArray(orderBy)) {
56+
out = [...out].sort((a, b) => {
57+
for (const { field, order } of orderBy) {
58+
const av = String(a[field] ?? '');
59+
const bv = String(b[field] ?? '');
60+
if (av !== bv) return (av < bv ? -1 : 1) * (order === 'desc' ? -1 : 1);
61+
}
62+
return 0;
63+
});
64+
}
65+
return typeof query.limit === 'number' ? out.slice(0, query.limit) : out;
66+
},
67+
};
68+
}
69+
70+
const loader = () => createSysEmailTemplateLoader(engine(materialize(allEmails)) as never);
71+
72+
describe('showcase email templates — declared tags the send ladder can actually reach', () => {
73+
it('resolves `showcase_task_done_email` for an explicit en-US send', async () => {
74+
const found = await loader().load('showcase_task_done_email', DEFAULT_TEMPLATE_LOCALE);
75+
76+
// The regression, stated as the send that used to fail. For an explicit
77+
// `en-US` the loader IS the whole ladder: rung 1 and rung 2 name the same
78+
// tag, so a null here is a `TEMPLATE_NOT_FOUND` throw at the service.
79+
expect(found).not.toBeNull();
80+
expect(found?.locale).toBe('en-US');
81+
});
82+
83+
it('resolves THIS template, not some other row of the bundle', async () => {
84+
const found = await loader().load('showcase_task_done_email', DEFAULT_TEMPLATE_LOCALE);
85+
86+
// A resolution that answers the wrong row is the failure mode a bare
87+
// "not null" assertion cannot see, so pin the identity of what came back.
88+
expect(found?.name).toBe('showcase_task_done_email');
89+
expect(found?.subject).toBe(TaskDoneEmail.subject);
90+
expect(found?.body_html).toBe(TaskDoneEmail.bodyHtml);
91+
});
92+
93+
it('answers a no-locale send from the default rung, not from the lowest-tag rung', async () => {
94+
// Rung 3 ("no en-US row at all ⇒ the bundle's lowest tag") is a
95+
// keep-the-tenant-working fallback, not a place an authored corpus should
96+
// be living. With the row at en-US this send is answered by rung 2.
97+
const found = await loader().load('showcase_task_done_email', undefined);
98+
expect(found?.locale).toBe(DEFAULT_TEMPLATE_LOCALE);
99+
});
100+
101+
it('does NOT answer the language-only tag `en` — and does not need to', async () => {
102+
// Pinned in the true direction: there is no prefix matching in either
103+
// direction, so an `en` lookup misses rung 1 by design. It still delivers,
104+
// because rung 2 of the service ladder is `en-US` — which is precisely why
105+
// `en-US` is the tag reachable from every call shape and `en` is not.
106+
expect(await loader().load('showcase_task_done_email', 'en')).toBeNull();
107+
});
108+
109+
it('every declared template in the corpus is reachable at the default locale', async () => {
110+
// The class guard: a template added later cannot reintroduce the defect
111+
// by picking a tag the ladder's default rung does not name.
112+
for (const template of allEmails) {
113+
const name = EmailTemplateDefinitionSchema.parse(template).name;
114+
const found = await loader().load(name, DEFAULT_TEMPLATE_LOCALE);
115+
expect(found, `${name} is unreachable at ${DEFAULT_TEMPLATE_LOCALE}`).not.toBeNull();
116+
}
117+
});
118+
119+
it('every declared template went through `defineEmailTemplateDefinition`', async () => {
120+
// The second half of the card: the literal used to be exported bare, so
121+
// `EmailTemplateDefinitionSchema.parse()` never ran at authoring time. A
122+
// definition that has been through the factory is a FIXED POINT of the
123+
// parse (every default already applied); a bare literal is not.
124+
for (const template of allEmails) {
125+
expect(EmailTemplateDefinitionSchema.parse(template)).toEqual(template);
126+
}
127+
});
128+
});

examples/app-showcase/tsconfig.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,19 @@
2727
// `dist` typechecks green over an engine contract that has since moved.
2828
// `pnpm check:type-source-resolution` is the gate; it wants the `paths`
2929
// rule, not a registry entry.
30+
//
31+
// Same rule, same reason, for the email plugin: `test/email-template-locale.test.ts`
32+
// resolves this app's declared email templates through plugin-email's real
33+
// `sys_email_template` loader and its real column mapping. Unaliased, its TYPES
34+
// come from `packages/plugins/plugin-email/dist/*.d.ts`, so `tsc --noEmit` would
35+
// grade those declarations against whatever was last built rather than against the
36+
// locale ladder in this checkout. Bare key, no `*`: a tsconfig `paths` key without
37+
// a star is an EXACT match, and the `@objectstack/plugin-email*` spelling would
38+
// fold every subpath onto this one target and type-check green against the wrong
39+
// module.
3040
"paths": {
31-
"@objectstack/formula": ["../../packages/formula/src/index.ts"]
41+
"@objectstack/formula": ["../../packages/formula/src/index.ts"],
42+
"@objectstack/plugin-email": ["../../packages/plugins/plugin-email/src/index.ts"]
3243
}
3344
},
3445
// This package took the widened-`include` route rather than a sibling

examples/app-showcase/vitest.config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,18 @@ export default defineConfig({
2424
// PREFIX, so with a FILE replacement it would also swallow any subpath and
2525
// resolve it to `…/formula/src/index.ts/<sub>` — `ENOTDIR` at run time,
2626
// from a config that reads as correct.
27+
//
28+
// `test/email-template-locale.test.ts` resolves this app's declared email
29+
// templates through plugin-email's real `sys_email_template` loader and its
30+
// real column mapping. Through the workspace link that package resolves to
31+
// `dist/` — a build artifact — so a stale dist would grade the declarations
32+
// against an OLD locale ladder, which is the one thing those assertions
33+
// exist to measure. `pnpm check:test-source-alias` is the gate, and its
34+
// registry is shrink-only: the alias is the sanctioned remedy, never a new
35+
// registry entry.
2736
alias: [
2837
{ find: /^@objectstack\/formula$/, replacement: path.resolve(__dirname, '../../packages/formula/src/index.ts') },
38+
{ find: /^@objectstack\/plugin-email$/, replacement: path.resolve(__dirname, '../../packages/plugins/plugin-email/src/index.ts') },
2939
],
3040
},
3141
test: {

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)