Skip to content

Commit dedc350

Browse files
fix(service-messaging): a notification template read for a recipient locale with no row tries the stack's declared fallback before en (#29048)
domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XQmNrHXYnPdkV5RqpKS1C5X)_ Fixes #28919 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:通知模板(`sys_notification_template`)按收件人 locale 取行时,在 `en` 之前多试一级:栈声明的 fallback locale(`i18n.fallbackLocale`,否则 `defaultLocale`)及其主语言(`zh-CN` → `zh`)。 - 为什么改:#28880 已让邮件模板读声明的 fallback,通知模板仍直接落到 `en`,同一个收件人在同一个栈上两类模板答案不同。 - 风险与回滚:只多查一到两次精确行;没有 i18n 服务、包外自建的 store、不带 locale 的调用都与原来完全相同;所有示例栈声明的都是 `en`,结果不变。回滚即 revert 本 PR。 - 先看:`template-renderer.ts`(`localeCandidates` 与 `declaredFallbackLocale`)、`messaging-service-plugin.ts`(把 i18n 的 `getFallbackLocale` 交给 store)、`template-declared-fallback.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/service-messaging exec vitest run src/template-declared-fallback.test.ts`,预期 12 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes - `NotificationTemplateStore`'s ladder for a named recipient locale is now: the named tag, its primary subtag, the stack's declared fallback and its primary subtag, then `DEFAULT_LOCALE` (`en`). Duplicates are dropped, so a stack declaring `en` walks exactly the old ladder. - The declared fallback is read at each resolve through a reader the plugin hands the store: the i18n service's `getFallbackLocale`, resolved from the service registry at call time, so an i18n service registered after `kernel:ready` is seen. Both hops are optional. A missing service, a missing method, an empty or non-tag answer, or a throw leave the rung out, and the store answers as it did before. - **Placement and the published surface.** The reader rides a module-private symbol key (`TEMPLATE_DECLARED_FALLBACK_LOCALE`, with the `NotificationTemplateStoreWiring` type), declared in `template-renderer.ts` and not re-exported from the package entry. This is the same shape #28917 used on the email side. The published `NotificationTemplateStoreOptions` and the class declaration are unchanged. The built `dist/index.d.ts` and `dist/index.d.cts` differ from `main` only in the store's docblock text. - **Ruling 2 reading: no `resolveBundleLocale` fold.** The store reads rows one `findOne` at a time and holds no locale list, so it cannot fold without a new read. The fallback takes the same two steps as the named tag (`tagThenPrimary`), and no third copy of the BCP-47 ladder is added. - A call that names no locale keeps its single `en` rung, matching the email side, where a no-locale call is unchanged. - The comment at `notification-template.object.ts` (the locale field) now gives the new two-resolver reading: the declared-fallback rung is shared, and the fold is not. The email-channel comment names the new rung. ## Why After #28917 (#28880), a templated email to a recipient whose locale has no row tries the declared fallback before `en-US`. A `sys_notification_template` render for the same recipient went straight to `en`. The two template families answered the same recipient on the same stack differently. **Measurement (ruling 3).** No shipped stack declares a non-`en` fallback. `examples/app-crm`, `app-showcase` and `app-todo` declare `fallbackLocale: 'en'`, `app-multi-package` declares no i18n block, and hotcrm's `src/sales` declares `'en'`. So for every shipped stack the ladder is unchanged. A stack that declares `defaultLocale: 'zh-CN'` and no `fallbackLocale` gets `zh-CN` as its fallback through `os serve`'s `fallbackLocale || defaultLocale || 'en'`, and that stack's ladder changes. The plugin already reads the i18n service from its context (`getDefaultTemplateLocale`), so the store reaches it without a new published member. ## Tests `src/template-declared-fallback.test.ts` (new, 12 cases): - Store: the declared fallback is chosen before `en`. The fallback folds to its primary subtag. The recipient's primary subtag still wins over the fallback. The ladder lands `en` when nothing matches. A changed answer is seen on the next resolve. Four ways of having no fallback (no reader, `undefined`, blank, throws) give today's ladder `['fr-FR','fr','en']`. A no-locale call reads `en` alone. - The new file's engine double routes `update`/`delete` through `assertEngineUpdateDispatch`/`assertEngineDeleteDispatch`, and its three rows are recorded in `scripts/engine-double-contract.pinned.json` (`--write`: 0 added or grown elsewhere, 0 lost). - Composition: through `MessagingServicePlugin`, the mounted email channel renders the `zh-CN` row for a `fr-FR` recipient when an i18n service with `getFallbackLocale: 'zh-CN'` is registered AFTER `kernel:ready`, and renders the `en` row on a host with no i18n service. <details><summary>Red first on main (5 red / 7 green)</summary> Test file only, implementation absent: the five fallback cases fail (`picks the declared fallback before en…`, `folds… primary subtag`, `lands en when neither…`, `reads… at each resolve`, the composition case). The no-fallback, subtag-wins and no-locale cases pass, as today's ladder already answers them. </details> <details><summary>Ablation, one per rung (each through scripts/ablation-replace.mjs, restored to a byte-identical HEAD)</summary> Tests import the source by relative path, so no `dist/` rebuild is in the path. - A, delete the fallback rung `tagThenPrimary(declaredFallback)`: 5 failed. - B, fallback without its primary subtag (`push(declaredFallback)`): 3 failed (`folds…`, `lands en…`, `reads… at each resolve`). - C, fallback before the named tag: 5 failed, including `the recipient's primary subtag still wins`. - D, drop the throw guard around the reader: 1 failed (`a reader that throws`). The first attempt was refused by the tool as a no-op (the replacement was a substring of the anchor) and ran nothing. The rerun used a distinct replacement. - E, drop the plugin wiring key: 1 failed (the composition case). </details> ## Acceptance notes - The store does not fold with `resolveBundleLocale` (see ruling 2 above). A `zh-TW` fallback over a store holding only `zh-CN` therefore lands `zh` or `en`, not `zh-CN`. The email side folds that case. - Not touched: `packages/spec/**` and `plugin-email`. ## Verification All readings are on head `1c8f2f69` (`origin/main` merged), except the ablation and d.ts readings, which were taken on the implementation commits before the merge. - `pnpm --filter @objectstack/service-messaging test`: exit 0, Test Files 63/63, Tests 771/771. - `pnpm --filter @objectstack/service-messaging typecheck`: exit 0. The new test file is in the package's `tsc --listFiles`. - `eslint --no-inline-config packages/services/service-messaging/src`: exit 0. - Published declarations: `dist/index.d.ts` and `dist/index.d.cts` were built at the base and at the fix. They differ only in the `NotificationTemplateStore` docblock text. No type, member or export changed, and the module-private symbol does not appear. - Derived gates (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`): 71 derived, 71 run, all exit 0. `--ran` reconciliation: 0 NOT-MEASURED, 0 UNRUN. Prerequisites cleared before the run: `check:i18n` and `check:dual-build-cjs-loads` read built output (closure built), and `check-plugin-teardown-shape --self-test` needs its pinned fixture commit fetched. - CI families outside the derived set: see this PR's check-runs. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0d15f89 commit dedc350

7 files changed

Lines changed: 319 additions & 17 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/service-messaging': patch
3+
---
4+
5+
fix(service-messaging): a `sys_notification_template` read for a recipient locale with no row tries the stack's declared fallback locale before `en` (#28919)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`NotificationTemplateStore` walked the named tag, its primary subtag (`zh-CN` → `zh`), then `DEFAULT_LOCALE` (`en`), and never read the stack's declared `i18n.fallbackLocale` (else `defaultLocale`). The email template ladder reads it since #28880, so the two template families answered the same recipient differently. The ladder is now: the named tag, its primary subtag, the declared fallback (read from the i18n service's `getFallbackLocale` at each resolve, so a service registered later is seen) and its primary subtag, then `en`. The store holds no locale list, so it tries exact rows; it does not fold with `resolveBundleLocale`. A host with no i18n service, a store built outside this package, and a call that names no locale answer exactly as before. Every shipped example declares `fallbackLocale: 'en'`, so their ladder is unchanged. The published `NotificationTemplateStoreOptions` is unchanged: the composition hands the reader in under a module-private key.

‎packages/services/service-messaging/src/email-channel.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,8 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel
472472

473473
// Same per-recipient resolution on the `sys_notification_template`
474474
// arm (#13881); the store walks its own ladder (`zh-CN` → `zh` →
475-
// DEFAULT_LOCALE) underneath, so a tag with no row still renders.
475+
// the declared fallback and its primary subtag → DEFAULT_LOCALE)
476+
// underneath, so a tag with no row still renders.
476477
const locale = recipientLocale ?? defaultLocale;
477478
// The row is the recipient organization's: the delivery's own
478479
// organization keys the read (see NotificationTemplateStore).

‎packages/services/service-messaging/src/messaging-service-plugin.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import { DEFAULT_MAX_IDLE_INTERVAL_MS, NotificationDispatcher, type DispatchClus
1313
import { HttpDispatcher } from './http-dispatcher.js';
1414
import { createEmailChannel } from './email-channel.js';
1515
import { createSmsChannel } from './sms-channel.js';
16-
import { NotificationTemplateStore } from './template-renderer.js';
16+
import {
17+
NotificationTemplateStore,
18+
TEMPLATE_DECLARED_FALLBACK_LOCALE,
19+
type NotificationTemplateStoreWiring,
20+
} from './template-renderer.js';
1721
import {
1822
bindTemplateHolePlacement,
1923
unbindTemplateHolePlacement,
@@ -456,7 +460,24 @@ export class MessagingServicePlugin implements Plugin {
456460
// Whether a MOUNTED channel can send is the separate question
457461
// `isAvailable` answers.
458462
if (typeof ctx.hook === 'function') {
459-
const templateStore = new NotificationTemplateStore({ getData });
463+
// [#28919] The store's ladder reads the stack's declared fallback
464+
// locale (`i18n.fallbackLocale`, else `defaultLocale`) before
465+
// `DEFAULT_LOCALE`, as the email template ladder does since
466+
// #28880. Probed at each resolve like `getDefaultTemplateLocale`:
467+
// both hops may be missing, and either leaves the rung out.
468+
const getDeclaredFallbackLocale = (): string | undefined => {
469+
try {
470+
const i18n = ctx.getService<{ getFallbackLocale?: () => string | undefined }>('i18n');
471+
return typeof i18n?.getFallbackLocale === 'function' ? i18n.getFallbackLocale() : undefined;
472+
} catch {
473+
return undefined;
474+
}
475+
};
476+
const templateStoreWiring: NotificationTemplateStoreWiring = {
477+
getData,
478+
[TEMPLATE_DECLARED_FALLBACK_LOCALE]: getDeclaredFallbackLocale,
479+
};
480+
const templateStore = new NotificationTemplateStore(templateStoreWiring);
460481
ctx.hook('kernel:ready', async () => {
461482
service.registerChannelProvider('email', lazyChannelMount({
462483
resolveTransport: getEmail,

‎packages/services/service-messaging/src/objects/notification-template.object.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -131,13 +131,18 @@ export const NotificationTemplate = ObjectSchema.create({
131131
defaultValue: 'en',
132132
// [commit e4902d2b9] Sibling-declaration bound (route A, ruling 2026-08-24): the same
133133
// BCP-47 tag family `sys_email_template.locale` stores, bounded 16
134-
// there. The BOUND is shared; the RESOLUTION is not, and neither
135-
// side picks a "best-matching" locale. This object is loaded by
136-
// `NotificationTemplateStore` on `(topic, channel, locale)` and
137-
// walks a fixed candidate list — the named tag, then its primary
138-
// subtag (`zh-CN` -> `zh`), then `DEFAULT_LOCALE` ('en').
139-
// `sys_email_template` matches `(name, locale)` EXACTLY, folds no
140-
// subtag at all, and retries the single literal rung `en-US`.
134+
// there. The BOUND is shared, and so is the declared-fallback rung
135+
// (#28919, aligning with #28880); the FOLD is not. This object is
136+
// loaded by `NotificationTemplateStore` on `(topic, channel,
137+
// locale)` and walks a fixed candidate list — the named tag, then
138+
// its primary subtag (`zh-CN` -> `zh`), then the stack's declared
139+
// fallback (`i18n.fallbackLocale`, else `defaultLocale`, via the
140+
// i18n service's `getFallbackLocale`) and its primary subtag, then
141+
// `DEFAULT_LOCALE` ('en'). The store holds no locale list, so it
142+
// tries exact rows. `sys_email_template`, over the built-in
143+
// loader, folds a named locale with `resolveBundleLocale` against
144+
// the bundle's locale list, then folds the declared fallback the
145+
// same way, then retries the literal rung `en-US`.
141146
maxLength: 16,
142147
description: "BCP-47 locale, e.g. 'en' / 'en-US' / 'zh-CN'.",
143148
}),
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
assertEngineDeleteDispatch,
6+
assertEngineFindOnePredicate,
7+
assertEngineUpdateDispatch,
8+
type EngineFindOneQueryInput,
9+
} from '@objectstack/metadata-core';
10+
import {
11+
NotificationTemplateStore,
12+
TEMPLATE_DECLARED_FALLBACK_LOCALE,
13+
type NotificationTemplateStoreWiring,
14+
} from './template-renderer.js';
15+
import { MessagingServicePlugin } from './messaging-service-plugin.js';
16+
import type { MessagingService } from './messaging-service.js';
17+
import type { Delivery } from './channel.js';
18+
19+
/**
20+
* The store's locale ladder reads the stack's DECLARED fallback
21+
* (`i18n.fallbackLocale`, else `defaultLocale`, through the i18n service's
22+
* `getFallbackLocale`), as the email template ladder does since #28880: the
23+
* named tag, its primary subtag, the declared fallback and ITS primary subtag,
24+
* then `DEFAULT_LOCALE`.
25+
*/
26+
27+
/** A `sys_notification_template` row in `org_1`. */
28+
function row(locale: string, subject = locale) {
29+
return { organization_id: 'org_1', topic: 't', channel: 'email', locale, is_active: true, subject, body: 'B' };
30+
}
31+
32+
function fakeData(rows: any[]) {
33+
const locales: string[] = [];
34+
return {
35+
locales,
36+
engine: {
37+
async findOne(object: string, query: EngineFindOneQueryInput & { where?: any }) {
38+
assertEngineFindOnePredicate(object, query);
39+
const w = query?.where ?? {};
40+
locales.push(w.locale);
41+
return (
42+
rows.find(
43+
(r) =>
44+
r.organization_id === w.organization_id && r.topic === w.topic &&
45+
r.channel === w.channel && r.locale === w.locale && r.is_active,
46+
) ?? null
47+
);
48+
},
49+
async find() { return []; },
50+
async insert(_o: string, r: any) { return { id: 'x', ...r }; },
51+
async update(_o: string, data: any, options?: any) { assertEngineUpdateDispatch(data, options); return {}; },
52+
async delete(_o: string, options?: any) { assertEngineDeleteDispatch(options); return {}; },
53+
async count() { return 0; },
54+
async aggregate() { return []; },
55+
} as any,
56+
};
57+
}
58+
59+
function store(engine: any, fallback?: () => string | undefined) {
60+
const wiring: NotificationTemplateStoreWiring = { getData: () => engine };
61+
if (fallback) wiring[TEMPLATE_DECLARED_FALLBACK_LOCALE] = fallback;
62+
return new NotificationTemplateStore(wiring);
63+
}
64+
65+
describe('NotificationTemplateStore — the declared fallback locale (#28919)', () => {
66+
it('picks the declared fallback before en when the recipient locale has no row', async () => {
67+
const data = fakeData([row('zh-CN'), row('en')]);
68+
const got = await store(data.engine, () => 'zh-CN').load('t', 'email', 'fr-FR', 'org_1');
69+
expect(got).toMatchObject({ subject: 'zh-CN' });
70+
expect(data.locales).toEqual(['fr-FR', 'fr', 'zh-CN']);
71+
});
72+
73+
it('folds the declared fallback to its primary subtag before en', async () => {
74+
const data = fakeData([row('zh'), row('en')]);
75+
const got = await store(data.engine, () => 'zh-CN').load('t', 'email', 'fr-FR', 'org_1');
76+
expect(got).toMatchObject({ subject: 'zh' });
77+
expect(data.locales).toEqual(['fr-FR', 'fr', 'zh-CN', 'zh']);
78+
});
79+
80+
it("the recipient's primary subtag still wins over the declared fallback", async () => {
81+
const data = fakeData([row('fr'), row('zh-CN'), row('en')]);
82+
const got = await store(data.engine, () => 'zh-CN').load('t', 'email', 'fr-CA', 'org_1');
83+
expect(got).toMatchObject({ subject: 'fr' });
84+
expect(data.locales).toEqual(['fr-CA', 'fr']);
85+
});
86+
87+
it('lands en when neither the recipient nor the declared fallback has a row', async () => {
88+
const data = fakeData([row('en')]);
89+
const got = await store(data.engine, () => 'zh-CN').load('t', 'email', 'fr-FR', 'org_1');
90+
expect(got).toMatchObject({ subject: 'en' });
91+
expect(data.locales).toEqual(['fr-FR', 'fr', 'zh-CN', 'zh', 'en']);
92+
});
93+
94+
it('reads the declared fallback at each resolve, so a later answer is seen', async () => {
95+
const data = fakeData([row('ja'), row('zh-CN'), row('en')]);
96+
let declared: string | undefined;
97+
const s = store(data.engine, () => declared);
98+
expect(await s.load('t', 'email', 'fr-FR', 'org_1')).toMatchObject({ subject: 'en' });
99+
declared = 'zh-CN';
100+
expect(await s.load('t', 'email', 'fr-FR', 'org_1')).toMatchObject({ subject: 'zh-CN' });
101+
declared = 'ja-JP';
102+
expect(await s.load('t', 'email', 'fr-FR', 'org_1')).toMatchObject({ subject: 'ja' });
103+
});
104+
105+
describe('a store with no declared fallback answers exactly as before', () => {
106+
for (const [label, fallback] of [
107+
['no fallback reader wired', undefined],
108+
['a reader that answers nothing', () => undefined],
109+
['a reader that answers an empty tag', () => ' '],
110+
['a reader that throws', () => { throw new Error('i18n down'); }],
111+
] as Array<[string, (() => string | undefined) | undefined]>) {
112+
it(label, async () => {
113+
const data = fakeData([row('zh-CN'), row('en')]);
114+
const got = await store(data.engine, fallback).load('t', 'email', 'fr-FR', 'org_1');
115+
expect(got).toMatchObject({ subject: 'en' });
116+
expect(data.locales).toEqual(['fr-FR', 'fr', 'en']);
117+
});
118+
}
119+
});
120+
121+
it('a call that names no locale keeps its single en rung', async () => {
122+
const data = fakeData([row('zh-CN')]);
123+
expect(await store(data.engine, () => 'zh-CN').load('t', 'email', undefined, 'org_1')).toBeNull();
124+
expect(data.locales).toEqual(['en']);
125+
});
126+
});
127+
128+
/**
129+
* The composition: the plugin hands its store the i18n service's
130+
* `getFallbackLocale`, resolved at each send (the service may register after
131+
* `kernel:ready`), and a host with no i18n service renders as before.
132+
*/
133+
describe('MessagingServicePlugin — the email channel reads the declared fallback (#28919)', () => {
134+
function composed() {
135+
const data = fakeData([row('zh-CN', 'zh-CN subject'), row('en', 'en subject')]);
136+
const engine = {
137+
...data.engine,
138+
async syncObjectSchema() {},
139+
async findOne(object: string, query: any) {
140+
if (object === 'sys_user') return { email: 'ada@example.com', locale: 'fr-FR' };
141+
if (object === 'sys_notification_template') return data.engine.findOne(object, query);
142+
assertEngineFindOnePredicate(object, query);
143+
return null;
144+
},
145+
};
146+
const services = new Map<string, unknown>([['data', engine], ['manifest', { register() {} }]]);
147+
const readyHooks: Array<() => Promise<void> | void> = [];
148+
const logger = { info() {}, warn() {}, error() {}, debug() {}, child() { return logger; } };
149+
const ctx = {
150+
logger,
151+
getService(name: string) {
152+
if (!services.has(name)) throw new Error(`service '${name}' not found`);
153+
return services.get(name);
154+
},
155+
registerService(name: string, svc: unknown) { services.set(name, svc); },
156+
hook(event: string, fn: () => Promise<void> | void) { if (event === 'kernel:ready') readyHooks.push(fn); },
157+
} as any;
158+
const sent: any[] = [];
159+
services.set('email', { async send(input: any) { sent.push(input); return { id: 'mail_1' }; } });
160+
return { ctx, data, sent, fireReady: async () => { for (const fn of readyHooks) await fn(); } };
161+
}
162+
163+
const delivery: Delivery = {
164+
channel: 'email',
165+
recipient: 'user_1',
166+
notification: {
167+
notificationId: 'evt_1',
168+
topic: 't',
169+
title: 'T',
170+
body: 'B',
171+
severity: 'info',
172+
recipients: ['user_1'],
173+
organizationId: 'org_1',
174+
payload: {},
175+
} as any,
176+
};
177+
const sendCtx = { logger: { info() {}, warn() {}, error() {} } } as any;
178+
179+
it('renders the declared fallback row for a recipient locale with no row, with i18n registered after kernel:ready', async () => {
180+
const { ctx, data, sent, fireReady } = composed();
181+
await new MessagingServicePlugin({ reliableDelivery: false }).init(ctx);
182+
await fireReady();
183+
ctx.registerService('i18n', { getFallbackLocale: () => 'zh-CN' });
184+
185+
const messaging = ctx.getService('messaging') as MessagingService;
186+
const result = await messaging.getChannel('email')!.send(sendCtx, delivery);
187+
expect(result.ok).toBe(true);
188+
expect(sent[0]?.subject).toBe('zh-CN subject');
189+
expect(data.locales).toEqual(['fr-FR', 'fr', 'zh-CN']);
190+
});
191+
192+
it('renders the en row on a host with no i18n service', async () => {
193+
const { ctx, data, sent, fireReady } = composed();
194+
await new MessagingServicePlugin({ reliableDelivery: false }).init(ctx);
195+
await fireReady();
196+
197+
const messaging = ctx.getService('messaging') as MessagingService;
198+
const result = await messaging.getChannel('email')!.send(sendCtx, delivery);
199+
expect(result.ok).toBe(true);
200+
expect(sent[0]?.subject).toBe('en subject');
201+
expect(data.locales).toEqual(['fr-FR', 'fr', 'en']);
202+
});
203+
});

‎packages/services/service-messaging/src/template-renderer.ts‎

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import type { IDataEngine } from '@objectstack/spec/contracts';
44
import { FAN_OUT_SYSTEM_CONTEXT } from './fan-out-system-context.js';
5+
import { normalizeRecipientLocale } from './recipient-locale.js';
56

67
/** The object notification templates live in. */
78
export const TEMPLATE_OBJECT = 'sys_notification_template';
@@ -182,10 +183,34 @@ export interface NotificationTemplateStoreOptions {
182183
objectName?: string;
183184
}
184185

186+
/**
187+
* [#28919] The key the composition hands the store the stack's DECLARED
188+
* fallback locale under: a reader of `i18n.fallbackLocale` (else
189+
* `defaultLocale`), through the i18n service's `getFallbackLocale`, called at
190+
* each resolve so a service registered later is seen.
191+
*
192+
* Module-private on purpose: neither this symbol nor
193+
* {@link NotificationTemplateStoreWiring} is re-exported from the package
194+
* entry, so the published `NotificationTemplateStoreOptions` is unchanged. A
195+
* store built without it (any caller outside this package) walks the ladder
196+
* it always walked.
197+
*/
198+
export const TEMPLATE_DECLARED_FALLBACK_LOCALE: unique symbol = Symbol('notification-template.declared-fallback-locale');
199+
200+
/** {@link NotificationTemplateStoreOptions} plus the in-package fallback reader. */
201+
export type NotificationTemplateStoreWiring = NotificationTemplateStoreOptions & {
202+
[TEMPLATE_DECLARED_FALLBACK_LOCALE]?: () => string | undefined;
203+
};
204+
185205
/**
186206
* Loads `sys_notification_template` rows by `(topic, channel, locale)` in the
187-
* recipient's organization, with a locale fallback (`en-US` → `en` →
188-
* {@link DEFAULT_LOCALE}). Best-effort: no data engine or a lookup error yields
207+
* recipient's organization, with a locale fallback: the named tag, its primary
208+
* subtag (`zh-CN` → `zh`), then the stack's declared fallback and its primary
209+
* subtag (#28919, the same rung the email template ladder reads since #28880),
210+
* then {@link DEFAULT_LOCALE}. The declared fallback is handed in by this
211+
* package's own composition; a call that names no locale reads
212+
* `DEFAULT_LOCALE` alone, and a store built without that reader skips the
213+
* rung. Best-effort: no data engine or a lookup error yields
189214
* `null` (→ the renderer's generic fallback), never a throw — a template outage
190215
* must not block delivery.
191216
*
@@ -224,7 +249,7 @@ export class NotificationTemplateStore {
224249
const data = this.opts.getData();
225250
if (!data) return null;
226251
if (!organizationId) return null;
227-
const candidates = localeCandidates(locale);
252+
const candidates = localeCandidates(locale, locale ? declaredFallbackLocale(this.opts) : undefined);
228253
for (const loc of candidates) {
229254
try {
230255
// The explicit system opt-in — see FAN_OUT_SYSTEM_CONTEXT: the
@@ -243,12 +268,34 @@ export class NotificationTemplateStore {
243268
}
244269
}
245270

246-
/** `en-US` → ['en-US','en', DEFAULT_LOCALE]; dedups, keeps order. */
247-
function localeCandidates(locale?: string): string[] {
271+
/**
272+
* The declared fallback, read now. A reader that is absent, answers no tag, or
273+
* throws contributes no rung: the declared fallback refines the ladder and
274+
* never costs the delivery its template.
275+
*/
276+
function declaredFallbackLocale(opts: NotificationTemplateStoreOptions): string | undefined {
277+
const read = (opts as NotificationTemplateStoreWiring)[TEMPLATE_DECLARED_FALLBACK_LOCALE];
278+
if (typeof read !== 'function') return undefined;
279+
try {
280+
return normalizeRecipientLocale(read());
281+
} catch {
282+
return undefined;
283+
}
284+
}
285+
286+
/**
287+
* `en-US` (declared fallback `zh-CN`) → ['en-US','en','zh-CN','zh', DEFAULT_LOCALE];
288+
* dedups, keeps order. The fallback takes the same two steps as the named tag.
289+
*/
290+
function localeCandidates(locale?: string, declaredFallback?: string): string[] {
248291
const out: string[] = [];
249292
const push = (l?: string) => { if (l && !out.includes(l)) out.push(l); };
250-
push(locale);
251-
if (locale && locale.includes('-')) push(locale.split('-')[0]);
293+
const tagThenPrimary = (l?: string) => {
294+
push(l);
295+
if (l && l.includes('-')) push(l.split('-')[0]);
296+
};
297+
tagThenPrimary(locale);
298+
tagThenPrimary(declaredFallback);
252299
push(DEFAULT_LOCALE);
253300
return out;
254301
}

0 commit comments

Comments
 (0)