Skip to content

Commit 58468fd

Browse files
committed
fix(runtime,i18n)!: /i18n/locales answers in one shape, plus the success-envelope conformance gate that found it
Follow-up to #3676 / #3833 / #3847. Those three were each a body that did not match the schema declaring it, and each survived a green suite because every test asserted the emitted body against a hand-written literal. Comparing output to a literal proves the code does what the test author believed; it cannot prove the code does what the contract declares. Nothing had ever put the emitted value and the declared schema in the same assertion. This adds that assertion as a suite — `i18n-success-envelope.conformance.test.ts` in runtime, the missing success-path twin of service-i18n's `error-envelope.conformance.test.ts` and the same pairing storage got in #3689. Every `/i18n` success body is parsed against `BaseResponseSchema` and against the schema `plugin-rest-api` names for that route, imported rather than restated. It found a fourth gap on its first run. `GET /i18n/locales` passed `getLocales()`'s raw `string[]` straight through the dispatcher, while `GetLocalesResponseSchema` declares `{ code, label, isDefault }[]` — and service-i18n, the OTHER provider of this identical route, already emitted descriptors. One endpoint, two shapes, decided by which plugin mounted it, with the dispatcher's form contradicting the SDK's own `GetLocalesResponse` type. That is the same split #3833 found in the field-labels derivation, one route over, and for the same reason: two surfaces, one mapping, kept twice. The mapping is now shared as `toLocaleDescriptors` next to `resolveObjectFieldLabels`, and both surfaces call it. `label` is the locale code — no display-name source exists in the tree and the schema requires the field; inventing an ICU display-name table here would be a product decision, not an implementation detail. The gate was verified the way #3833's was: the fix was reverted and the suite confirmed to FAIL on it ("expected object, received string" at locales[0]), rather than merely passing once written. Five existing tests pinned the bare `string[]`; they now assert on `.map(l => l.code)`, so the codes stay pinned while the shape is owned by the schema. BREAKING: `GET /i18n/locales` served by the dispatcher now returns `[{ code, label, isDefault }]` instead of `['en', ...]`. Callers on the service-i18n mount already received this shape, and the SDK's published `GetLocalesResponse` type has always described it, so this ends a divergence rather than starting one. Worth generalizing beyond `/i18n`: `plugin-rest-api.zod.ts` already carries a `responseSchema` name on essentially every route (29 declarations across 28 handlers), so the route -> declaring-schema mapping needed to run this check repo-wide exists today and is unused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0196gwfwMK5vW8RToPyzGMJo
1 parent 1d4756e commit 58468fd

9 files changed

Lines changed: 325 additions & 21 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": patch
4+
"@objectstack/service-i18n": patch
5+
---
6+
7+
fix(runtime,i18n)!: `/i18n/locales` answers in one shape — plus the
8+
success-envelope conformance gate that found it
9+
10+
Follow-up to #3676 / #3833 / #3847. Those three were each a body that did not
11+
match the schema declaring it, and each survived a green suite because **every
12+
test asserted the emitted body against a hand-written literal**. Comparing
13+
output to a literal proves the code does what the test author believed; it
14+
cannot prove the code does what the contract declares. Nothing had ever put the
15+
emitted value and the declared schema in the same assertion.
16+
17+
This adds that assertion as a suite — `i18n-success-envelope.conformance.test.ts`
18+
in `runtime`, the missing success-path twin of service-i18n's
19+
`error-envelope.conformance.test.ts` and the same pairing storage got in #3689.
20+
Every `/i18n` success body is parsed against `BaseResponseSchema` and against
21+
the schema `plugin-rest-api` names for that route (`responseSchema:
22+
'GetLocalesResponseSchema'`, …), imported rather than restated.
23+
24+
**It found a fourth gap on its first run.** `GET /i18n/locales` passed
25+
`getLocales()`'s raw `string[]` straight through the dispatcher, while
26+
`GetLocalesResponseSchema` declares `{ code, label, isDefault }[]` — and
27+
service-i18n, the *other* provider of this identical route, already emitted
28+
descriptors. One endpoint, two shapes, decided by which plugin mounted it, with
29+
the dispatcher's form contradicting the SDK's own `GetLocalesResponse` type.
30+
31+
That is the same split #3833 found in the field-labels derivation, one route
32+
over, and it happened for the same reason: two surfaces, one mapping, kept
33+
twice. So the mapping is now shared as `toLocaleDescriptors` in
34+
`packages/spec/src/system/i18n-resolver.ts`, next to `resolveObjectFieldLabels`,
35+
and both surfaces call it. `label` is the locale code — no display-name source
36+
exists in the tree and the schema requires the field; inventing an ICU
37+
display-name table here would be a product decision, not an implementation
38+
detail.
39+
40+
The gate was verified the same way #3833's was: the fix was reverted and the
41+
suite confirmed to fail on it —
42+
43+
```
44+
locales body does not match its declared schema:
45+
[{"expected":"object","code":"invalid_type","path":["locales",0],
46+
"message":"Invalid input: expected object, received string"}, …]
47+
```
48+
49+
— rather than merely passing once written. Five existing tests pinned the bare
50+
`string[]`; they now assert on `.map(l => l.code)`, so the codes stay pinned
51+
while the shape is owned by the schema.
52+
53+
BREAKING: `GET /i18n/locales` served by the dispatcher now returns
54+
`[{ code, label, isDefault }]` instead of `['en', …]`. Callers on the
55+
service-i18n mount already received this shape, and the SDK's published
56+
`GetLocalesResponse` type has always described it, so this ends a divergence
57+
rather than starting one.
58+
59+
Worth generalizing beyond `/i18n`: `plugin-rest-api.zod.ts` already carries a
60+
`responseSchema` name on essentially every route (29 declarations across 28
61+
handlers), so the route → declaring-schema mapping needed to run this check
62+
repo-wide exists today and is unused.

packages/runtime/src/domain-handler-registry.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ describe('HttpDispatcher domain registry (D11 step ③)', () => {
126126
const i18n = { getLocales: vi.fn().mockReturnValue(['en', 'zh-CN']), getTranslations: vi.fn().mockReturnValue({}) };
127127
const result = await makeDispatcher({ i18n }).dispatch('GET', '/i18n/locales', undefined, {}, {} as any);
128128
expect(result.response?.status).toBe(200);
129-
expect(result.response?.body?.data?.locales).toEqual(['en', 'zh-CN']);
129+
expect(result.response?.body?.data?.locales.map((l: any) => l.code)).toEqual(['en', 'zh-CN']);
130130
});
131131

132132
it('/analytics bridges to the analytics service exactly as the legacy branch did', async () => {

packages/runtime/src/domains/i18n.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
*/
1818

1919
import { resolveLocale } from '@objectstack/core';
20-
import { CoreServiceName, resolveObjectFieldLabels } from '@objectstack/spec/system';
20+
import { CoreServiceName, resolveObjectFieldLabels, toLocaleDescriptors } from '@objectstack/spec/system';
2121
import type { TranslationData } from '@objectstack/spec/system';
2222
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
2323
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
@@ -48,7 +48,16 @@ export async function handleI18nRequest(
4848

4949
// GET /i18n/locales
5050
if (parts[0] === 'locales' && parts.length === 1) {
51-
const locales = i18nService.getLocales();
51+
// Descriptors, not the raw `string[]` `getLocales()` hands back —
52+
// that is what `GetLocalesResponseSchema` declares and what
53+
// service-i18n already emitted for this same route. Passing the bare
54+
// array through made one endpoint answer in two shapes depending on
55+
// which provider mounted it, the dispatcher's contradicting the SDK's
56+
// own `GetLocalesResponse` type.
57+
const locales = toLocaleDescriptors(
58+
i18nService.getLocales(),
59+
typeof i18nService.getDefaultLocale === 'function' ? i18nService.getDefaultLocale() : undefined,
60+
);
5261
return { handled: true, response: deps.success({ locales }) };
5362
}
5463

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1854,7 +1854,9 @@ describe('HttpDispatcher', () => {
18541854
const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} });
18551855
expect(result.handled).toBe(true);
18561856
expect(result.response?.status).toBe(200);
1857-
expect(result.response?.body?.data?.locales).toEqual(['en', 'zh-CN', 'ja']);
1857+
// Descriptors, not bare codes — the shape `GetLocalesResponseSchema`
1858+
// declares and both surfaces now emit (#3859 follow-up).
1859+
expect(result.response?.body?.data?.locales.map((l: any) => l.code)).toEqual(['en', 'zh-CN', 'ja']);
18581860
expect(mockI18nService.getLocales).toHaveBeenCalled();
18591861
});
18601862

@@ -2015,7 +2017,7 @@ describe('HttpDispatcher', () => {
20152017
it('should dispatch /i18n routes via dispatch()', async () => {
20162018
const result = await dispatcher.dispatch('GET', '/i18n/locales', undefined, {}, { request: {} });
20172019
expect(result.handled).toBe(true);
2018-
expect(result.response?.body?.data?.locales).toEqual(['en', 'zh-CN', 'ja']);
2020+
expect(result.response?.body?.data?.locales.map((l: any) => l.code)).toEqual(['en', 'zh-CN', 'ja']);
20192021
});
20202022

20212023
it('should resolve locale via fallback (zh → zh-CN) for translations', async () => {
@@ -2107,7 +2109,7 @@ describe('HttpDispatcher', () => {
21072109
const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} });
21082110
expect(result.handled).toBe(true);
21092111
expect(result.response?.status).toBe(200);
2110-
expect(result.response?.body?.data?.locales).toEqual(['en', 'fr']);
2112+
expect(result.response?.body?.data?.locales.map((l: any) => l.code)).toEqual(['en', 'fr']);
21112113
});
21122114

21132115
it('should populate locale from actual i18n service', async () => {
@@ -2295,7 +2297,7 @@ describe('HttpDispatcher', () => {
22952297
// MSW-style dispatch: full path stripped to relative
22962298
const localesResult = await dispatcher.dispatch('GET', '/i18n/locales', undefined, {}, { request: {} });
22972299
expect(localesResult.handled).toBe(true);
2298-
expect(localesResult.response?.body?.data?.locales).toEqual(['en', 'de']);
2300+
expect(localesResult.response?.body?.data?.locales.map((l: any) => l.code)).toEqual(['en', 'de']);
22992301

23002302
const translationsResult = await dispatcher.dispatch('GET', '/i18n/translations/de', undefined, {}, { request: {} });
23012303
expect(translationsResult.handled).toBe(true);
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Success-envelope conformance for the dispatcher's `/i18n` domain — the
5+
* missing twin of `service-i18n`'s `error-envelope.conformance.test.ts`, and
6+
* the same pairing storage got in #3689.
7+
*
8+
* WHY THIS SUITE EXISTS
9+
*
10+
* Three consecutive defects landed on this one route family, and every one of
11+
* them was a body that did not match the schema declaring it:
12+
*
13+
* #3676 the request declared `namespace`/`keys` filters no server read
14+
* #3833 the field-labels derivation scanned a retired key dialect and so
15+
* returned `{}` for every provider
16+
* #3847 `labels` entries were emitted as bare strings under a schema
17+
* declaring `{ label, help?, options? }`
18+
*
19+
* All three survived a green test suite, because every test asserted the
20+
* emitted body against a HAND-WRITTEN LITERAL. Comparing output to a literal
21+
* proves the code does what the test author believed; it cannot prove the code
22+
* does what the contract declares. Nothing had ever put the emitted value and
23+
* the declared schema in the same assertion.
24+
*
25+
* So these assertions parse each response with the schema `plugin-rest-api`
26+
* names for that route (`responseSchema: 'GetLocalesResponseSchema'`, …),
27+
* imported rather than restated. A body that parses is a body the SDK's
28+
* published return type does not lie about.
29+
*
30+
* The first thing the suite caught, on the day it was written: `GET
31+
* /i18n/locales` passed `getLocales()`'s raw `string[]` straight through,
32+
* while `GetLocalesResponseSchema` declares `{ code, label, isDefault }[]` and
33+
* service-i18n — the OTHER provider of this identical route — already emitted
34+
* descriptors. One endpoint, two shapes, decided by which plugin mounted it.
35+
* The dispatcher's copy of the field-labels derivation went wrong the same way
36+
* in #3833, one route over, which is why both mappings are now shared.
37+
*
38+
* The route ledgers cannot catch this. They audit which routes exist and
39+
* whether the SDK can address them, not what comes back.
40+
*/
41+
42+
import { describe, it, expect, vi, beforeEach } from 'vitest';
43+
import { HttpDispatcher } from './http-dispatcher.js';
44+
import { BaseResponseSchema } from '@objectstack/spec/api';
45+
import {
46+
GetLocalesResponseSchema,
47+
GetTranslationsResponseSchema,
48+
GetFieldLabelsResponseSchema,
49+
} from '@objectstack/spec/api';
50+
51+
/** The nested shape every producer writes (#3778). */
52+
const BUNDLE = {
53+
objects: {
54+
contact: {
55+
label: '联系人',
56+
fields: {
57+
first_name: { label: '名' },
58+
email: { label: '邮箱', help: '主邮箱地址' },
59+
status: { label: '状态', options: { open: '进行中', closed: '已关闭' } },
60+
phone: { help: '优先手机' },
61+
},
62+
},
63+
},
64+
messages: { save: '保存' },
65+
};
66+
67+
describe('/i18n success-envelope conformance (dispatcher domain)', () => {
68+
let dispatcher: HttpDispatcher;
69+
let i18nService: Record<string, unknown>;
70+
71+
beforeEach(() => {
72+
i18nService = {
73+
getLocales: vi.fn().mockReturnValue(['en', 'zh-CN']),
74+
getDefaultLocale: vi.fn().mockReturnValue('en'),
75+
getTranslations: vi.fn().mockReturnValue(BUNDLE),
76+
};
77+
const kernel = {
78+
getService: vi.fn(async (name: string) => (name === 'i18n' ? i18nService : null)),
79+
services: new Map(),
80+
context: { getService: (name: string) => (name === 'i18n' ? i18nService : null) },
81+
};
82+
dispatcher = new HttpDispatcher(kernel as never);
83+
});
84+
85+
/** Every success body must satisfy the shared envelope. */
86+
function expectEnvelope(body: unknown) {
87+
const parsed = BaseResponseSchema.safeParse(body);
88+
expect(parsed.success, `body is not a BaseResponse: ${JSON.stringify(body)}`).toBe(true);
89+
expect((body as { success?: boolean }).success).toBe(true);
90+
}
91+
92+
it('GET /locales — body satisfies GetLocalesResponseSchema', async () => {
93+
const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} } as never);
94+
expect(result.response?.status).toBe(200);
95+
expectEnvelope(result.response?.body);
96+
97+
const parsed = GetLocalesResponseSchema.safeParse(result.response?.body?.data);
98+
expect(
99+
parsed.success,
100+
`locales body does not match its declared schema: ${JSON.stringify(parsed.error?.issues)}`,
101+
).toBe(true);
102+
expect(parsed.data?.locales).toEqual([
103+
{ code: 'en', label: 'en', isDefault: true },
104+
{ code: 'zh-CN', label: 'zh-CN', isDefault: false },
105+
]);
106+
});
107+
108+
it('GET /locales — a bare string[] is DEAD, so a revert cannot pass quietly', async () => {
109+
const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} } as never);
110+
const locales = result.response?.body?.data?.locales as unknown[];
111+
expect(locales.every((l) => typeof l === 'object' && l !== null)).toBe(true);
112+
expect(locales).not.toContain('en');
113+
});
114+
115+
it('GET /translations/:locale — body satisfies GetTranslationsResponseSchema', async () => {
116+
const result = await dispatcher.handleI18n('/translations/zh-CN', 'GET', {}, { request: {} } as never);
117+
expect(result.response?.status).toBe(200);
118+
expectEnvelope(result.response?.body);
119+
120+
const parsed = GetTranslationsResponseSchema.safeParse(result.response?.body?.data);
121+
expect(
122+
parsed.success,
123+
`translations body does not match its declared schema: ${JSON.stringify(parsed.error?.issues)}`,
124+
).toBe(true);
125+
});
126+
127+
it('GET /labels/:object/:locale — body satisfies GetFieldLabelsResponseSchema', async () => {
128+
const result = await dispatcher.handleI18n('/labels/contact/zh-CN', 'GET', {}, { request: {} } as never);
129+
expect(result.response?.status).toBe(200);
130+
expectEnvelope(result.response?.body);
131+
132+
const parsed = GetFieldLabelsResponseSchema.safeParse(result.response?.body?.data);
133+
expect(
134+
parsed.success,
135+
`labels body does not match its declared schema: ${JSON.stringify(parsed.error?.issues)}`,
136+
).toBe(true);
137+
// The entries are objects carrying what the bundle holds — the gap #3847
138+
// closed, pinned here against the contract rather than a literal.
139+
expect(parsed.data?.labels.email?.help).toBe('主邮箱地址');
140+
expect(parsed.data?.labels.status?.options?.open).toBe('进行中');
141+
});
142+
143+
it('an empty label map is still a conforming body', async () => {
144+
const result = await dispatcher.handleI18n('/labels/unknown_object/zh-CN', 'GET', {}, { request: {} } as never);
145+
expect(result.response?.status).toBe(200);
146+
expect(GetFieldLabelsResponseSchema.safeParse(result.response?.body?.data).success).toBe(true);
147+
});
148+
149+
/**
150+
* A provider may omit the optional `getDefaultLocale`. The descriptor
151+
* mapping must still produce a conforming body — `isDefault` is required by
152+
* the schema, so an undefined default must yield `false`, not `undefined`.
153+
*/
154+
it('GET /locales conforms even when the provider omits getDefaultLocale', async () => {
155+
delete i18nService.getDefaultLocale;
156+
const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} } as never);
157+
const parsed = GetLocalesResponseSchema.safeParse(result.response?.body?.data);
158+
expect(parsed.success).toBe(true);
159+
expect(parsed.data?.locales.every((l) => l.isDefault === false)).toBe(true);
160+
});
161+
});

packages/services/service-i18n/src/i18n-service-plugin.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { wireAuthoredTranslationSync } from '@objectstack/core';
55
import type { IHttpServer, IHttpRequest, IHttpResponse } from '@objectstack/spec/contracts';
66
import type { II18nService } from '@objectstack/spec/contracts';
77
import type { TranslationData } from '@objectstack/spec/system';
8-
import { resolveObjectFieldLabels } from '@objectstack/spec/system';
8+
import { resolveObjectFieldLabels, toLocaleDescriptors } from '@objectstack/spec/system';
99
import { FileI18nAdapter } from './file-i18n-adapter.js';
1010
import type { FileI18nAdapterOptions } from './file-i18n-adapter.js';
1111

@@ -182,18 +182,12 @@ export class I18nServicePlugin implements Plugin {
182182
// GET /i18n/locales
183183
httpServer.get(`${basePath}/locales`, async (_req: IHttpRequest, res: IHttpResponse) => {
184184
try {
185-
const locales = i18n.getLocales();
186-
const defaultLocale = i18n.getDefaultLocale?.() ?? 'en';
187-
res.json({
188-
success: true,
189-
data: {
190-
locales: locales.map((code) => ({
191-
code,
192-
label: code,
193-
isDefault: code === defaultLocale,
194-
})),
195-
},
196-
});
185+
// Same shared mapping the dispatcher's `/i18n` domain uses — the two
186+
// surfaces serve this route interchangeably, and each holding its own
187+
// copy is what let them answer in different shapes (#3833's lesson,
188+
// one route over).
189+
const locales = toLocaleDescriptors(i18n.getLocales(), i18n.getDefaultLocale?.() ?? 'en');
190+
res.json({ success: true, data: { locales } });
197191
} catch (error: any) {
198192
sendError(res, 500, 'INTERNAL', error?.message ?? 'Internal error');
199193
}

packages/spec/api-surface.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -894,6 +894,7 @@
894894
"LifecyclePolicyConfigSchema (const)",
895895
"LifecyclePolicyRule (type)",
896896
"LifecyclePolicyRuleSchema (const)",
897+
"LocaleDescriptor (interface)",
897898
"LocaleSchema (const)",
898899
"LogDestination (type)",
899900
"LogDestinationSchema (const)",
@@ -1323,6 +1324,7 @@
13231324
"resolveViewDescription (function)",
13241325
"resolveViewLabel (function)",
13251326
"s3StorageExample (const)",
1327+
"toLocaleDescriptors (function)",
13261328
"translateAction (function)",
13271329
"translateApp (function)",
13281330
"translateDashboard (function)",

0 commit comments

Comments
 (0)