You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit c24203f
Browse filesBrowse the repository at this point in the historyBrowse 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 490c6640d56c86 -- 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>
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.
0 commit comments