Skip to content

Commit 7858ce1

Browse files
committed
feat(spec): resolveI18nLabel — the shared I18nLabelstring resolver (#6765)
`I18nLabelSchema` has authorized two forms of a display label since #5728: a plain string, and an inline locale map. Only ONE end of the platform knew what the second form means — objectui's `pickLocalized`. Every backend producer that had to put a label on the wire tested `typeof label === 'string'` and dropped anything else, so a dataset declaring its dimension label the way the schema authorizes shipped `fields[]` entries with no label at all (#6761's measurements). This adds the missing half in `packages/spec` rather than inside the service that needed it first (maintainer ruling 2026-08-08, #6761 option B): the backend had zero inline-map resolvers, and a first one born as a private fork is what the next producer copies (PD#12). Rule parity with `pickLocalized` is the contract and it is EXECUTED, not asserted: a 26-row vector table is checked against a pinned verbatim copy of the reference implementation first, then against this resolver. The only visible difference is the spelling of a miss — `undefined` here, `''` there — bridged by one `??` and pinned as an identity. Consumption (`AnalyticsService.queryDataset`, `dataset-compiler.ts`) is #6761 and is deliberately not touched here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011sGk4SKHqGRgmmqUok1P8M
1 parent 8b82686 commit 7858ce1

6 files changed

Lines changed: 622 additions & 0 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`resolveI18nLabel` — the shared `I18nLabel``string` resolver, and the first one the backend has
6+
7+
`I18nLabelSchema` has authorized two forms of a display label since #5728: a
8+
plain string, and an inline locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) —
9+
which three published platform pages author 31 times. Only ONE end of the
10+
platform knew what the second form means: objectui's `pickLocalized`. Every
11+
backend producer that had to put a label on the wire tested
12+
`typeof label === 'string'` and dropped anything else, so a dataset that declared
13+
its dimension label the way the schema authorizes shipped `fields[]` entries with
14+
no label at all — or with the machine name published as a display title. The
15+
shape was declared and unreadable on the side that produces it.
16+
17+
`packages/spec/src/ui/i18n-label-resolver.ts` is that missing half:
18+
19+
```ts
20+
import { resolveI18nLabel } from '@objectstack/spec/ui';
21+
22+
resolveI18nLabel({ en: 'Owner', 'zh-CN': '负责人' }, 'zh-CN'); // '负责人'
23+
resolveI18nLabel(dimension.label, locale) ?? dimension.name; // producer shape
24+
```
25+
26+
It lives in `packages/spec` rather than inside the service that needed it first
27+
(maintainer ruling 2026-08-08, #6761 option B): the backend had **zero** inline-map
28+
resolvers, and a first one born as a private fork inside one service is what the
29+
next producer copies (Prime Directive #12).
30+
31+
**Rule parity with `pickLocalized` is the contract, and it is executed, not
32+
asserted.** The resolution rule — exact tag → base language (`zh-CN``zh`) →
33+
first region-qualified sibling sharing the base (`zh``zh-CN`) → `default`
34+
`en` → any string in the map, with `(locale || 'en').trim()` and no case folding —
35+
is mirrored limb for limb from objectui `packages/i18n/src/pickLocalized.ts`, and
36+
a 26-row vector table asserts each vector against a pinned verbatim copy of the
37+
reference before asserting it against this resolver. Two resolvers that drift
38+
would render the same metadata differently on the two ends with neither side
39+
erroring; that is the fork this exists to prevent.
40+
41+
The one visible difference is the spelling of a miss: `pickLocalized` returns `''`
42+
because its caller writes into a text node, while this returns `undefined` because
43+
its callers fill a `label?: string` field whose downstream enrichment is guarded by
44+
`if (field.label == null)` — a producer writing `''` would not be saying "no label",
45+
it would be permanently displacing a real label a later stage still had. The bridge
46+
is one `??`, pinned as an identity: `resolveI18nLabel(l, loc) ?? '' === pickLocalized(l, loc)`.
47+
48+
Additive only — one new exported function on `@objectstack/spec/ui`, no existing
49+
declaration changed. The consumption half (`AnalyticsService.queryDataset`'s two
50+
enrichment sites and `dataset-compiler.ts`'s `d.name` substitution) is #6761.

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,22 @@
2929
* `['en']`) → literal `label` from the metadata. Helpers never throw — they
3030
* always return at minimum the metadata literal so unconfigured languages
3131
* gracefully degrade.
32+
*
33+
* ## The OTHER half of `I18nLabel`, and where it lives
34+
*
35+
* This file resolves form **1** of {@link I18nLabelSchema} — a plain-string
36+
* label whose translations live in a bundle, addressed by the conventions
37+
* above. Form **2**, the inline locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`)
38+
* the author writes into the metadata document itself, is resolved by
39+
* `ui/i18n-label-resolver.ts`'s `resolveI18nLabel` (#6765, #6761 ruling B) —
40+
* the shared seat for that rule, kept in lockstep with objectui's
41+
* `pickLocalized` by an executed parity table.
42+
*
43+
* They compose, inline map first: objectui's own call sites read
44+
* `translateLabel(pickLocalized(label, language), language)`, i.e. collapse the
45+
* map to a string, then look that string up in the bundle. A caller holding an
46+
* `I18nLabel` that may be either form wants `resolveI18nLabel` before anything
47+
* here.
3248
*/
3349

3450
import type { TranslationBundle, TranslationData } from './translation.zod';
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Rule parity between `resolveI18nLabel` and objectui's `pickLocalized`.
5+
*
6+
* The #6761 ruling's acceptance hinge is not the resolver's API shape but that
7+
* the two ends of the platform pick the SAME ENTRY out of the same inline
8+
* locale map. A comment claiming that is worth nothing: two resolvers that
9+
* drift apart render the same metadata differently on the two ends and NEITHER
10+
* SIDE ERRORS — the server-rendered column header and the client-rendered one
11+
* simply disagree, forever. So the parity is executed here, not asserted in
12+
* prose.
13+
*
14+
* ## How the expectations were derived
15+
*
16+
* `pickLocalizedReference` below is a VERBATIM copy of the reference
17+
* implementation, taken from
18+
*
19+
* repo objectstack-ai/objectui
20+
* path packages/i18n/src/pickLocalized.ts
21+
* rev origin/main 50fa3766ebb2ebf2ec78c5d13b1d627e6a91696f
22+
* blob 9e5d92ae2efe9be62d4d010cb0a26e598211f3ec
23+
* last touched by objectui#3278 (2026-08-03)
24+
*
25+
* Copied rather than imported because `@objectstack/spec` must not take a
26+
* workspace dependency on objectui — spec sits UNDER objectui in the dependency
27+
* order, and inverting that to buy a test fixture would be a far worse trade
28+
* than copying 20 lines. The copy is what makes each `pickLocalized` column
29+
* below a MEASUREMENT instead of the author's recollection: every vector is
30+
* asserted against the reference first (`the reference really answers this`),
31+
* and only then against `resolveI18nLabel`.
32+
*
33+
* ⛔ `pickLocalizedReference` is a test fixture. It is not exported from this
34+
* file, and nothing under `src/` may import it — the whole point of #6765 is
35+
* that the repo has ONE resolver, not a private copy per consumer (PD#12). If
36+
* you find yourself wanting to call it from production code, you want
37+
* `resolveI18nLabel`.
38+
*
39+
* ## Keeping it honest when objectui moves
40+
*
41+
* The copy is pinned to a revision, so it cannot silently follow objectui.
42+
* If `pickLocalized` changes there, this file goes stale rather than wrong:
43+
* re-read the source at the new revision, update the copy AND the pin above,
44+
* and let the vectors say whether the rule moved. A vector that flips is a
45+
* two-repo decision, not a number to re-record.
46+
*/
47+
48+
import { describe, it, expect } from 'vitest';
49+
import { resolveI18nLabel } from './i18n-label-resolver';
50+
import type { I18nLabel } from './i18n.zod';
51+
52+
// ---------------------------------------------------------------------------
53+
// The reference implementation — verbatim, see the header for provenance. Only
54+
// the NAME differs, so that a reader of a failing assertion can tell at a glance
55+
// which side is the copy.
56+
function pickLocalizedReference(value: unknown, language: string | undefined | null): string {
57+
if (value == null) return '';
58+
if (typeof value === 'string') return value;
59+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
60+
if (typeof value === 'object') {
61+
const o = value as Record<string, unknown>;
62+
const lang = (language || 'en').trim();
63+
const base = lang.split('-')[0];
64+
// Runtime language is often a bare base code ('zh') while metadata authors
65+
// write full BCP-47 tags ('zh-CN') — upgrade to any key sharing the base.
66+
const regional = Object.keys(o).find((k) => k.split('-')[0] === base && typeof o[k] === 'string');
67+
const pick =
68+
o[lang] ??
69+
o[base] ??
70+
(regional !== undefined ? o[regional] : undefined) ??
71+
o.default ??
72+
o.en ??
73+
Object.values(o).find((v) => typeof v === 'string');
74+
return pick == null ? '' : String(pick);
75+
}
76+
return String(value);
77+
}
78+
// ---------------------------------------------------------------------------
79+
80+
interface ParityVector {
81+
/** What this vector demonstrates — the limb of the rule it exercises. */
82+
readonly limb: string;
83+
readonly label: I18nLabel | undefined;
84+
readonly locale: string | undefined;
85+
/** The reference's answer. Asserted against the reference itself below. */
86+
readonly pick: string;
87+
}
88+
89+
/**
90+
* One table, both ends. Every row is an input `I18nLabelSchema` accepts (or the
91+
* absence of one), so every row is inside the declared domain where parity is
92+
* total.
93+
*/
94+
const PARITY_VECTORS: readonly ParityVector[] = [
95+
// Form 1 — the plain string.
96+
{ limb: '0 plain string passes through', label: 'Owner', locale: 'zh-CN', pick: 'Owner' },
97+
{ limb: '0 an empty string is a label the author wrote', label: '', locale: 'zh-CN', pick: '' },
98+
99+
// Limb 1 — exact tag.
100+
{ limb: '1 exact tag', label: { en: 'Owner', 'zh-CN': '负责人' }, locale: 'zh-CN', pick: '负责人' },
101+
{ limb: '1 exact tag (source language)', label: { en: 'Owner', 'zh-CN': '负责人' }, locale: 'en', pick: 'Owner' },
102+
{
103+
limb: '1 exact tag beats an earlier sibling sharing the base',
104+
label: { 'zh-TW': '擁有者', 'zh-CN': '负责人' },
105+
locale: 'zh-CN',
106+
pick: '负责人',
107+
},
108+
109+
// Limb 2 — region request, base key (`zh-CN` → `zh`).
110+
{ limb: '2 region → base', label: { en: 'Owner', zh: '负责人' }, locale: 'zh-CN', pick: '负责人' },
111+
{ limb: '2 region → base, multi-subtag tag', label: { en: 'Owner', zh: '负责人' }, locale: 'zh-Hans-CN', pick: '负责人' },
112+
113+
// Limb 3 — base request, region key (`zh` → `zh-CN`).
114+
{ limb: '3 base → region', label: { en: 'Owner', 'zh-CN': '负责人' }, locale: 'zh', pick: '负责人' },
115+
{ limb: '3 base → region (ja)', label: { en: 'Owner', 'ja-JP': '所有者' }, locale: 'ja', pick: '所有者' },
116+
{ limb: '3 base key wins over the region upgrade', label: { zh: '基础', 'zh-CN': '区域' }, locale: 'zh', pick: '基础' },
117+
{
118+
limb: '3 first sibling in key order wins, not the "best" region',
119+
label: { 'zh-TW': '擁有者', 'zh-CN': '负责人' },
120+
locale: 'zh',
121+
pick: '擁有者',
122+
},
123+
{
124+
limb: '3 runs BEFORE default — a wrong-region hit beats the untagged entry',
125+
label: { default: 'Owner', 'fr-FR': 'Propriétaire' },
126+
locale: 'fr',
127+
pick: 'Propriétaire',
128+
},
129+
130+
// Case sensitivity, both halves of the tag. See the module doc on the
131+
// resolver: this asymmetry is the reference's rule, pinned as-is.
132+
{
133+
limb: '3 the REGION subtag\'s case does not matter (only the base is compared)',
134+
label: { 'zh-CN': '负责人' },
135+
locale: 'zh-cn',
136+
pick: '负责人',
137+
},
138+
{
139+
limb: '5 the LANGUAGE subtag\'s case DOES — `ZH-CN` matches nothing and lands on `en`',
140+
label: { 'zh-CN': '负责人', en: 'Owner' },
141+
locale: 'ZH-CN',
142+
pick: 'Owner',
143+
},
144+
145+
// Limb 4 / 5 / 6 — the named fallbacks, then any string at all.
146+
{ limb: '4 default', label: { default: 'D', en: 'E' }, locale: 'fr', pick: 'D' },
147+
{ limb: '5 en', label: { en: 'E', ja: 'J' }, locale: 'fr', pick: 'E' },
148+
{ limb: '6 first string value', label: { ja: 'J' }, locale: 'fr', pick: 'J' },
149+
{ limb: '6 first string value, in key order', label: { ja: 'J', ko: 'K' }, locale: 'fr', pick: 'J' },
150+
151+
// Locale normalization — `(locale || 'en').trim()`, no case folding.
152+
{ limb: 'norm undefined locale ⇒ en', label: { en: 'E', 'zh-CN': 'Z' }, locale: undefined, pick: 'E' },
153+
{ limb: 'norm empty locale ⇒ en', label: { en: 'E', 'zh-CN': 'Z' }, locale: '', pick: 'E' },
154+
{ limb: 'norm surrounding whitespace is trimmed', label: { en: 'E', 'zh-CN': 'Z' }, locale: ' zh-CN ', pick: 'Z' },
155+
{ limb: 'norm undefined locale still reaches limb 6', label: { 'zh-CN': 'Z' }, locale: undefined, pick: 'Z' },
156+
157+
// An entry whose VALUE is empty is still a hit — the reference's `??` chain
158+
// does not skip `''`, and neither may this one.
159+
{ limb: '1 an empty value is a hit, not a miss', label: { en: '', 'zh-CN': '负责人' }, locale: 'en', pick: '' },
160+
{ limb: '5 an empty `en` is a hit, not a miss', label: { en: '' }, locale: 'fr', pick: '' },
161+
162+
// The miss cases. The reference spells "nothing was picked" as `''`.
163+
{ limb: 'miss empty map', label: {}, locale: 'zh-CN', pick: '' },
164+
{ limb: 'miss absent label', label: undefined, locale: 'zh-CN', pick: '' },
165+
];
166+
167+
describe('resolveI18nLabel — rule parity with objectui pickLocalized (#6765 / #6761 ruling B)', () => {
168+
describe('the vector table really is the reference\'s behaviour', () => {
169+
it.each(PARITY_VECTORS)('$limb', ({ label, locale, pick }) => {
170+
// Asserted against the copied reference FIRST. If this row is wrong, the
171+
// parity assertion below would be comparing `resolveI18nLabel` to the
172+
// author's recollection instead of to objectui.
173+
expect(pickLocalizedReference(label, locale)).toBe(pick);
174+
});
175+
});
176+
177+
describe('resolveI18nLabel picks the same entry', () => {
178+
it.each(PARITY_VECTORS)('$limb', ({ label, locale, pick }) => {
179+
// The identity the two spellings of "nothing was picked" are bridged by.
180+
// `?? ''` is the ONLY difference between the two functions inside the
181+
// declared domain — everything else is the same limb, in the same order.
182+
expect(resolveI18nLabel(label, locale) ?? '').toBe(pick);
183+
});
184+
});
185+
186+
it('every vector agrees limb for limb, in one pass', () => {
187+
const disagreements = PARITY_VECTORS.filter(
188+
(v) => (resolveI18nLabel(v.label, v.locale) ?? '') !== pickLocalizedReference(v.label, v.locale),
189+
).map((v) => v.limb);
190+
expect(disagreements).toEqual([]);
191+
});
192+
});
193+
194+
describe('resolveI18nLabel — the producer-facing return shape', () => {
195+
// Why this is not `''`: downstream enrichment in the producing direction is
196+
// guarded by `if (field.label == null)`, so a producer that wrote `''` would
197+
// not be writing "no label" — it would permanently displace the real label a
198+
// later stage still had (#5199 route A, judged harmful rather than
199+
// redundant; restated in #6761).
200+
it('answers `undefined` — not `\'\'` — when the label is absent', () => {
201+
expect(resolveI18nLabel(undefined, 'zh-CN')).toBeUndefined();
202+
});
203+
204+
it('answers `undefined` when no limb matched', () => {
205+
expect(resolveI18nLabel({}, 'zh-CN')).toBeUndefined();
206+
});
207+
208+
it('answers `\'\'` when the author really wrote an empty label', () => {
209+
// A hit is a hit. This is the case a `''` miss value would be
210+
// indistinguishable from, which is why the miss is `undefined`.
211+
expect(resolveI18nLabel('', 'zh-CN')).toBe('');
212+
expect(resolveI18nLabel({ en: '' }, 'en')).toBe('');
213+
});
214+
215+
it('composes with `??` into the producer call shape #6761 needs', () => {
216+
// `dataset-compiler.ts:374/406` today: `typeof d.label === 'string' ? d.label : d.name`,
217+
// which publishes the MACHINE NAME as a display title for a map label.
218+
const dimension = { name: 'owner', label: { en: 'Owner', 'zh-CN': '负责人' } as I18nLabel };
219+
expect(resolveI18nLabel(dimension.label, 'zh-CN') ?? dimension.name).toBe('负责人');
220+
221+
const unlabelled = { name: 'owner', label: undefined };
222+
expect(resolveI18nLabel(unlabelled.label, 'zh-CN') ?? unlabelled.name).toBe('owner');
223+
});
224+
});
225+
226+
describe('resolveI18nLabel — the two deliberate departures from the reference', () => {
227+
// Both are documented on the resolver's module doc. They are pinned here with
228+
// BOTH answers so the divergence stays MEASURED: if a later change makes the
229+
// two agree again, these tests go red and say so, rather than quietly
230+
// becoming decoration.
231+
232+
it('reads own properties only — a locale naming an Object.prototype member is a miss', () => {
233+
const label: I18nLabel = { en: 'Owner' };
234+
235+
// The reference resolves `map['constructor']` up the prototype chain and
236+
// renders the function's source text as the label. Filed as objectui#3907.
237+
expect(pickLocalizedReference(label, 'constructor')).toContain('function Object');
238+
239+
// Here it is simply not a key, so the chain continues to `en`. No BCP-47
240+
// tag is an `Object.prototype` member, so no in-contract input can tell the
241+
// two implementations apart — but on a server the locale can arrive in an
242+
// `Accept-Language` header, which is why this one is hardened.
243+
expect(resolveI18nLabel(label, 'constructor')).toBe('Owner');
244+
expect(resolveI18nLabel(label, 'toString')).toBe('Owner');
245+
});
246+
247+
it('treats a non-string value as absent on EVERY limb, not just limbs 3 and 6', () => {
248+
// Off-spec: `InlineLocaleMapSchema` is `z.record(<tag>, z.string())`, so no
249+
// in-contract map can hold this. The cast is what makes that explicit.
250+
const offSpec = { 'zh-CN': { nested: 'x' }, en: 'Owner' } as unknown as I18nLabel;
251+
252+
// The reference filters by `typeof === 'string'` on limbs 3 and 6 but not
253+
// on 1/2/4/5, so an exact-tag hit short-circuits and gets stringified.
254+
expect(pickLocalizedReference(offSpec, 'zh-CN')).toBe('[object Object]');
255+
256+
// PD#12: the producer is wrong; the consumer must not coerce `[object
257+
// Object]` onto a screen. The filter is uniform, so the limb is a miss and
258+
// the chain continues.
259+
expect(resolveI18nLabel(offSpec, 'zh-CN')).toBe('Owner');
260+
});
261+
262+
it('refuses an off-contract scalar rather than stringifying it', () => {
263+
// `pickLocalized` accepts `unknown` and stringifies numbers/booleans. This
264+
// resolver's parameter is the declared `I18nLabel`, so the shapes below are
265+
// type errors — the `@ts-expect-error` directives immediately after are the
266+
// real guard. This asserts the runtime half: no coerced `'42'` label.
267+
// @ts-expect-error a number is not an `I18nLabel` — off-spec input is refused, not coerced
268+
expect(resolveI18nLabel(42, 'en')).toBeUndefined();
269+
// @ts-expect-error a boolean is not an `I18nLabel`
270+
expect(resolveI18nLabel(true, 'en')).toBeUndefined();
271+
});
272+
});
273+
274+
describe('resolveI18nLabel — the type signature refuses the calls that caused #6761', () => {
275+
// Reverse verification at the type level. `check:test-typecheck` compiles this
276+
// file (packages/spec/tsconfig.test.json), so each directive below is a REAL
277+
// check: delete the argument it guards and tsc goes red on the unused
278+
// `@ts-expect-error` instead of letting the call through.
279+
280+
it('rejects a map whose values are not strings', () => {
281+
// @ts-expect-error `InlineLocaleMap` values are strings; a number is not a label
282+
const bad: I18nLabel = { en: 42 };
283+
expect(resolveI18nLabel(bad, 'en')).toBeUndefined();
284+
});
285+
286+
it('rejects the call that forgets the locale', () => {
287+
// The defect #6761 records is a producer shipping ONE audience's language to
288+
// every audience. `locale` is positional rather than optional precisely so
289+
// that omitting it cannot compile.
290+
// @ts-expect-error `locale` is required positionally — a producer must decide it
291+
expect(resolveI18nLabel({ en: 'Owner' })).toBe('Owner');
292+
});
293+
294+
it('accepts both authorized forms, and an absent label', () => {
295+
const plain: I18nLabel = 'All Active';
296+
const inline: I18nLabel = { en: 'All Active', 'zh-CN': '全部活跃' };
297+
expect(resolveI18nLabel(plain, 'zh-CN')).toBe('All Active');
298+
expect(resolveI18nLabel(inline, 'zh-CN')).toBe('全部活跃');
299+
expect(resolveI18nLabel(undefined, 'zh-CN')).toBeUndefined();
300+
});
301+
});

0 commit comments

Comments
 (0)