Skip to content

Commit ea484a4

Browse files
author
os-zhuang
committed
docs(spec,i18n): GET /i18n/locales stops declaring label a display name
`GetLocalesResponseSchema` described each locale descriptor's `label` as "Display name of the locale" while the sole producer — `toLocaleDescriptors` in `system/i18n-resolver.ts`, shared deliberately by the runtime dispatcher's `/i18n` domain and service-i18n's autonomous route — sets it to the code. `GET /api/v1/i18n/locales` answers `{ code: 'th', label: 'th' }`, never `{ code: 'th', label: 'ไทย' }`. Declared not enforced (ADR-0049), one field wide, and the describe carries the claim into the JSON Schema, the OpenAPI surface and the SDK type. Measured before choosing the fix: no consumer anywhere reads `label`. In this repo every read of the body takes `code` or `isDefault`; the one wire fixture spelling `label` sets it to the code and asserts only the array length. In objectui the one real consumer, `apps/console/src/loadLocales.ts`, reads `entry?.code` and documents that the descriptor's label is not a display name (objectui#4039). With nothing consuming the field, the honest declaration is the whole fix — runtime behaviour is unchanged. The declaration now states the convention it ships, and `i18n-resolver.test.ts` pins both sides of it: a producer that starts inventing display names and a describe that starts promising them each go red separately. Serving real display names (CLDR data on the server for something every client computes) and retiring the field are both left open on #7634 — neither has a ruling. Fixes #7634
1 parent 3efe6f2 commit ea484a4

4 files changed

Lines changed: 123 additions & 3 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec,i18n): `GET /i18n/locales` stops declaring `label` a display name
6+
7+
`GetLocalesResponseSchema` described each locale descriptor's `label` as
8+
"Display name of the locale", and no producer has ever written one. The sole
9+
producer is `toLocaleDescriptors` (`system/i18n-resolver.ts`) — deliberately
10+
shared by the runtime dispatcher's `/i18n` domain and `service-i18n`'s
11+
autonomous route, so there is no second implementation to diverge — and it sets
12+
`label` to the code. `GET /api/v1/i18n/locales` answers `{ code: 'th', label:
13+
'th' }`, never `{ code: 'th', label: 'ไทย' }`. Declared not enforced (ADR-0049),
14+
one field wide, and the describe is what carries the claim into the generated
15+
JSON Schema, the OpenAPI surface and the SDK type — so a client that trusts it
16+
renders locale codes at users and only finds out by looking. objectui#4039 hit
17+
exactly that and routed around the field: the console's language menu reads
18+
`code` alone off this body and names locales from its own built-in table plus
19+
`Intl.DisplayNames`.
20+
21+
Patch, and describe-only. The measurement behind that: **no consumer anywhere
22+
reads `label`**. In this repo every read of the body takes `code` or
23+
`isDefault` (`http-dispatcher.test.ts`, `domain-handler-registry.test.ts`,
24+
`i18n-success-envelope.conformance.test.ts`); the one wire fixture that spells
25+
`label` sets it to the code and asserts only the array length. In objectui the
26+
one real consumer, `apps/console/src/loadLocales.ts`, reads `entry?.code` and
27+
documents in its header that the descriptor's label is not a display name. With
28+
nothing consuming the field, the honest declaration is the whole fix: the
29+
runtime behaviour is unchanged, and only the field's documented meaning moves.
30+
31+
So the declaration now states the convention it ships — `label` equals `code`;
32+
naming a locale for a UI is the client's job, where `Intl.DisplayNames` already
33+
lives and where the choice of *which* language to name it in belongs. The two
34+
alternatives are deliberately not taken here: serving real display names is a
35+
capability addition with no measured pull (CLDR data on the server for
36+
something every client can compute), and retiring the field is a heavier
37+
response-contract action. Both stay open on #7634.
38+
39+
`toLocaleDescriptors`' output and the declaration are now pinned against each
40+
other in `i18n-resolver.test.ts`, on both sides — a producer that starts
41+
inventing display names and a describe that starts promising them each turn it
42+
red separately.

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1713,10 +1713,37 @@ export const RejectAiPendingActionResponseSchema = lazySchema(() => z.object({
17131713

17141714
export const GetLocalesRequestSchema = lazySchema(() => z.object({}));
17151715

1716+
/**
1717+
* `GET /api/v1/i18n/locales` — the available locale set.
1718+
*
1719+
* `label` is the locale CODE, echoed back. Every descriptor on every serving
1720+
* surface comes from one helper — `toLocaleDescriptors`
1721+
* (`system/i18n-resolver.ts`), shared by the runtime dispatcher's `/i18n`
1722+
* domain and `service-i18n`'s autonomous route — and it sets `label` to the
1723+
* code, because nothing in the tree carries a locale name to set it from.
1724+
*
1725+
* This describe used to read "Display name of the locale" while no producer
1726+
* ever wrote one: the declared-not-enforced shape ADR-0049 is about, one field
1727+
* wide. A client that trusted it rendered `th` where `ไทย` belongs — which is
1728+
* what objectui#4039 hit, and why the console routes around the field
1729+
* entirely: its language menu reads `code` alone off this body and names
1730+
* locales from its own built-in table plus `Intl.DisplayNames`
1731+
* (`apps/console/src/loadLocales.ts`). So #7634 made the declaration state the
1732+
* convention it actually ships, and `i18n-resolver.test.ts` pins both halves —
1733+
* the values the producer emits, and this text promising them.
1734+
*
1735+
* Naming a locale for a UI stays the client's job: `Intl.DisplayNames` is in
1736+
* every runtime that matters, and *which* language to name it in (its own, or
1737+
* the requester's `Accept-Language`) is a caller's choice the server cannot
1738+
* make for it. Serving real names would put CLDR data behind an endpoint for
1739+
* something every client can already compute, and no consumer asks for it.
1740+
*/
17161741
export const GetLocalesResponseSchema = lazySchema(() => z.object({
17171742
locales: z.array(z.object({
17181743
code: z.string().describe('BCP-47 locale code (e.g., en-US, zh-CN)'),
1719-
label: z.string().describe('Display name of the locale'),
1744+
label: z.string().describe(
1745+
'Locale label. Equals `code` on every serving surface today — the client names locales for its UI (#7634)',
1746+
),
17201747
isDefault: z.boolean().default(false).describe('Whether this is the default locale'),
17211748
})).describe('Available locales'),
17221749
}));

packages/spec/src/system/i18n-resolver.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1637,4 +1637,43 @@ describe('toLocaleDescriptors', () => {
16371637
expect(toLocaleDescriptors(undefined, 'en')).toEqual([]);
16381638
expect(toLocaleDescriptors([], 'en')).toEqual([]);
16391639
});
1640+
1641+
it('sets `label` to the code, and the declaration promises exactly that (#7634)', () => {
1642+
// The SUBSTANCE pin: producer output vs the DECLARED shape, both halves.
1643+
//
1644+
// `GetLocalesResponseSchema` described `label` as "Display name of the
1645+
// locale" while this helper — the ONLY producer, shared by the
1646+
// dispatcher's `/i18n` domain and service-i18n's route — echoed the code
1647+
// back. Declared ≠ enforced, one field wide (ADR-0049), and it misleads in
1648+
// exactly one direction: a client that trusts the describe renders `th`
1649+
// where `ไทย` belongs. objectui#4039 hit that and routed around the field
1650+
// (`apps/console/src/loadLocales.ts` reads `code` alone).
1651+
//
1652+
// Two assertions because the drift has two sides. Restoring `label: code`
1653+
// to a real display name turns the first red; restoring the describe's
1654+
// display-name promise turns the second red. Neither can move alone.
1655+
const out = toLocaleDescriptors(['en', 'zh-CN', 'ja-JP', 'th'], 'en');
1656+
expect(out).toHaveLength(4);
1657+
for (const descriptor of out) {
1658+
expect(
1659+
descriptor.label,
1660+
`toLocaleDescriptors must set label to the code (${descriptor.code}) — producing a real `
1661+
+ 'display name is a product decision nothing pulls for, and the describe promises the code (#7634)',
1662+
).toBe(descriptor.code);
1663+
}
1664+
1665+
const shape = (GetLocalesResponseSchema as unknown as {
1666+
shape: { locales: { element: { shape: Record<string, { description?: string }> } } };
1667+
}).shape;
1668+
const description = shape.locales.element.shape.label?.description ?? '';
1669+
expect(description.length, 'the `label` field must carry a describe at all').toBeGreaterThan(0);
1670+
expect(
1671+
description,
1672+
'the `label` describe must not promise a display name while every producer sets the code (#7634)',
1673+
).not.toMatch(/display name/i);
1674+
expect(
1675+
description,
1676+
'the `label` describe must state the code-equality convention it actually ships (#7634)',
1677+
).toMatch(/equals `code`/i);
1678+
});
16401679
});

packages/spec/src/system/i18n-resolver.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,7 +1087,11 @@ function lookupObjectFieldAttr(
10871087
export interface LocaleDescriptor {
10881088
/** BCP-47 locale code. */
10891089
code: string;
1090-
/** Display name. Falls back to the code — nothing in the tree carries one. */
1090+
/**
1091+
* Locale label — the CODE, echoed back, not a display name. Nothing in the
1092+
* tree carries a locale name to set it from, and naming a locale for a UI is
1093+
* the client's job (#7634); `GetLocalesResponseSchema` declares it that way.
1094+
*/
10911095
label: string;
10921096
/** Whether this is the stack's default locale. */
10931097
isDefault: boolean;
@@ -1108,7 +1112,15 @@ export interface LocaleDescriptor {
11081112
*
11091113
* `label` is the code: no locale display-name source exists in the tree, and
11101114
* the schema requires the field. Inventing one here (an ICU display-name
1111-
* table) would be a product decision, not an implementation detail.
1115+
* table) would be a product decision, not an implementation detail — and one
1116+
* nothing pulls for: the only real consumer of this body, the console's
1117+
* language menu, reads `code` alone and names locales itself (objectui#4039,
1118+
* `apps/console/src/loadLocales.ts`).
1119+
*
1120+
* `GetLocalesResponseSchema` used to describe the field as a display name
1121+
* anyway — declared ≠ enforced, one field wide. #7634 made the declaration say
1122+
* what this produces instead; the `label === code` convention is pinned in the
1123+
* tests below, on both sides.
11121124
*/
11131125
export function toLocaleDescriptors(
11141126
codes: readonly string[] | undefined,

0 commit comments

Comments
 (0)