Skip to content

Commit 88ef03e

Browse files
authored
fix(spec,client)!: GetTranslationsRequest is locale-only — drop the namespace/keys filters no server reads (#3676) (#3832)
The request schema declared two optional filters and the endpoint description promised one of them. Neither serving surface read either: the dispatcher domain body takes `parts[1]`/`query.locale`, service-i18n takes `req.params.locale`, and both return the locale's whole bundle. The SDK put both on the query string, so a caller passing `keys` to shrink the payload shrank nothing and was told nothing — Prime Directive #10's declared != enforced. Trimmed rather than implemented. No call site in this repo or objectui passed either field, and the docs already described `getTranslations(locale)` as a full-bundle snapshot. `keys` could not deliver what it advertises anyway: `II18nService.getTranslations` takes only `locale`, so a filter could only post-filter an already-materialized bundle — saving wire bytes but none of the server work the name implies. `keys` also has no defined meaning against the nested `TranslationData` shape #3778 settled on. `namespace` is the one that got easier — it now lands exactly on `TranslationData`'s top-level groups. Still trimmed: re-adding an optional request field is additive the day the Studio's per-module views need it, whereas shipping an unexercised filter path now means dead code with tests. The client test that exercised the fields asserted the query string was BUILT, pinning the phantom rather than any behaviour. Replaced by its inverse. The spec test is likewise rewritten to assert the parsed output, since `safeParse` still succeeds on a payload carrying the old fields (z.object strips unknown keys) and a success-only assertion would stay green either way. BREAKING: the two schema fields and the `getTranslations(locale, options?)` second parameter are removed with no deprecation cycle. A passed filter was silently ignored, so there is no behaviour to protect.
1 parent f63cd09 commit 88ef03e

7 files changed

Lines changed: 102 additions & 16 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/client": minor
4+
---
5+
6+
fix(spec,client)!: `GetTranslationsRequest` is locale-only — drop the
7+
`namespace` / `keys` filters no server ever read (#3676)
8+
9+
`GetTranslationsRequestSchema` declared two optional filters, and the endpoint
10+
description promised one of them ("...for the specified locale and optional
11+
namespace"). Neither serving surface read either: the dispatcher domain body
12+
(`runtime/src/domains/i18n.ts`) takes `parts[1]` / `query.locale`, and
13+
service-i18n (`i18n-service-plugin.ts`) takes `req.params.locale`. Both return
14+
the locale's whole bundle. The SDK meanwhile put both on the query string, so a
15+
caller who passed `keys` to shrink the response shrank nothing and got no
16+
indication the filter was inert — Prime Directive #10's declared ≠ enforced, the
17+
same shape #1475 trimmed out of the validation-rule types.
18+
19+
Trimmed rather than implemented, on three counts:
20+
21+
- **No consumer.** No call site in this repo or `objectui` passed either field.
22+
The docs (`content/docs/api/client-sdk.mdx`, `skills/objectstack-i18n/SKILL.md`)
23+
already documented `getTranslations(locale)` as a full-bundle snapshot, so the
24+
schema was the outlier, not the docs. The one thing that did exercise them was
25+
a client test asserting the query string got *built* — it pinned the phantom
26+
rather than any behaviour, since no server read what it asserted was sent. It
27+
is replaced here by its inverse: a regression test that the request carries no
28+
filter query at all.
29+
- **`keys` could not deliver what it advertises.** `II18nService.getTranslations`
30+
(`contracts/i18n-service.ts`) takes only `locale`, so a filter could only be a
31+
post-filter over an already-materialized bundle. `keys` reads as a payload
32+
optimization; a post-filter saves wire bytes but none of the server work, and
33+
widening the contract would break every implementer (`memory-i18n`,
34+
`file-i18n-adapter`) for a capability with no caller.
35+
- **`keys` has no defined meaning against the current bundle shape.** Under the
36+
retired flat `o.`-dotted dialect, `keys: ['o.account.label']` was an obvious
37+
pick. #3778 settled the tree on one nested `TranslationData` shape, where a
38+
flat `string[]` is neither a path set nor a group set, and a filtered response
39+
would have to be rebuilt as a sparse nested tree to stay schema-valid. That is
40+
a design decision, and nothing is waiting on it.
41+
42+
`namespace` is the one that got *easier* — it now lands exactly on
43+
`TranslationData`'s top-level groups, which is what its own description already
44+
said ("e.g., objects, apps, messages"). It is still trimmed here: re-adding an
45+
optional request field is additive and non-breaking the day the Studio's
46+
per-module views actually need it, whereas shipping an unexercised filter path
47+
now means dead code with tests to match, and a declared-but-unread field is
48+
precisely the exemplar the next author copies.
49+
50+
BREAKING: the two schema fields and the `getTranslations(locale, options?)`
51+
second parameter are removed with no deprecation cycle. Nothing worked through
52+
them — a passed filter was silently ignored — so there is no behavior to
53+
protect. Runtime impact is nil (the fields were optional and now strip); TS
54+
callers passing them fail to compile, which is the intended signal.

content/docs/references/api/protocol.mdx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -711,8 +711,6 @@ const result = AiInsightsRequest.parse(data);
711711
| Property | Type | Required | Description |
712712
| :--- | :--- | :--- | :--- |
713713
| **locale** | `string` || BCP-47 locale code |
714-
| **namespace** | `string` | optional | Translation namespace (e.g., objects, apps, messages) |
715-
| **keys** | `string[]` | optional | Specific translation keys to fetch |
716714

717715

718716
---

packages/client/src/client.test.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -656,15 +656,23 @@ describe('i18n namespace', () => {
656656
);
657657
});
658658

659-
it('i18n.getTranslations keeps namespace/keys as query params on the path form', async () => {
659+
it('i18n.getTranslations sends no filter query the server reads none (#3676)', async () => {
660660
const { client, fetchMock } = createMockClient({
661661
success: true,
662662
data: { locale: 'zh-CN', translations: { hello: '你好' } }
663663
});
664-
await client.i18n.getTranslations('zh-CN', { namespace: 'common', keys: ['a', 'b'] });
665-
expect(String(fetchMock.mock.calls[0][0])).toBe(
666-
'http://localhost:3000/api/v1/i18n/translations/zh-CN?namespace=common&keys=a%2Cb',
664+
// This used to accept `{ namespace, keys }` and append them as query
665+
// params. Neither serving surface ever read them (the dispatcher takes
666+
// parts[1]/query.locale, service-i18n takes params.locale), so the
667+
// filter was inert and the caller got the full bundle either way. The
668+
// predecessor of this test asserted the query string was BUILT, which
669+
// pinned the phantom in place rather than the behaviour.
670+
await (client.i18n.getTranslations as (l: string, o?: unknown) => Promise<unknown>)(
671+
'zh-CN', { namespace: 'common', keys: ['a', 'b'] },
667672
);
673+
const url = String(fetchMock.mock.calls[0][0]);
674+
expect(url).toBe('http://localhost:3000/api/v1/i18n/translations/zh-CN');
675+
expect(url).not.toContain('?');
668676
});
669677

670678
it('i18n.getFieldLabels puts both object and locale on the path (#3636)', async () => {

packages/client/src/index.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3522,15 +3522,16 @@ export class ObjectStackClient {
35223522
* The `?locale=` query form this used to send matched no route anywhere
35233523
* and 404'd on the wire; the dispatcher's domain body accepts it, but
35243524
* nothing ever routes a bare `/translations` to that body (#3636).
3525+
*
3526+
* Returns the locale's full bundle. The `options.namespace` / `options.keys`
3527+
* this used to accept rode the query string to a server that read neither,
3528+
* so the filter silently did nothing — trimmed with the request schema's
3529+
* fields in #3676.
35253530
*/
3526-
getTranslations: async (locale: string, options?: { namespace?: string; keys?: string[] }): Promise<GetTranslationsResponse> => {
3531+
getTranslations: async (locale: string): Promise<GetTranslationsResponse> => {
35273532
const route = this.getRoute('i18n');
3528-
const params = new URLSearchParams();
3529-
if (options?.namespace) params.set('namespace', options.namespace);
3530-
if (options?.keys) params.set('keys', options.keys.join(','));
3531-
const query = params.toString();
35323533
const res = await this.fetch(
3533-
`${this.baseUrl}${route}/translations/${encodeURIComponent(locale)}${query ? `?${query}` : ''}`,
3534+
`${this.baseUrl}${route}/translations/${encodeURIComponent(locale)}`,
35343535
);
35353536
return this.unwrapResponse<GetTranslationsResponse>(res);
35363537
},

packages/spec/src/api/plugin-rest-api.zod.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1182,7 +1182,7 @@ export const DEFAULT_I18N_ROUTES: RestApiRouteRegistration = {
11821182
category: 'i18n',
11831183
public: false,
11841184
summary: 'Get translations for a locale',
1185-
description: 'Returns translation strings for the specified locale and optional namespace',
1185+
description: "Returns the specified locale's full translation bundle",
11861186
tags: ['i18n'],
11871187
responseSchema: 'GetTranslationsResponseSchema',
11881188
cacheable: true,

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,14 @@ describe('ObjectStack Protocol', () => {
302302
{ code: 'es-ES', label: 'Spanish (Spain)' },
303303
],
304304
}).success).toBe(true);
305-
expect(GetTranslationsRequestSchema.safeParse({ locale: 'en-US', namespace: 'objects' }).success).toBe(true);
305+
expect(GetTranslationsRequestSchema.safeParse({ locale: 'en-US' }).success).toBe(true);
306+
// The request is locale-only. `namespace`/`keys` were declared here but read
307+
// by no serving surface, so they were trimmed (#3676). Asserting on the
308+
// PARSED OUTPUT is the point: `safeParse` still succeeds on a payload
309+
// carrying them (z.object strips unknown keys), so a success-only assertion
310+
// would keep passing green whether the fields were trimmed or not.
311+
const trimmed = GetTranslationsRequestSchema.parse({ locale: 'en-US', namespace: 'objects', keys: ['a'] });
312+
expect(trimmed).toEqual({ locale: 'en-US' });
306313
expect(GetTranslationsResponseSchema.safeParse({
307314
locale: 'en-US',
308315
translations: { objects: { task: { label: 'Task', pluralLabel: 'Tasks' } }, messages: { save: 'Save' } },

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -954,10 +954,28 @@ export const GetLocalesResponseSchema = lazySchema(() => z.object({
954954
})).describe('Available locales'),
955955
}));
956956

957+
/**
958+
* `locale` is the whole request — the endpoint returns that locale's full
959+
* bundle, and there is no server-side filter to ask for.
960+
*
961+
* This once declared `namespace` and `keys` filters. Neither serving surface
962+
* ever read them: the dispatcher domain body takes `parts[1]`/`query.locale`
963+
* and service-i18n takes `req.params.locale`, so both returned the whole
964+
* bundle while the SDK dutifully put both on the query string. A caller who
965+
* passed `keys` to shrink the payload shrank nothing and was told nothing —
966+
* Prime Directive #10's declared ≠ enforced, the shape #1475 trimmed out of
967+
* the validation-rule types. Removed rather than implemented (#3676): no
968+
* caller in this repo or objectui passed either one, `II18nService`
969+
* (`contracts/i18n-service.ts`) takes only `locale` so a filter could only be
970+
* a post-filter on an already-materialized bundle — which is precisely not
971+
* the server-side work `keys` reads as saving — and against the nested
972+
* `TranslationData` shape #3778 settled on, a flat `keys: string[]` has no
973+
* defined meaning at all. Re-adding an optional filter is additive and
974+
* non-breaking the day a consumer actually needs one; a declared-but-unread
975+
* field is the exemplar the next author copies.
976+
*/
957977
export const GetTranslationsRequestSchema = lazySchema(() => z.object({
958978
locale: z.string().describe('BCP-47 locale code'),
959-
namespace: z.string().optional().describe('Translation namespace (e.g., objects, apps, messages)'),
960-
keys: z.array(z.string()).optional().describe('Specific translation keys to fetch'),
961979
}));
962980

963981
export const GetTranslationsResponseSchema = lazySchema(() => z.object({

0 commit comments

Comments
 (0)