Skip to content

Commit c24203f

Browse files
fix(service-messaging): a notification digest's heading is rendered in its recipient's language (#25170)
Fixes #25015 Clause-②: no Metadata-protocol: none ## What this does A notification digest of two or more items was headed "You have N notifications", an English literal built in `digest-render.ts`, for every recipient whatever their language. Its items were already rendered in the recipient's language (PR #25014), so a zh-CN recipient read zh-CN items under an English heading. The heading is now keyed and rendered by the `EmitInput.keyedText` renderer, the same one and the same locale read as the items. There is no second mechanism. - **The key.** `messages.notificationDigestTitle`, with a `{{count}}` hole, in `@objectstack/service-messaging`'s own translation bundle (`MessagingTranslations`). It ships in every locale the bundle ships: en, zh-CN, ja-JP and es-ES. The bundle gains a hand-authored `messages` face (`src/translations/messages.ts`), in the shape the approvals and audit plugins use for their notification sentences. The plugin's existing `kernel:ready` hook loads it with the rest of the bundle. - **The render.** `NotificationDispatcher.processDigestGroup` now builds the digest through `localizedDigest()`. For a group of two or more, the heading (`digestHeading(count)`: the key, its `{{count}}`, and the literal) goes to `NotificationTextRenderer.renderAllFor` in the same call as the group's items. So one locale read covers the group: `sys_user.locale`, then the deployment default (#13881). It is negotiated against the `i18n` service's locales by the renderer's existing `resolveBundleLocale` step. - **The literal fallback.** It is the `en` entry interpolated, byte for byte the old sentence ("You have 3 notifications"). A recipient reads it with no `i18n` service, with no renderer composed, or when the catalog has no entry in any locale it consults. In that last case the renderer's existing miss warning is said once per key and locale. - **Unchanged.** A one-item digest is titled by its item, as before. The digest body (the item list), the payload (`digest`, `count`, `items`) and the actor rule are untouched. No export, option or type changes. `renderDigest` is internal and only gains an optional `heading` argument. ## Why no plural mechanism was needed (PM assumption 2, measured) Neither `II18nService` implementation selects a plural form. `createMemoryI18n().t()` (`packages/core/src/fallbacks/memory-i18n.ts`) and `FileI18nAdapter.t()` (`packages/services/service-i18n/src/file-i18n-adapter.ts`) both interpolate `{{name}}` holes and nothing else. The renderer carries the count as an ordinary string param. That is enough because the heading is only ever rendered for a count of two or more. In each of the four shipped locales, every count from two up takes one form: en and es-ES use the plural, and zh-CN and ja-JP do not inflect. So no new mechanism was added, and there is no gap to report for the shipped locales. A locale whose form varies above two (Polish, Russian, Arabic) would need plural selection in the `i18n` service. That is recorded under Acceptance notes and in `messages.ts`, and not built. ## Where the producer is (PM assumption 1, measured on `06900c9c2`) - Confirmed: `digest-render.ts:43` held the literal, and `NotificationDispatcher.processDigestGroup` (`dispatcher.ts:286`) reached it for every digest group of two or more rows. The fix lands there, in the declared file surface. - PM assumption 2 also holds: PR #25014's path is on `main` (`notification-text.ts`; `readNotificationTextKeys` at `dispatcher.ts:347` and `:382`). Its renderer is reused unchanged. - PM assumption 3 also holds: the bundle ships en, zh-CN, ja-JP and es-ES, and the key is added in all four. ## The cost change With an `i18n` service registered, a digest of two or more items now reads its recipient's locale once even when none of its items is keyed, because its heading always is. Before, such a group read nothing. That is one read per group per window, on a path that already reads the same row for email and SMS addresses. The cost note in `notification-text.ts` says so. ## Pins All pins are in `packages/services/service-messaging/src/keyed-notification-text.integration.test.ts`, a new `#25015` block of seven cases. They run on the file's existing real stack: ObjectQL over SqlDriver (better-sqlite3), the kernel's in-memory `i18n` holding this package's real `MessagingTranslations` (loaded the way the plugin's `kernel:ready` hook loads it), the real `MessagingService`, the inbox and email channels, the memory outbox, and the dispatcher. 1. A zh-CN recipient's three-item digest is headed `您有 3 条通知` over three zh-CN items, in the inbox row and in the mail subject. 2. Control: the English recipient of the same digest reads `You have 2 notifications`. 3. A recipient with no locale reads the deployment default (ja-JP): `2 件の通知があります`. 4. A recipient whose language no pack holds (fr-FR) reads the deployment default's pack (zh-CN). 5. When neither the recipient's language nor the deployment default has a pack, the recipient reads the English literal, and the miss is said once. 6. A one-item digest keeps its item's own title, rendered in zh-CN, with no heading. 7. Every shipped locale carries every messaging sentence with the same `{{holes}}`, and `MessagingTranslations` serves exactly those entries per locale. The existing `digest.test.ts` cases, with no renderer composed, still read `You have 3 notifications` and `You have 2 notifications`. They are the byte-unchanged pin for the literal. ## Ablation (one-off, no permanent file) Run on head `490c66405` with the fix committed (the package is byte-identical at the final head `0d56c868d`), through `node scripts/ablation-replace.mjs` under the verify lock. The mutation was in `dispatcher.ts`: `return renderDigest(localized, heading ? rendered[rows.length].title : undefined);` became `return renderDigest(localized, undefined);`, which drops the keyed heading. On disk: anchor 1 to 0, replacement 0 to 1, blob `a404506aa183` to `afccb98e9e01`. - Result: the expected direction, red. `Tests 3 failed | 14 passed (17)`. Pins 1, 3 and 4 went red (`expected 'You have 2 notifications' to be '2 件の通知があります'`, and the same shape for zh-CN). The English control, the literal fallback, the one-item pin and the parity pin stayed green, as they should. - Restore: blob after restore `a404506aa183` equals the blob at HEAD, and `git diff HEAD` is empty. Tests import the package source (`./dispatcher.js`) directly, not a built `dist/`, so no rebuild was part of either leg. ## Tests The final head is `0d56c868d`: the fix (`06a8ce4ef`) and its changeset (`490c66405`), with `origin/main` `425c743fd` merged in. That merge brought one commit touching only `plugin-auth`. `git diff 490c664 0d56c86 -- packages/services/service-messaging` is empty. The dependency closure was built first (`pnpm --workspace-concurrency=2 --filter '@objectstack/service-messaging^...' build`, exit 0), then the package itself. - On `0d56c868d`, `pnpm --filter @objectstack/service-messaging test`: `Test Files 55 passed (55)`, `Tests 617 passed (617)`. - On `0d56c868d`, `pnpm --filter @objectstack/service-messaging typecheck` (`tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json`): exit 0. The package tsconfig includes `src`, so the test files are compiled. - Targeted, on `490c66405`: `vitest run src/keyed-notification-text.integration.test.ts src/digest.test.ts` gave `Tests 26 passed (26)`. ## Gates (local subset; CI runs the full farm) - **Derived list.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the final head `0d56c868d` derived 68 commands, the same list as on `490c66405`. Each was run with its exit code captured before any pipe. Result: all 68 exited 0. That includes `pnpm check:i18n` ("all bundles in sync", `services/service-messaging in sync (7 bundle(s))`), `pnpm check:i18n-stale-fill`, `pnpm check:nul-bytes`, `pnpm check:published-files`, `pnpm check:dts-closure`, `pnpm check:dual-build-cjs-loads`, `pnpm check:type-check-coverage` and the changeset gates. - **Reconciliation.** `--ran` gave "68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero because every line records its exit code. - **First round.** On `490c66405`, two gates exited 3 with PREREQUISITE NOT MET: `check:i18n` (the CLI was not built) and `check:dual-build-cjs-loads` (workspace dist missing). The prerequisite closure that `check:i18n` names was then built, and both read 0 in the final-head round above. - **Not run locally.** The CI-owned jobs `dispatch-gates` names outside the 68 (Test Core shards, Build Core, the type-check lanes) are left to CI. So are the repo-wide scans (`pnpm lint`). The `test:repo` project is not touched by this diff. ## Published surface (Clause-② measurement) `src/index.ts` is unchanged, so the `.` entry exports nothing new. In the built `dist/index.d.ts`, the new names (`digestHeading`, `DIGEST_HEADING_KEY`, `messagingMessages`) appear only once, inside a doc comment on a private member. No export, type or option moves. The built `dist/index.js` and `dist/index.cjs` now carry the catalog sentence: a runtime string loaded into the `i18n` service, not an accept set or an export. So the line stays `Clause-②: no`, with `Metadata-protocol: none` because nothing a metadata author writes changes. The changeset is a `patch` on `@objectstack/service-messaging`. ## Acceptance notes - **Plural selection (dormant, carrier: none).** The `i18n` service interpolates `{{name}}` only and has no plural selection. That is enough for every locale shipped today, because the digest heading's count is always two or more. It would not be enough for a locale whose plural form varies above two. No such locale ships, and nothing reads one. - **Item title fallback (observation, carrier: none).** `renderDigest` still titles an item with neither a title nor a topic as the English `Notification`. It is unreachable through `emit()`, because `EmitInput.topic` is required and is snapshotted onto every delivery row. It is not changed here. - **File surface.** As declared in claim 6331845950: `digest-render.ts`, the bundle (`translations/index.ts`, plus the new `translations/messages.ts`), the keyed-text reader (`dispatcher.ts`'s digest path; doc lines only in `notification-text.ts`), the test, and `.changeset/25015-digest-heading-locale.md`. It does not touch `notification-preference.object.ts`, `preference-resolver.ts` or `packages/spec/**`. --- _Generated by [Claude Code](https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 68a57ad commit c24203f

7 files changed

Lines changed: 305 additions & 27 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-messaging': patch
3+
---
4+
5+
fix(service-messaging): the heading of a notification digest of two or more items is rendered in its recipient's language, not as the English "You have N notifications" for everyone
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Before.** A recipient whose notification preference digests `daily` or `weekly` received each window's digest headed "You have N notifications" in English, whatever their language, even when every item under it was rendered in their language.
11+
- **Now.** The heading is keyed (`messages.notificationDigestTitle`, with a `{{count}}` hole) in `@objectstack/service-messaging`'s own translation bundle, in en, zh-CN, ja-JP and es-ES. It is rendered at delivery by the same keyed-text renderer as the digest's items (`EmitInput.keyedText`), in the same locale read: the recipient's `sys_user.locale`, then the deployment default. A zh-CN recipient reads "您有 3 条通知".
12+
- **Unchanged.** The English sentence stays the literal fallback, byte for byte: with no `i18n` service, with no renderer composed, or when neither the recipient's language nor the deployment default has a pack. A one-item digest is still titled by its item. No export, option or type changes.
13+
- **Cost.** With an `i18n` service registered, each digest of two or more items now reads its recipient's locale once, even when none of its items is keyed, because its heading always is.

‎packages/services/service-messaging/src/digest-render.ts‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { NotificationDeliveryRecord } from './outbox.js';
4+
import type { NotificationTextItem } from './notification-text.js';
5+
import { interpolate } from './template-renderer.js';
6+
import { enMessages } from './translations/messages.js';
47

58
/**
69
* One collapsed digest message assembled from a window's batched deliveries
@@ -22,13 +25,46 @@ export interface DigestRenderResult {
2225
}>;
2326
}
2427

28+
/**
29+
* [#25015] The catalog key of the heading a digest of two or more items
30+
* carries: this service's own entry (`./translations/messages.ts`, loaded with
31+
* `MessagingTranslations`), rendered by the one keyed-text renderer
32+
* (`NotificationTextRenderer`, the `EmitInput.keyedText` path) in the digest's
33+
* recipient's language — `sys_user.locale`, then the deployment default
34+
* (#13881). ⛔ There is no second renderer for it.
35+
*/
36+
export const DIGEST_HEADING_KEY = 'messages.notificationDigestTitle';
37+
38+
/**
39+
* [#25015] The heading of a digest of `count` items (two or more), as the
40+
* keyed-text renderer takes it: {@link DIGEST_HEADING_KEY} with its
41+
* `{{count}}`, beside the literal a recipient reads whenever the key cannot be
42+
* rendered — the `en` entry interpolated, byte for byte the English sentence
43+
* every digest carried before its heading was keyed. A digest has no body
44+
* sentence to key; its body is the item list {@link renderDigest} builds.
45+
*/
46+
export function digestHeading(count: number): NotificationTextItem {
47+
const n = String(count);
48+
return {
49+
keys: { titleKey: DIGEST_HEADING_KEY, params: { count: n } },
50+
literal: { title: interpolate(enMessages.notificationDigestTitle, { count: n }), body: '' },
51+
};
52+
}
53+
2554
/**
2655
* Collapse same-`(recipient, channel, window)` deliveries into a single message.
2756
* The caller guarantees `rows` is non-empty and shares a recipient + channel
2857
* (the digest group). Only non-`critical` notifications are ever batched, so the
2958
* digest severity is always `info`.
59+
*
60+
* `heading` is the title of a digest of two or more items as rendered for its
61+
* recipient — {@link digestHeading} through the keyed-text renderer, which the
62+
* dispatcher runs with the group's items in one locale read. Omitted, the
63+
* title is that heading's literal, which is also what the renderer delivers
64+
* when the key cannot render. A one-item digest is titled by its item, and
65+
* `heading` is not read.
3066
*/
31-
export function renderDigest(rows: NotificationDeliveryRecord[]): DigestRenderResult {
67+
export function renderDigest(rows: NotificationDeliveryRecord[], heading?: string): DigestRenderResult {
3268
const items = rows.map((r) => {
3369
const p = r.payload ?? {};
3470
return {
@@ -40,7 +76,7 @@ export function renderDigest(rows: NotificationDeliveryRecord[]): DigestRenderRe
4076
};
4177
});
4278
const count = items.length;
43-
const title = count === 1 ? items[0].title : `You have ${count} notifications`;
79+
const title = count === 1 ? items[0].title : (heading ?? digestHeading(count).literal.title);
4480
const body = items.map((it) => `• ${it.title}`).join('\n');
4581
return { title, body, severity: 'info', count, items };
4682
}

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

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type { MessagingChannel, MessagingChannelContext, Notification, SendResult } from './channel.js';
44
import type { AckResult, ClaimedDeliveryRecord, INotificationOutbox, NotificationDeliveryRecord } from './outbox.js';
55
import { classifyDeliveryAttempt } from './backoff.js';
6-
import { renderDigest } from './digest-render.js';
6+
import { digestHeading, renderDigest, type DigestRenderResult } from './digest-render.js';
77
import { readNotificationTextKeys, type NotificationTextRenderer } from './notification-text.js';
88
import { DispatchLoop } from '@objectstack/core';
99

@@ -295,7 +295,7 @@ export class NotificationDispatcher {
295295
return;
296296
}
297297

298-
const digest = renderDigest(await this.localizedForRecipient(recipient, rows));
298+
const digest = await this.localizedDigest(recipient, rows);
299299
const notification: Notification = {
300300
notificationId: rows[0].notificationId, // representative event id
301301
organizationId: rows[0].organizationId,
@@ -329,18 +329,27 @@ export class NotificationDispatcher {
329329
}
330330

331331
/**
332-
* [#24978] A digest group's rows with each keyed row's title and body
333-
* rendered in the group's ONE recipient's language (one locale read for
334-
* the group), so the items the digest lists read in that language. Rows
335-
* that carry no keys, and every row when no renderer is composed, are
336-
* handed back as they are.
332+
* A digest group's collapsed message, its text rendered in the group's ONE
333+
* recipient's language, with one locale read for the whole group:
334+
*
335+
* - [#24978] each keyed row's title and body, so the items the digest
336+
* lists read in that language — a row that carries no keys is listed as
337+
* it is;
338+
* - [#25015] for a group of two or more, the heading
339+
* ({@link digestHeading}), which this service keys from its own
340+
* translation bundle, so it is ALWAYS keyed: the group's items and its
341+
* heading go to the renderer in one call.
342+
*
343+
* With no renderer composed, every row and the heading are their literal
344+
* text.
337345
*/
338-
private async localizedForRecipient(
346+
private async localizedDigest(
339347
recipient: string,
340348
rows: ClaimedDeliveryRecord[],
341-
): Promise<ClaimedDeliveryRecord[]> {
349+
): Promise<DigestRenderResult> {
350+
const heading = rows.length > 1 ? digestHeading(rows.length) : undefined;
342351
const renderer = this.opts.textRenderer;
343-
if (!renderer) return rows;
352+
if (!renderer) return renderDigest(rows, heading?.literal.title);
344353
const items = rows.map((row) => {
345354
const p = row.payload ?? {};
346355
return {
@@ -351,11 +360,11 @@ export class NotificationDispatcher {
351360
},
352361
};
353362
});
354-
if (!items.some((item) => item.keys)) return rows;
355-
const rendered = await renderer.renderAllFor(recipient, items);
356-
return rows.map((row, i) => items[i].keys
363+
const rendered = await renderer.renderAllFor(recipient, heading ? [...items, heading] : items);
364+
const localized = rows.map((row, i) => items[i].keys
357365
? { ...row, payload: { ...row.payload, title: rendered[i].title, body: rendered[i].body } }
358366
: row);
367+
return renderDigest(localized, heading ? rendered[rows.length].title : undefined);
359368
}
360369

361370
private async processRow(row: ClaimedDeliveryRecord): Promise<void> {

‎packages/services/service-messaging/src/keyed-notification-text.integration.test.ts‎

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@
2626
* else to the emitter's literal — and a key no locale holds is the literal;
2727
* - a literal-only emit is byte-unchanged: the same inbox row, mail, text and
2828
* delivery snapshot as a service with no renderer at all, and no locale read;
29-
* - each item of a digest is rendered in its recipient's language;
29+
* - each item of a digest is rendered in its recipient's language, and so
30+
* is the heading of a digest of two or more (#25015), from this service's
31+
* own bundle: the recipient's locale, then the deployment default, then the
32+
* English literal — while a one-item digest keeps its item's own title;
3033
* - the snapshot reserves `keyedText`: a producer payload member of that name
3134
* is never rendered, and a malformed `keyedText` is refused before anything
3235
* is written.
@@ -47,6 +50,9 @@ import { SqlNotificationOutbox, DELIVERY_OBJECT } from './sql-outbox.js';
4750
import { MemoryNotificationOutbox } from './memory-outbox.js';
4851
import { NotificationDispatcher } from './dispatcher.js';
4952
import { NotificationTextRenderer } from './notification-text.js';
53+
import { DIGEST_HEADING_KEY } from './digest-render.js';
54+
import { MessagingTranslations } from './translations/index.js';
55+
import { enMessages as messagingEnMessages, messagingMessages } from './translations/messages.js';
5056
import type { PreferenceResolver } from './preference-resolver.js';
5157
import {
5258
InboxMessage,
@@ -78,6 +84,8 @@ const USERS = [
7884
{ id: 'u_en', name: 'Ada', email: 'ada@example.com', phone_number: '+15550000001', locale: 'en' },
7985
{ id: 'u_ja', name: 'Sato', email: 'sato@example.com', phone_number: '+81300000001', locale: 'ja-JP' },
8086
{ id: 'u_none', name: 'Nobody', email: 'nobody@example.com', phone_number: '+15550000002' },
87+
// A language no pack in this deployment holds.
88+
{ id: 'u_fr', name: 'Dupont', email: 'dupont@example.com', phone_number: '+33100000001', locale: 'fr-FR' },
8189
];
8290

8391
/** The catalog, as a producer's translation bundle would contribute it. */
@@ -142,10 +150,19 @@ async function boot(): Promise<ObjectQL> {
142150
return ql;
143151
}
144152

145-
/** The kernel's in-memory i18n service with {@link CATALOG} loaded. */
146-
function catalog(opts: { defaultLocale?: string; fallbackLocale?: string } = {}) {
153+
/**
154+
* The kernel's in-memory i18n service with {@link CATALOG} loaded — and, with
155+
* `messagingBundle`, this service's own `MessagingTranslations` too, loaded the
156+
* way the plugin's `kernel:ready` hook loads it.
157+
*/
158+
function catalog(opts: { defaultLocale?: string; fallbackLocale?: string; messagingBundle?: boolean } = {}) {
147159
const i18n = createMemoryI18n();
148160
for (const [locale, data] of Object.entries(CATALOG)) i18n.loadTranslations(locale, data);
161+
if (opts.messagingBundle) {
162+
for (const [locale, data] of Object.entries(MessagingTranslations)) {
163+
i18n.loadTranslations(locale, data as Record<string, unknown>);
164+
}
165+
}
149166
if (opts.defaultLocale) i18n.setDefaultLocale(opts.defaultLocale);
150167
if (opts.fallbackLocale) i18n.setFallbackLocale(opts.fallbackLocale);
151168
return i18n;
@@ -407,6 +424,107 @@ describe('#24978 — keyed notification text, durable path (SQL outbox + dispatc
407424
});
408425
});
409426

427+
describe("#25015 — a digest's heading is rendered in its recipient's language", () => {
428+
/** Every recipient batches every topic into one window, on the memory outbox. */
429+
const digestAll = {
430+
async filter(recipients: string[], channels: string[]) {
431+
return recipients.map((recipient) => ({ recipient, channels, digest: { window: '2026-10-20' } }));
432+
},
433+
} as unknown as PreferenceResolver;
434+
435+
/** Emit `n` keyed notifications to `recipient` on `channels`, then run one dispatcher tick. */
436+
async function digestOf(
437+
ql: ObjectQL,
438+
c: Composition,
439+
recipient: string,
440+
n: number,
441+
channels: string[] = ['inbox'],
442+
): Promise<Array<{ title: string; body_md: string }>> {
443+
for (let i = 0; i < n; i++) {
444+
await c.messaging.emit(emitTo([recipient], { dedupKey: `d${i}`, channels }));
445+
}
446+
await c.dispatcher!.tick();
447+
return inboxOf(ql, recipient);
448+
}
449+
450+
it('a zh-CN recipient reads the heading in zh-CN, over items in zh-CN, on every channel', async () => {
451+
const ql = await boot();
452+
const c = compose(ql, { outbox: 'memory', preferenceResolver: digestAll, i18n: catalog({ messagingBundle: true }) });
453+
454+
const inbox = await digestOf(ql, c, 'u_zh', 3, ['inbox', 'email']);
455+
456+
expect(inbox).toEqual([{ title: '您有 3 条通知', body_md: `• ${ZH.title}\n• ${ZH.title}\n• ${ZH.title}` }]);
457+
expect(c.mail).toEqual([expect.objectContaining({ to: 'zhang@example.com', subject: '您有 3 条通知' })]);
458+
});
459+
460+
it('the English recipient of the same digest reads the English sentence — the control', async () => {
461+
const ql = await boot();
462+
const c = compose(ql, { outbox: 'memory', preferenceResolver: digestAll, i18n: catalog({ messagingBundle: true }) });
463+
464+
const [digest] = await digestOf(ql, c, 'u_en', 2);
465+
466+
expect(digest.title).toBe('You have 2 notifications');
467+
});
468+
469+
it('a recipient with no locale reads the deployment default', async () => {
470+
const ql = await boot();
471+
const c = compose(ql, {
472+
outbox: 'memory', preferenceResolver: digestAll, deploymentDefault: 'ja-JP',
473+
i18n: catalog({ messagingBundle: true, defaultLocale: 'ja-JP' }),
474+
});
475+
476+
const [digest] = await digestOf(ql, c, 'u_none', 2);
477+
478+
expect(digest.title).toBe('2 件の通知があります');
479+
});
480+
481+
it("a recipient whose language no pack holds reads the deployment default's pack", async () => {
482+
const ql = await boot();
483+
const c = compose(ql, {
484+
outbox: 'memory', preferenceResolver: digestAll, deploymentDefault: 'zh-CN',
485+
i18n: catalog({ messagingBundle: true, defaultLocale: 'zh-CN' }),
486+
});
487+
488+
const [digest] = await digestOf(ql, c, 'u_fr', 2);
489+
490+
expect(digest.title).toBe('您有 2 条通知');
491+
});
492+
493+
it('when neither the recipient\'s language nor the deployment default has a pack, the English literal — said once', async () => {
494+
const ql = await boot();
495+
const c = compose(ql, {
496+
outbox: 'memory', preferenceResolver: digestAll, deploymentDefault: 'fr-FR',
497+
i18n: catalog({ messagingBundle: true, defaultLocale: 'fr-FR' }),
498+
});
499+
500+
const [digest] = await digestOf(ql, c, 'u_fr', 2);
501+
502+
expect(digest.title).toBe('You have 2 notifications');
503+
expect(c.warnings.filter((w) => w.includes(`'${DIGEST_HEADING_KEY}'`) && w.includes('fr-FR'))).toHaveLength(1);
504+
});
505+
506+
it("a one-item digest keeps its item's own title, rendered in the recipient's language", async () => {
507+
const ql = await boot();
508+
const c = compose(ql, { outbox: 'memory', preferenceResolver: digestAll, i18n: catalog({ messagingBundle: true }) });
509+
510+
const inbox = await digestOf(ql, c, 'u_zh', 1);
511+
512+
expect(inbox).toEqual([{ title: ZH.title, body_md: `• ${ZH.title}` }]);
513+
});
514+
515+
it('every shipped locale carries every messaging sentence, with the same holes, and the bundle serves them', async () => {
516+
const holes = (text: string) => [...text.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]).sort();
517+
expect(Object.keys(messagingMessages).sort()).toEqual(Object.keys(MessagingTranslations).sort());
518+
for (const [locale, messages] of Object.entries(messagingMessages)) {
519+
expect(Object.keys(messages).sort(), locale).toEqual(Object.keys(messagingEnMessages).sort());
520+
for (const [id, text] of Object.entries(messages)) {
521+
expect(holes(text), `${locale} ${id}`).toEqual(holes(messagingEnMessages[id as keyof typeof messagingEnMessages]));
522+
}
523+
expect(MessagingTranslations[locale]?.messages, locale).toEqual(messages);
524+
}
525+
});
526+
});
527+
410528
describe('#24978 — the contract is closed', () => {
411529
it('a malformed keyedText is refused before anything is written', async () => {
412530
const ql = await boot();

‎packages/services/service-messaging/src/notification-text.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
*
3939
* At delivery, per recipient, ahead of the channel: the inline fan-out renders
4040
* before each `send()`, and the dispatcher renders each claimed row (and each
41-
* row of a digest group) before it builds the channel's `Notification`. So
41+
* row of a digest group, with that digest's heading) before it builds the
42+
* channel's `Notification`. So
4243
* every channel — inbox, email, SMS, and any channel a third party mounts —
4344
* receives the rendered `title` / `body` it already reads, and no channel had
4445
* to learn the contract.
@@ -53,7 +54,9 @@
5354
*
5455
* One `sys_user` read per keyed delivery (one per digest group), and none at
5556
* all for a delivery that carries no keys or a deployment with no `i18n`
56-
* service. The email and SMS channels read the same row again for the address;
57+
* service. A digest of two or more items always carries a key — its heading,
58+
* which this service keys from its own bundle (#25015, `digest-render.ts`) —
59+
* so each such group costs its one read. The email and SMS channels read the same row again for the address;
5760
* the two reads resolve one chain, so they cannot disagree on a language.
5861
*/
5962

0 commit comments

Comments
 (0)