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
1 change: 1 addition & 0 deletions examples/app-showcase/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@
"@objectstack/formula": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/plugin-approvals": "workspace:*",
"@objectstack/plugin-email": "workspace:*",
"@objectstack/service-automation": "workspace:*",
"@objectstack/service-messaging": "workspace:*",
"@playwright/test": "^1.62.1",
Expand Down
43 changes: 36 additions & 7 deletions examples/app-showcase/src/system/emails/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,49 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/** Email template fired by the Task Completed flow. */
export const TaskDoneEmail = {
import { defineEmailTemplateDefinition } from '@objectstack/spec';

/**
* Email template declared for the Task Completed flow.
*
* ## Why `locale` is `en-US`, and why it is spelled out
*
* `sendTemplate`'s ladder is **exact match → `en-US` → (no-locale calls only)
* the bundle's lowest tag**, with deliberately no language-prefix matching:
* `en` does not satisfy `en-US`. Because `en-US` is the ladder's own second
* rung, a row authored at `en-US` is reachable from *every* call shape — an
* explicit `en-US`, this app's `defaultLocale: 'en'` (which the notify path
* passes as the recipient locale), and a call naming no locale at all. Any
* other tag is reachable from strictly fewer: this row used to say `en`, which
* made `sendTemplate({ locale: 'en-US' })` fail with `TEMPLATE_NOT_FOUND`.
*
* The key is written out rather than left to the schema default because the
* example corpus is what gets copied: the tag is the bundle key a second
* language row has to match, and `content/docs/automation/email-templates.mdx`
* teaches authoring the tags your callers actually pass.
*
* ## Not wired to the flow yet
*
* `showcase_task_completed`'s notify node demonstrates the inline
* `title`/`message` path, which the node schema makes mutually exclusive with
* `template` — so referencing this template there is a substitution, not an
* addition: it would cost the script node's `{summary}` its only consumer, and
* that consumption is what the flow exists to demonstrate. Tracked as its own
* decision in #10394 rather than smuggled in here.
*/
export const TaskDoneEmail = defineEmailTemplateDefinition({
name: 'showcase_task_done_email',
label: 'Task Done Notification',
category: 'workflow' as const,
locale: 'en',
category: 'workflow',
locale: 'en-US',
subject: '✅ Task done: {{title}}',
bodyHtml: '<p>The task <strong>{{title}}</strong> on project {{project}} was marked done.</p>',
bodyText: 'The task {{title}} on project {{project}} was marked done.',
variables: [
{ name: 'title', type: 'string' as const, required: true, description: 'Task title' },
{ name: 'project', type: 'string' as const, required: false, description: 'Project name' },
{ name: 'title', type: 'string', required: true, description: 'Task title' },
{ name: 'project', type: 'string', required: false, description: 'Project name' },
],
active: true,
isSystem: false,
};
});

export const allEmails = [TaskDoneEmail];
128 changes: 128 additions & 0 deletions examples/app-showcase/test/email-template-locale.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// The showcase's declared email templates have to be REACHABLE, and the only
// way to know that is to resolve them.
//
// The bug this pins: `showcase_task_done_email` declared `locale: 'en'`.
// `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
// same loader used below: `load(name, 'en-US')` answered `null`, and a
// `sendTemplate({ locale: 'en-US' })` threw
// `TEMPLATE_NOT_FOUND: showcase_task_done_email (locale=en-US)`. A no-locale
// send still worked — by falling through to rung 3, the arbitrary-looking
// "lowest tag in the bundle" — which is exactly why the defect was latent.
//
// So these assertions run the DECLARED templates through the real boot path:
// the canonical schema parse, the real `mapTemplateToRow` projection into
// `sys_email_template` columns, and the real `createSysEmailTemplateLoader`.
// Nothing here inspects the source literal's strings; every verdict is a
// resolution. The composition of rungs 1-3 itself is plugin-email's own
// contract and is pinned there (`template-locale-resolution.test.ts`).

import { describe, it, expect } from 'vitest';
import { EmailTemplateDefinitionSchema } from '@objectstack/spec/system';
import {
createSysEmailTemplateLoader,
mapTemplateToRow,
EMAIL_TEMPLATE_OBJECT,
DEFAULT_TEMPLATE_LOCALE,
} from '@objectstack/plugin-email';
import { allEmails, TaskDoneEmail } from '../src/system/emails/index.js';

type Row = Record<string, unknown> & { id: string };

/** What the boot seeder writes: schema parse, then the shared column mapping. */
function materialize(templates: readonly unknown[]): Row[] {
return templates.map((t, i) => ({
id: `row-${i}`,
...mapTemplateToRow(EmailTemplateDefinitionSchema.parse(t) as never),
}));
}

/**
* A driver-ish engine over the materialized rows — filters by `where`, honours
* `orderBy`, then `limit`. Mirrors the fake plugin-email's own resolution
* suite uses, so the loader is exercised the way a real store exercises it.
*/
function engine(rows: Row[]) {
return {
async find(object: string, query: Record<string, unknown>) {
expect(object).toBe(EMAIL_TEMPLATE_OBJECT);
const where = (query.where ?? {}) as Record<string, unknown>;
let out = rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
const orderBy = query.orderBy as Array<{ field: string; order?: string }> | undefined;
if (Array.isArray(orderBy)) {
out = [...out].sort((a, b) => {
for (const { field, order } of orderBy) {
const av = String(a[field] ?? '');
const bv = String(b[field] ?? '');
if (av !== bv) return (av < bv ? -1 : 1) * (order === 'desc' ? -1 : 1);
}
return 0;
});
}
return typeof query.limit === 'number' ? out.slice(0, query.limit) : out;
},
};
}

const loader = () => createSysEmailTemplateLoader(engine(materialize(allEmails)) as never);

describe('showcase email templates — declared tags the send ladder can actually reach', () => {
it('resolves `showcase_task_done_email` for an explicit en-US send', async () => {
const found = await loader().load('showcase_task_done_email', DEFAULT_TEMPLATE_LOCALE);

// The regression, stated as the send that used to fail. For an explicit
// `en-US` the loader IS the whole ladder: rung 1 and rung 2 name the same
// tag, so a null here is a `TEMPLATE_NOT_FOUND` throw at the service.
expect(found).not.toBeNull();
expect(found?.locale).toBe('en-US');
});

it('resolves THIS template, not some other row of the bundle', async () => {
const found = await loader().load('showcase_task_done_email', DEFAULT_TEMPLATE_LOCALE);

// A resolution that answers the wrong row is the failure mode a bare
// "not null" assertion cannot see, so pin the identity of what came back.
expect(found?.name).toBe('showcase_task_done_email');
expect(found?.subject).toBe(TaskDoneEmail.subject);
expect(found?.body_html).toBe(TaskDoneEmail.bodyHtml);
});

it('answers a no-locale send from the default rung, not from the lowest-tag rung', async () => {
// Rung 3 ("no en-US row at all ⇒ the bundle's lowest tag") is a
// keep-the-tenant-working fallback, not a place an authored corpus should
// be living. With the row at en-US this send is answered by rung 2.
const found = await loader().load('showcase_task_done_email', undefined);
expect(found?.locale).toBe(DEFAULT_TEMPLATE_LOCALE);
});

it('does NOT answer the language-only tag `en` — and does not need to', async () => {
// Pinned in the true direction: there is no prefix matching in either
// direction, so an `en` lookup misses rung 1 by design. It still delivers,
// because rung 2 of the service ladder is `en-US` — which is precisely why
// `en-US` is the tag reachable from every call shape and `en` is not.
expect(await loader().load('showcase_task_done_email', 'en')).toBeNull();
});

it('every declared template in the corpus is reachable at the default locale', async () => {
// The class guard: a template added later cannot reintroduce the defect
// by picking a tag the ladder's default rung does not name.
for (const template of allEmails) {
const name = EmailTemplateDefinitionSchema.parse(template).name;
const found = await loader().load(name, DEFAULT_TEMPLATE_LOCALE);
expect(found, `${name} is unreachable at ${DEFAULT_TEMPLATE_LOCALE}`).not.toBeNull();
}
});

it('every declared template went through `defineEmailTemplateDefinition`', async () => {
// The second half of the card: the literal used to be exported bare, so
// `EmailTemplateDefinitionSchema.parse()` never ran at authoring time. A
// definition that has been through the factory is a FIXED POINT of the
// parse (every default already applied); a bare literal is not.
for (const template of allEmails) {
expect(EmailTemplateDefinitionSchema.parse(template)).toEqual(template);
}
});
});
13 changes: 12 additions & 1 deletion examples/app-showcase/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,8 +27,19 @@
// `dist` typechecks green over an engine contract that has since moved.
// `pnpm check:type-source-resolution` is the gate; it wants the `paths`
// rule, not a registry entry.
//
// Same rule, same reason, for the email plugin: `test/email-template-locale.test.ts`
// resolves this app's declared email templates through plugin-email's real
// `sys_email_template` loader and its real column mapping. Unaliased, its TYPES
// come from `packages/plugins/plugin-email/dist/*.d.ts`, so `tsc --noEmit` would
// grade those declarations against whatever was last built rather than against the
// locale ladder in this checkout. Bare key, no `*`: a tsconfig `paths` key without
// a star is an EXACT match, and the `@objectstack/plugin-email*` spelling would
// fold every subpath onto this one target and type-check green against the wrong
// module.
"paths": {
"@objectstack/formula": ["../../packages/formula/src/index.ts"]
"@objectstack/formula": ["../../packages/formula/src/index.ts"],
"@objectstack/plugin-email": ["../../packages/plugins/plugin-email/src/index.ts"]
}
},
// This package took the widened-`include` route rather than a sibling
Expand Down
10 changes: 10 additions & 0 deletions examples/app-showcase/vitest.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,8 +24,18 @@ export default defineConfig({
// PREFIX, so with a FILE replacement it would also swallow any subpath and
// resolve it to `…/formula/src/index.ts/<sub>` — `ENOTDIR` at run time,
// from a config that reads as correct.
//
// `test/email-template-locale.test.ts` resolves this app's declared email
// templates through plugin-email's real `sys_email_template` loader and its
// real column mapping. Through the workspace link that package resolves to
// `dist/` — a build artifact — so a stale dist would grade the declarations
// against an OLD locale ladder, which is the one thing those assertions
// exist to measure. `pnpm check:test-source-alias` is the gate, and its
// registry is shrink-only: the alias is the sanctioned remedy, never a new
// registry entry.
alias: [
{ find: /^@objectstack\/formula$/, replacement: path.resolve(__dirname, '../../packages/formula/src/index.ts') },
{ find: /^@objectstack\/plugin-email$/, replacement: path.resolve(__dirname, '../../packages/plugins/plugin-email/src/index.ts') },
],
},
test: {
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

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

Loading