Repository navigation
fix(service-messaging): a notification digest's heading is rendered in its recipient's language - #25170
Conversation
…language The heading of a digest of two or more items was the English literal 'You have N notifications' for every recipient. It is now keyed from service-messaging's own translation bundle (messages.notificationDigestTitle, en/zh-CN/ja-JP/es-ES) and rendered by the EmitInput.keyedText renderer in the same call, and the same locale read, as the group's items. The English sentence stays the literal fallback, byte for byte. Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
…gest-heading-locale Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 66c61184ba38cf9343343f44dee62a459ab3d9c4 && git checkout 66c61184ba38cf9343343f44dee62a459ab3d9c4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e3aa111ad979cf019dda166ca0c6cf5a06946631 0d56c868d5ac7a760a8a37ce5a58f19c48b15b80 && git checkout -B drift-repro e3aa111ad979cf019dda166ca0c6cf5a06946631 && git merge --no-ff 0d56c868d5ac7a760a8a37ce5a58f19c48b15b80
node scripts/docs-audit/affected-docs.mjs --json e3aa111ad979cf019dda166ca0c6cf5a06946631
|
席位核对
Landing: ordinary queue landing. The seat sets ready and auto-merge through the relay in this act. After the merge, #25015 closes by Changeset sentences checked against the diff:
All hold. Generated by Claude Code |
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.keyedTextrenderer, the same one and the same locale read as the items. There is no second mechanism.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-authoredmessagesface (src/translations/messages.ts), in the shape the approvals and audit plugins use for their notification sentences. The plugin's existingkernel:readyhook loads it with the rest of the bundle.NotificationDispatcher.processDigestGroupnow builds the digest throughlocalizedDigest(). For a group of two or more, the heading (digestHeading(count): the key, its{{count}}, and the literal) goes toNotificationTextRenderer.renderAllForin the same call as the group's items. So one locale read covers the group:sys_user.locale, then the deployment default ([Decision] Per-user notification locale —— 2026-08-13 裁决所等的「实测拉力」已到(hotcrm:4 个已发布语言 × 16 个 notify 节点 × 0 可本地化) #13881). It is negotiated against thei18nservice's locales by the renderer's existingresolveBundleLocalestep.enentry interpolated, byte for byte the old sentence ("You have 3 notifications"). A recipient reads it with noi18nservice, 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.digest,count,items) and the actor rule are untouched. No export, option or type changes.renderDigestis internal and only gains an optionalheadingargument.Why no plural mechanism was needed (PM assumption 2, measured)
Neither
II18nServiceimplementation selects a plural form.createMemoryI18n().t()(packages/core/src/fallbacks/memory-i18n.ts) andFileI18nAdapter.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 thei18nservice. That is recorded under Acceptance notes and inmessages.ts, and not built.Where the producer is (PM assumption 1, measured on
06900c9c2)digest-render.ts:43held the literal, andNotificationDispatcher.processDigestGroup(dispatcher.ts:286) reached it for every digest group of two or more rows. The fix lands there, in the declared file surface.main(notification-text.ts;readNotificationTextKeysatdispatcher.ts:347and:382). Its renderer is reused unchanged.The cost change
With an
i18nservice 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 innotification-text.tssays so.Pins
All pins are in
packages/services/service-messaging/src/keyed-notification-text.integration.test.ts, a new#25015block of seven cases. They run on the file's existing real stack: ObjectQL over SqlDriver (better-sqlite3), the kernel's in-memoryi18nholding this package's realMessagingTranslations(loaded the way the plugin'skernel:readyhook loads it), the realMessagingService, the inbox and email channels, the memory outbox, and the dispatcher.您有 3 条通知over three zh-CN items, in the inbox row and in the mail subject.You have 2 notifications.2 件の通知があります.{{holes}}, andMessagingTranslationsserves exactly those entries per locale.The existing
digest.test.tscases, with no renderer composed, still readYou have 3 notificationsandYou have 2 notifications. They are the byte-unchanged pin for the literal.Ablation (one-off, no permanent file)
Run on head
490c66405with the fix committed (the package is byte-identical at the final head0d56c868d), throughnode scripts/ablation-replace.mjsunder the verify lock. The mutation was indispatcher.ts:return renderDigest(localized, heading ? rendered[rows.length].title : undefined);becamereturn renderDigest(localized, undefined);, which drops the keyed heading. On disk: anchor 1 to 0, replacement 0 to 1, bloba404506aa183toafccb98e9e01.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.a404506aa183equals the blob at HEAD, andgit diff HEADis empty.Tests import the package source (
./dispatcher.js) directly, not a builtdist/, so no rebuild was part of either leg.Tests
The final head is
0d56c868d: the fix (06a8ce4ef) and its changeset (490c66405), withorigin/main425c743fdmerged in. That merge brought one commit touching onlyplugin-auth.git diff 490c66405 0d56c868d -- packages/services/service-messagingis empty. The dependency closure was built first (pnpm --workspace-concurrency=2 --filter '@objectstack/service-messaging^...' build, exit 0), then the package itself.0d56c868d,pnpm --filter @objectstack/service-messaging test:Test Files 55 passed (55),Tests 617 passed (617).0d56c868d,pnpm --filter @objectstack/service-messaging typecheck(tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json): exit 0. The package tsconfig includessrc, so the test files are compiled.490c66405:vitest run src/keyed-notification-text.integration.test.ts src/digest.test.tsgaveTests 26 passed (26).Gates (local subset; CI runs the full farm)
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon the final head0d56c868dderived 68 commands, the same list as on490c66405. Each was run with its exit code captured before any pipe. Result: all 68 exited 0. That includespnpm 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-coverageand the changeset gates.--rangave "68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero because every line records its exit code.490c66405, two gates exited 3 with PREREQUISITE NOT MET:check:i18n(the CLI was not built) andcheck:dual-build-cjs-loads(workspace dist missing). The prerequisite closure thatcheck:i18nnames was then built, and both read 0 in the final-head round above.dispatch-gatesnames outside the 68 (Test Core shards, Build Core, the type-check lanes) are left to CI. So are the repo-wide scans (pnpm lint). Thetest:repoproject is not touched by this diff.Published surface (Clause-② measurement)
src/index.tsis unchanged, so the.entry exports nothing new. In the builtdist/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 builtdist/index.jsanddist/index.cjsnow carry the catalog sentence: a runtime string loaded into thei18nservice, not an accept set or an export. So the line staysClause-②: no, withMetadata-protocol: nonebecause nothing a metadata author writes changes. The changeset is apatchon@objectstack/service-messaging.Acceptance notes
i18nservice 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.renderDigeststill titles an item with neither a title nor a topic as the EnglishNotification. It is unreachable throughemit(), becauseEmitInput.topicis required and is snapshotted onto every delivery row. It is not changed here.digest-render.ts, the bundle (translations/index.ts, plus the newtranslations/messages.ts), the keyed-text reader (dispatcher.ts's digest path; doc lines only innotification-text.ts), the test, and.changeset/25015-digest-heading-locale.md. It does not touchnotification-preference.object.ts,preference-resolver.tsorpackages/spec/**.Generated by Claude Code