Skip to content

Commit bcdb1db

Browse files
fix(spec,messaging): a push device's locale is described as stored, not as the language pushes render in (#30544)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg5yARdbrWsd674TB3515nv2)_ domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF ## 维护者速读 - 改了什么:推送设备 `locale` 的三处描述(设备 schema、设备注册请求、`sys_user_device.locale` 字段及其四种语言的帮助文字)改成如实说法:它只随设备保存,推送不按它渲染,推送用收件人的语言(用户语言,其次部署默认)。 - 为什么改:原描述承诺"推送按设备语言渲染",实际没有任何代码这样做;手机设成日文、账号设成英文的用户收到的是英文推送。契约说了做不到的事(ADR-0049:要么兑现要么删掉),席位选了最小的方案 1。 - 风险与回滚:只改描述文字和一个测试,运行行为零变化,调用方发什么都照常收;回滚即 revert 本 PR。 - 先看:`packages/spec/src/system/push-device.zod.ts`(locale 一行)、`packages/spec/src/api/protocol.zod.ts`(RegisterDeviceRequest.locale 一行)、`packages/services/service-messaging/src/keyed-notification-text.integration.test.ts`(末尾新 describe)。 - 两分钟验证:`pnpm --filter @objectstack/service-messaging exec vitest run src/keyed-notification-text.integration.test.ts`,预期 20/20 通过,其中 3 条是新钉的"推送按用户语言而非设备语言"。 - 需要你做的:无,席位落地。 Fixes #29818 Clause-②: no Metadata-protocol: none ## What changes - `UserDeviceSchema.locale` (`packages/spec/src/system/push-device.zod.ts`): was "Device locale … that pushes to this device are rendered in". Now: stored for the device, as the registration named it; no push renders by it; a push arrives in the recipient's language (`sys_user.locale`, then the deployment default). - `RegisterDeviceRequestSchema.locale` (`packages/spec/src/api/protocol.zod.ts`): the same promise in the request contract a client sends, corrected the same way. **Scope extension approved by the seat (14:22Z):** the dispatch named one spec description; this twin carries the identical wording and defect, so it rides in this PR. - `sys_user_device.locale` field description (`packages/services/service-messaging/src/objects/user-device.object.ts`) says the same; the messaging i18n bundle's `help` for that field is regenerated in `en` (`check-i18n-bundles --write`) and its hand-written `zh-CN` / `ja-JP` / `es-ES` translations are rewritten to the new meaning (they otherwise kept translating the old promise). Declared to and approved by the seat. - Regenerated: `content/docs/references/api/protocol.mdx`, `content/docs/references/system/push-device.mdx` (`gen:docs`). No other spec artifact moved (`check:generated` 14/14 current). - Changeset: `patch` for `@objectstack/spec` and `@objectstack/service-messaging` (`Metadata-protocol: none`). - Not done (option 2 of the card, a widening): no `push` row in `TEMPLATE_CHANNELS`, no per-device rendering. ## Tests A new top-level `describe` in `keyed-notification-text.integration.test.ts` (the shared keyed-text harness: real `ObjectQL` over `SqlDriver`/better-sqlite3, the kernel's in-memory i18n, the real `MessagingService`), adding the real push channel over the real device registry (`sys_user_device`) on a push-service double that records each token's title and body. Each recipient's phone is registered with a locale that is not the recipient's: the `zh-CN` user's phone in `en`, the `en` user's phone in `zh-CN`, the no-locale user's phone in `ja-JP`. - the device row keeps the locale it was registered with; - inline fan-out: the `zh-CN` user's phone gets the `zh-CN` sentence, the `en` user's phone the English one, and the no-locale user's phone the deployment default's (`zh-CN`), not `ja-JP`; - durable path (SQL outbox + dispatcher): the same, rendered at delivery. Measured on `main` (fa9c0aa) before any description change: the test was green, so the card's premise holds (pushes render by the recipient, never the device). ## Acceptance notes - The push relay's `text` member (`packages/spec/src/api/push-relay.zod.ts:78`) also says the line is shown "in the device locale". No producer of it lives in this repository (the relay is cloud's), so nothing here was measured against it; untouched, carrier: cloud. ## Verification All readings at `e541b3721` (head; `origin/main` was still `fa9c0aa2`, so the merge was a no-op). <details><summary>Tests</summary> - `pnpm --filter @objectstack/service-messaging test`: Test Files 66 passed (66), Tests 815 passed (815); `typecheck` exit 0 (lock VERDICT command-exit 0). - `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2`: Test Files 905 passed (905), Tests 27161 passed, 1 todo (lock VERDICT command-exit 0). - The new pins alone: `keyed-notification-text.integration.test.ts` 20/20, first green on `main`'s code before any description edit (commit `8ab75e9f`). </details> <details><summary>Derived gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 107 families; each run with its exit recorded before any pipe; `--ran`: 107 accounted, 106 run, 1 NOT-MEASURED. - NOT MEASURED: `pnpm check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET: needs every package's `dist/`; left to CI). - First runs of `check-plugin-teardown-shape --self-test` and `check:skill-examples` exited 3 (shallow history; `@objectstack/client` and `client-react` not built); both re-run green after the prerequisite (`git fetch` of the fixture commit; building those two packages). - `pnpm --filter @objectstack/spec check:generated`: all 14 artifacts current. `node scripts/check-i18n-bundles.mjs --write`: exit 0, moved only `en.objects.generated.ts`. - `pnpm lint`: exit 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZqHFs5GUFFGRbLgxy)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 27b0bc7 commit bcdb1db

11 files changed

Lines changed: 91 additions & 10 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/service-messaging': patch
4+
---
5+
6+
fix(spec,messaging): a push device's `locale` is described as what it is — stored for the device, and no push renders by it
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **`@objectstack/spec`**: `UserDeviceSchema.locale` and `RegisterDeviceRequestSchema.locale` no longer say pushes to the device are rendered in its locale. They now say the value is stored for the device and that a push arrives in the recipient's language: `sys_user.locale`, then the deployment default. Description text only; nothing a caller sends is accepted or refused differently.
12+
- **`@objectstack/service-messaging`**: the `sys_user_device.locale` field description says the same, in all four shipped languages. A test now pins it end to end: with the real push channel over the real device registry, a phone registered with a locale other than its user's receives the push in the user's language, on the inline fan-out and on the durable outbox path alike.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2777,7 +2777,7 @@ Register or refresh a push device for the signed-in user
27772777
| **name** | `string` | optional | Device friendly name |
27782778
| **vendor** | `Enum<'apns' \| 'fcm'>` | optional | Push vendor the token belongs to: apns or fcm. Absent, the server derives it from platform: ios means apns, android means fcm. web has no default vendor: a web registration names apns or fcm, and one without vendor is refused |
27792779
| **appId** | `string` | optional | App the token was issued to: the iOS bundle ID or the Android application ID |
2780-
| **locale** | `string` | optional | Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in |
2780+
| **locale** | `string` | optional | Device locale (BCP 47, for example zh-CN). Stored on the device row; no push renders by it: a push arrives in the recipient's language (sys_user.locale, then the deployment default) |
27812781
| **origin** | `string` | optional | [PLANNED — not yet stored] The origin the Console was loaded from (window.location.origin, for example https://crm.example.com): the address the mobile shell saved for this server. The Console writes it; the server stores it on the device row and every push to the device carries it back, so a shell with several saved servers opens the push on the one that sent it. http or https, a lowercase host and an optional port; a path, another scheme or a bare host is refused |
27822782

27832783

‎content/docs/references/system/push-device.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ A push device of one user: one row per app install
3636
| **token** | `string` | ✅ | Vendor push token. Held by one live device at a time: a second user's registration moves it |
3737
| **platform** | `Enum<'ios' \| 'android' \| 'web'>` | ✅ | Device platform, as the registration named it |
3838
| **appId** | `string` | optional | App the token was issued to: the iOS bundle ID or the Android application ID |
39-
| **locale** | `string` | optional | Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in |
39+
| **locale** | `string` | optional | Device locale (BCP 47, for example zh-CN), as the registration named it. Stored for the device; no push renders by it: a push arrives in the recipient's language (sys_user.locale, then the deployment default) |
4040
| **name** | `string` | optional | Device friendly name, as the registration named it |
4141
| **origin** | `string` | optional | [PLANNED — not yet stored] The origin the Console was loaded from, as the registration named it (for example https://crm.example.com). The device registry keeps it on the row and the push channel copies it into every push to this device, where the mobile shell picks the saved server by it. Absent on a device registered without one |
4242
| **enabled** | `boolean` | ✅ | False once the vendor reported the token dead; nothing is delivered to a disabled device |

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

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { MessagingService, NOTIFICATION_EVENT_OBJECT, type EmitInput } from './m
4545
import { createInboxChannel, INBOX_OBJECT } from './inbox-channel.js';
4646
import { createEmailChannel } from './email-channel.js';
4747
import { createSmsChannel } from './sms-channel.js';
48+
import { createPushChannel, type PushSenderSurface } from './push-channel.js';
4849
import { NotificationTemplateStore } from './template-renderer.js';
4950
import { SqlNotificationOutbox, DELIVERY_OBJECT } from './sql-outbox.js';
5051
import { MemoryNotificationOutbox } from './memory-outbox.js';
@@ -60,6 +61,7 @@ import {
6061
NotificationPreference,
6162
NotificationReceipt,
6263
NotificationTemplate,
64+
UserDevice,
6365
} from './objects/index.js';
6466

6567
const SYSTEM = { context: { isSystem: true } as ExecutionContext };
@@ -138,7 +140,7 @@ async function boot(): Promise<ObjectQL> {
138140
await ql.init();
139141
for (const def of [
140142
SYS_ORGANIZATION, SYS_USER, SysNotification, InboxMessage, NotificationReceipt,
141-
NotificationDelivery, NotificationPreference, NotificationTemplate,
143+
NotificationDelivery, NotificationPreference, NotificationTemplate, UserDevice,
142144
]) {
143145
ql.registry.registerObject(def as any, '@objectstack/service-messaging');
144146
}
@@ -543,3 +545,63 @@ describe('#24978 — the contract is closed', () => {
543545
expect(await ql.find(NOTIFICATION_EVENT_OBJECT, { fields: ['id'] }, SYSTEM)).toEqual([]);
544546
});
545547
});
548+
549+
describe("a push reaches the recipient in the recipient's language, not in its device's locale", () => {
550+
type Pushed = Parameters<PushSenderSurface['send']>[0];
551+
552+
/**
553+
* The real push channel over the real device registry (`sys_user_device`),
554+
* on a push-service double that records what each device token was sent.
555+
* Each recipient's phone is registered with a locale that is not the
556+
* recipient's own: the zh-CN user's phone in `en`, the `en` user's in
557+
* `zh-CN`, the user with no locale's in `ja-JP`.
558+
*/
559+
async function withPush(c: Composition): Promise<Pushed[]> {
560+
const pushed: Pushed[] = [];
561+
c.messaging.registerChannel(createPushChannel({
562+
getPush: () => ({
563+
vendors: () => ['apns'],
564+
async send(m) { pushed.push(m); return { status: 'sent', messageId: `push_${pushed.length}` }; },
565+
}),
566+
devices: c.messaging,
567+
}));
568+
await c.messaging.registerDevice('u_zh', { token: 'tok-zh', platform: 'ios', deviceId: 'phone-zh', locale: 'en' });
569+
await c.messaging.registerDevice('u_en', { token: 'tok-en', platform: 'ios', deviceId: 'phone-en', locale: 'zh-CN' });
570+
await c.messaging.registerDevice('u_none', { token: 'tok-none', platform: 'ios', deviceId: 'phone-none', locale: 'ja-JP' });
571+
return pushed;
572+
}
573+
574+
const toToken = (pushed: Pushed[], token: string) =>
575+
pushed.filter((m) => m.token === token).map((m) => ({ title: m.title, body: m.body }));
576+
577+
it('the device row keeps the locale it was registered with — stored, and read by nothing on the way out', async () => {
578+
const ql = await boot();
579+
await withPush(compose(ql));
580+
const rows = (await ql.find('sys_user_device', { fields: ['user_id', 'locale'] }, SYSTEM)) as any[];
581+
expect(rows.map((r) => [r.user_id, r.locale]).sort()).toEqual([['u_en', 'zh-CN'], ['u_none', 'ja-JP'], ['u_zh', 'en']]);
582+
});
583+
584+
it('inline fan-out: each phone gets its user\'s sentence, then the deployment default for a user with none', async () => {
585+
const ql = await boot();
586+
const c = compose(ql, { deploymentDefault: 'zh-CN' });
587+
const pushed = await withPush(c);
588+
589+
await c.messaging.emit(emitTo(['u_zh', 'u_en', 'u_none'], { channels: ['push'] }));
590+
591+
expect(toToken(pushed, 'tok-zh')).toEqual([ZH]); // device says en
592+
expect(toToken(pushed, 'tok-en')).toEqual([LITERAL]); // device says zh-CN
593+
expect(toToken(pushed, 'tok-none')).toEqual([ZH]); // device says ja-JP; the deployment default wins
594+
});
595+
596+
it('durable path (SQL outbox + dispatcher): the same, rendered at delivery', async () => {
597+
const ql = await boot();
598+
const c = compose(ql, { outbox: 'sql' });
599+
const pushed = await withPush(c);
600+
601+
expect((await c.messaging.emit(emitTo(['u_zh', 'u_en'], { channels: ['push'] }))).enqueued).toBe(2);
602+
await c.dispatcher!.tick();
603+
604+
expect(toToken(pushed, 'tok-zh')).toEqual([ZH]);
605+
expect(toToken(pushed, 'tok-en')).toEqual([LITERAL]);
606+
});
607+
});

‎packages/services/service-messaging/src/objects/user-device.object.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@ export const UserDevice = ObjectSchema.create({
117117
required: false,
118118
// BCP 47 tags are short; 35 is the RFC 5646 practical maximum.
119119
maxLength: 35,
120-
description: 'Device locale that pushes to this device are rendered in.',
120+
description: 'Device locale, as the registration named it. Stored for the device; no push renders by it: a push '
121+
+ 'arrives in the recipient\'s language (their user locale, then the deployment default).',
121122
}),
122123

123124
name: Field.text({

‎packages/services/service-messaging/src/translations/en.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
460460
},
461461
locale: {
462462
label: "Locale",
463-
help: "Device locale that pushes to this device are rendered in."
463+
help: "Device locale, as the registration named it. Stored for the device; no push renders by it: a push arrives in the recipient's language (their user locale, then the deployment default)."
464464
},
465465
name: {
466466
label: "Device Name"

‎packages/services/service-messaging/src/translations/es-ES.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
460460
},
461461
locale: {
462462
label: "Configuración regional",
463-
help: "Configuración regional del dispositivo en la que se muestran las notificaciones push enviadas a él."
463+
help: "Configuración regional del dispositivo, tal como la indicó el registro. Se guarda para el dispositivo; ninguna notificación push se muestra según ella: llega en el idioma del destinatario (su configuración regional de usuario y, si no, la predeterminada de la implantación)."
464464
},
465465
name: {
466466
label: "Nombre del dispositivo"

‎packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
460460
},
461461
locale: {
462462
label: "ロケール",
463-
help: "このデバイスへのプッシュ通知の表示に使うデバイスのロケール。"
463+
help: "登録時に指定されたデバイスのロケール。デバイスに保存されますが、プッシュ通知の表示には使われません。プッシュ通知は受信者の言語(ユーザーのロケール、次にデプロイメントの既定)で届きます。"
464464
},
465465
name: {
466466
label: "デバイス名"

‎packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
460460
},
461461
locale: {
462462
label: "语言区域",
463-
help: "发送到此设备的推送所使用的设备语言区域。"
463+
help: "注册时提交的设备语言区域,随设备保存;推送不按它渲染:推送以收件人的语言送达(其用户语言,其次部署默认语言)。"
464464
},
465465
name: {
466466
label: "设备名称"

‎packages/spec/src/api/protocol.zod.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3713,7 +3713,10 @@ export const RegisterDeviceRequestSchema = lazySchema(() => z.object({
37133713
+ 'android means fcm. web has no default vendor: a web registration names apns or fcm, and one without vendor is refused',
37143714
),
37153715
appId: z.string().optional().describe('App the token was issued to: the iOS bundle ID or the Android application ID'),
3716-
locale: LocaleSchema.optional().describe('Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in'),
3716+
locale: LocaleSchema.optional().describe(
3717+
'Device locale (BCP 47, for example zh-CN). Stored on the device row; no push renders by it: a push arrives in the '
3718+
+ 'recipient\'s language (sys_user.locale, then the deployment default)',
3719+
),
37173720
origin: z.string().regex(PUSH_SERVER_ORIGIN_PATTERN, { message: PUSH_SERVER_ORIGIN_MESSAGE }).optional().describe(
37183721
'[PLANNED — not yet stored] The origin the Console was loaded from (window.location.origin, for example '
37193722
+ 'https://crm.example.com): the address the mobile shell saved for this server. The Console writes it; the server '

0 commit comments

Comments
 (0)