diff --git a/.changeset/i18n-locales-supported-locales.md b/.changeset/i18n-locales-supported-locales.md new file mode 100644 index 0000000000..d6493d04ba --- /dev/null +++ b/.changeset/i18n-locales-supported-locales.md @@ -0,0 +1,69 @@ +--- +"@objectstack/spec": minor +"@objectstack/core": minor +"@objectstack/runtime": minor +"@objectstack/service-i18n": minor +--- + +fix(i18n): `GET /i18n/locales` reports the locales the app declared, not every locale a plugin happened to load (#7679) + +`GET /api/v1/i18n/locales` answered with four locale descriptors — `en`, +`zh-CN`, `ja-JP`, `es-ES` — on the showcase app, whose artifact declares +`i18n.supportedLocales: ['en', 'zh-CN']`. The envelope was correct (#3636); the +**set** was a superset. + +Nothing was wrong with what had been *loaded*. Every platform plugin +(`platform-objects`, `service-settings`, `service-storage`, `service-messaging`, +`service-realtime`, `plugin-security`, `plugin-sharing`, `plugin-webhooks`) +ships an `en/zh-CN/ja-JP/es-ES` bundle and pushes it at `kernel:ready`, which is +what a platform should do. What was wrong is that the **loaded** set was +reported as the **offered** set — two different facts owned by two different +parties. So a locale picker built from this route, including the platform's own +Settings > Localization select, offered `ja-JP` and `es-ES`: locales in which +only `sys_*` objects are translated, guaranteeing a mixed-language session for +everything the app itself owns. + +**What changed.** `II18nService` gains an optional +`setSupportedLocales(locales)`. `AppPlugin.loadTranslations` threads the +artifact's `i18n.supportedLocales` into it exactly the way it already threads +`defaultLocale`, and both providers of the `i18n` slot — `createMemoryI18n` in +`@objectstack/core` and `FileI18nAdapter` in `@objectstack/service-i18n` — +narrow what `getLocales()` reports to that declaration. The runtime app-plugin +layer is the only place this can originate: `getLocales()` sees what is loaded, +and the app's declaration is not visible below it. + +The narrowing is applied as a filter at **read** time, never as a prune of what +is stored, because the platform bundles arrive *after* the app plugin has run. + +**Only the reported set narrows.** Bundles stay loaded and stay servable: +`GET /i18n/translations/ja-JP` still answers on a stack that no longer +advertises `ja-JP`, and `t()` still resolves it. Unloading those bundles buys +nothing — `sys_*` translations for an unadvertised locale cost nothing sitting +in the map. + +Two questions the fix had to settle, both behaviour in their own right: + +- **An app that declares no `supportedLocales` is not narrowed.** Absent means + "no narrowing", and it keeps reporting every loaded locale — the behaviour it + has today. Every app written before this change declared nothing, so + narrowing an undeclared app to zero (or to its default alone) would have + emptied the picker on every stack whose author never opted in. An + `i18n` block carrying only a `defaultLocale`, and a `supportedLocales: []` + that declares no usable code, are both read the same way. +- **A declared locale with no bundle behind it is reported, not dropped.** If an + app declares a locale the platform plugins never shipped, it appears in the + response as declared-but-unserved rather than being silently intersected away. + The declaration is the app's statement of intent and the client is entitled to + see it; a quietly shortened list hides the authoring gap from both ends. + Reporting the declaration is also the only answer that does not depend on how + many bundles had loaded by the time the route was called. Reads for such a + locale degrade to the default/fallback exactly as a half-translated bundle's + missing keys already do. + +Reported locales now follow the **declared order** rather than the insertion +order of whichever plugin loaded first, so a picker renders the ordering the app +author wrote. + +`setSupportedLocales` is optional on the contract, like `setDefaultLocale`: a +third-party `II18nService` that does not implement it keeps its current +behaviour instead of failing to boot. diff --git a/packages/core/src/fallbacks/fallbacks.test.ts b/packages/core/src/fallbacks/fallbacks.test.ts index efffdc30a6..bf99ab2b64 100644 --- a/packages/core/src/fallbacks/fallbacks.test.ts +++ b/packages/core/src/fallbacks/fallbacks.test.ts @@ -314,3 +314,114 @@ describe('createMemoryI18n locale fallback', () => { expect(i18n.t('hello', 'ja')).toBe('Hello'); }); }); + +describe('createMemoryI18n supportedLocales narrowing (#7679)', () => { + // `GET /i18n/locales` advertised four locales on an app declaring two. + // Nothing was wrong with what was LOADED — every platform plugin ships an + // `en/zh-CN/ja-JP/es-ES` bundle and pushes it at `kernel:ready`, which is + // correct. What was wrong is that the loaded set was REPORTED as the set + // the app offers, so a picker built from the route handed users locales in + // which only `sys_*` metadata is translated. + + /** The four-locale platform reality this fix has to narrow. */ + function loadedFourLocales() { + const i18n = createMemoryI18n(); + i18n.loadTranslations('en', { objects: { sys_user: { label: 'User' } } }); + i18n.loadTranslations('zh-CN', { objects: { sys_user: { label: '用户' } } }); + i18n.loadTranslations('ja-JP', { objects: { sys_user: { label: 'ユーザー' } } }); + i18n.loadTranslations('es-ES', { objects: { sys_user: { label: 'Usuario' } } }); + return i18n; + } + + it('reports only the declared locales — the #7679 repro', () => { + const i18n = loadedFourLocales(); + expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']); + + i18n.setSupportedLocales(['en', 'zh-CN']); + expect(i18n.getLocales()).toEqual(['en', 'zh-CN']); + }); + + it('narrows what is REPORTED, not what is LOADED — undeclared locales stay servable', () => { + // Explicitly out of scope for #7679, and pinned so nobody "completes" + // the fix by unloading the bundles. `sys_*` translations for a locale + // the app does not advertise cost nothing sitting in the map, and + // anything that already asked for them by code keeps working. + const i18n = loadedFourLocales(); + i18n.setSupportedLocales(['en', 'zh-CN']); + + expect(i18n.getLocales()).not.toContain('ja-JP'); + expect(i18n.getTranslations('ja-JP')).toEqual({ objects: { sys_user: { label: 'ユーザー' } } }); + expect(i18n.t('objects.sys_user.label', 'ja-JP')).toBe('ユーザー'); + }); + + it('applies at READ time, so bundles loaded AFTER the declaration are narrowed too', () => { + // The ordering that makes a one-shot prune wrong: `AppPlugin` declares + // the set during its own setup, and the platform plugins push their + // bundles later, at `kernel:ready`. A prune would narrow only whatever + // had loaded first and the rest would grow straight back. + const i18n = createMemoryI18n(); + i18n.setSupportedLocales(['en', 'zh-CN']); + i18n.loadTranslations('en', { a: 'a' }); + i18n.loadTranslations('ja-JP', { a: 'あ' }); + + expect(i18n.getLocales()).toEqual(['en', 'zh-CN']); + }); + + it('DECISION 1 — an app that declares nothing reports every loaded locale', () => { + const i18n = loadedFourLocales(); + expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']); + + // Same answer for an explicit clear and for a declaration carrying no + // usable code: absent is "no narrowing", never "narrow to nothing". + i18n.setSupportedLocales(undefined); + expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']); + i18n.setSupportedLocales([]); + expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']); + }); + + it('DECISION 2 — a declared locale with no bundle is REPORTED, not dropped', () => { + // Declared-but-unserved. The declaration is the app's statement of + // intent and the client is entitled to see it; a silently shortened + // list leaves the authoring gap invisible on both sides. Reads degrade + // to the default locale exactly the way a half-translated bundle does. + const i18n = loadedFourLocales(); + i18n.setSupportedLocales(['en', 'zh-CN', 'fr-FR']); + + expect(i18n.getLocales()).toEqual(['en', 'zh-CN', 'fr-FR']); + expect(i18n.getTranslations('fr-FR')).toEqual({}); + expect(i18n.t('objects.sys_user.label', 'fr-FR')).toBe('User'); + }); + + it('a declaration can be replaced, and clearing restores the loaded set', () => { + const i18n = loadedFourLocales(); + i18n.setSupportedLocales(['en']); + expect(i18n.getLocales()).toEqual(['en']); + i18n.setSupportedLocales(['zh-CN', 'en']); + expect(i18n.getLocales()).toEqual(['zh-CN', 'en']); + i18n.setSupportedLocales(undefined); + expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']); + }); + + it('the caller cannot mutate the stored declaration through the array it passed or got back', () => { + const i18n = loadedFourLocales(); + const declared = ['en', 'zh-CN']; + i18n.setSupportedLocales(declared); + declared.push('ja-JP'); + expect(i18n.getLocales()).toEqual(['en', 'zh-CN']); + + i18n.getLocales().push('es-ES'); + expect(i18n.getLocales()).toEqual(['en', 'zh-CN']); + }); + + it('narrows the authored overlay too — authored-only locales are not a bypass', () => { + // `getLocales()` unions the static and authored maps, so a locale that + // exists only as runtime-authored `translation` metadata (#2591) would + // otherwise slip past the declaration. + const i18n = loadedFourLocales(); + i18n.replaceAuthoredTranslations({ 'ko-KR': { objects: { sys_user: { label: '사용자' } } } }); + expect(i18n.getLocales()).toContain('ko-KR'); + + i18n.setSupportedLocales(['en', 'zh-CN']); + expect(i18n.getLocales()).toEqual(['en', 'zh-CN']); + }); +}); diff --git a/packages/core/src/fallbacks/memory-i18n.ts b/packages/core/src/fallbacks/memory-i18n.ts index b5e3e43e4a..c9870ac074 100644 --- a/packages/core/src/fallbacks/memory-i18n.ts +++ b/packages/core/src/fallbacks/memory-i18n.ts @@ -1,5 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { normalizeSupportedLocales } from '@objectstack/spec/system'; + /** * Recursively merge `source` into `target`. Nested plain objects are merged * rather than replaced, so multiple plugins can each contribute their own @@ -84,6 +86,13 @@ export function createMemoryI18n() { // while authored values win over static bundle values on read. const authored = new Map>(); let defaultLocale = 'en'; + // [#7679] The app's DECLARED `i18n.supportedLocales`, injected by + // `AppPlugin.loadTranslations` the same way `defaultLocale` is. `undefined` + // means the app declared nothing, which must keep reporting every loaded + // locale. Held as a read-time filter, never as a prune of `translations`: + // platform plugins push their bundles at `kernel:ready`, after the app + // plugin has run, so anything pruned once would grow back. + let supportedLocales: string[] | undefined; /** * Resolve a dot-notation key from a nested object. @@ -170,10 +179,31 @@ export function createMemoryI18n() { } }, + /** + * Report the locales this stack offers. + * + * [#7679] When the app declared `i18n.supportedLocales`, that declaration + * IS the answer — in declared order, and including a declared locale no + * bundle was ever loaded for (declared-but-unserved). Reporting the + * declaration rather than an intersection is what gives a client the + * signal that the locale it is being offered has nothing behind it yet; + * quietly dropping it would leave the gap invisible on both sides. It is + * also the only answer that does not depend on how much had loaded by the + * time this was called. + * + * With nothing declared, the loaded set — the behaviour every app that + * never opted in already has. + */ getLocales(): string[] { + if (supportedLocales) return [...supportedLocales]; return [...new Set([...translations.keys(), ...authored.keys()])]; }, + /** @see II18nService.setSupportedLocales — [#7679] */ + setSupportedLocales(locales: readonly string[] | undefined): void { + supportedLocales = normalizeSupportedLocales(locales); + }, + getDefaultLocale(): string { return defaultLocale; }, diff --git a/packages/runtime/src/app-plugin.test.ts b/packages/runtime/src/app-plugin.test.ts index 3a60ee5706..df575c7a12 100644 --- a/packages/runtime/src/app-plugin.test.ts +++ b/packages/runtime/src/app-plugin.test.ts @@ -143,6 +143,7 @@ describe('AppPlugin', () => { mockI18n = { loadTranslations: vi.fn(), setDefaultLocale: vi.fn(), + setSupportedLocales: vi.fn(), getLocales: vi.fn().mockReturnValue([]), getDefaultLocale: vi.fn().mockReturnValue('en'), }; @@ -184,6 +185,86 @@ describe('AppPlugin', () => { expect(mockI18n.setDefaultLocale).toHaveBeenCalledWith('zh-CN'); }); + // ── #7679: `supportedLocales` is threaded like `defaultLocale` ────── + // This layer is the only one that can see the app's declaration, so + // these assertions are about the HANDOFF; what the provider then does + // with it is pinned in core / service-i18n, and the two ends are wired + // together in `i18n-supported-locales.test.ts`. + + it('should thread supportedLocales from i18n config to the i18n service', async () => { + const bundle = { + id: 'com.test.supported', + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }, + translations: [{ en: { messages: { hello: 'Hello' } } }], + }; + const plugin = new AppPlugin(bundle); + await plugin.start!(mockContext); + + expect(mockI18n.setSupportedLocales).toHaveBeenCalledWith(['en', 'zh-CN']); + }); + + it('should thread supportedLocales even when the app ships no bundles of its own', async () => { + // The showcase's shape: the app declares which locales it offers, + // while the translations arrive from the platform plugins. An + // early return on "no bundles" here would leave exactly the app + // this issue was filed against unnarrowed. + const bundle = { + id: 'com.test.declared-only', + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }, + }; + const plugin = new AppPlugin(bundle); + await plugin.start!(mockContext); + + expect(mockI18n.setSupportedLocales).toHaveBeenCalledWith(['en', 'zh-CN']); + expect(mockI18n.loadTranslations).not.toHaveBeenCalled(); + }); + + it('should NOT touch supportedLocales when the app declares none', async () => { + // Not merely "does not narrow" — does not CALL. Several AppPlugins + // share one kernel (the config apps are AppPlugins too), so an app + // with no `i18n` block clearing the declaration would undo the + // narrowing a sibling app had just set. + const bundle = { + id: 'com.test.undeclared', + i18n: { defaultLocale: 'zh-CN' }, + translations: [{ en: { messages: { hello: 'Hello' } } }], + }; + const plugin = new AppPlugin(bundle); + await plugin.start!(mockContext); + + expect(mockI18n.setSupportedLocales).not.toHaveBeenCalled(); + expect(mockI18n.setDefaultLocale).toHaveBeenCalledWith('zh-CN'); + }); + + it('should treat an empty supportedLocales declaration as no declaration', async () => { + const bundle = { + id: 'com.test.emptylocales', + i18n: { defaultLocale: 'en', supportedLocales: [] }, + translations: [{ en: { messages: { hello: 'Hello' } } }], + }; + const plugin = new AppPlugin(bundle); + await plugin.start!(mockContext); + + expect(mockI18n.setSupportedLocales).not.toHaveBeenCalled(); + }); + + it('should skip narrowing on a provider that does not implement setSupportedLocales', async () => { + // `setSupportedLocales` is OPTIONAL on `II18nService` (as + // `setDefaultLocale` is), so a third-party provider must keep + // booting rather than crash on a method it never declared. + delete mockI18n.setSupportedLocales; + const bundle = { + id: 'com.test.oldprovider', + i18n: { defaultLocale: 'en', supportedLocales: ['en'] }, + translations: [{ en: { messages: { hello: 'Hello' } } }], + }; + const plugin = new AppPlugin(bundle); + await plugin.start!(mockContext); + + expect(mockI18n.loadTranslations).toHaveBeenCalledWith('en', { messages: { hello: 'Hello' } }); + expect(mockContext.logger.error).not.toHaveBeenCalled(); + }); + it('should auto-register in-memory i18n fallback when service is not registered', async () => { vi.mocked(mockContext.getService).mockImplementation((name: string) => { if (name === 'objectql') return mockQL; diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 509396ee36..09052c77f5 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -1472,6 +1472,49 @@ export class AppPlugin implements Plugin { ctx.logger.debug('[i18n] Set default locale', { appId, locale: i18nConfig.defaultLocale }); } + // [#7679] Narrow what `getLocales()` REPORTS to the locales the app + // declared. This is the only layer that can: `getLocales()` sees the + // loaded set, and what is loaded is not the app's decision — every + // platform plugin (platform-objects, service-settings, service-storage, + // service-messaging, service-realtime, plugin-security, plugin-sharing, + // plugin-webhooks) pushes its own `en/zh-CN/ja-JP/es-ES` bundle at + // `kernel:ready`. A showcase declaring `['en','zh-CN']` therefore + // advertised four locales on `GET /i18n/locales`, two of which + // translate `sys_*` metadata and nothing the app owns. + // + // Threaded exactly like `defaultLocale` immediately above, and applied + // through the same optional-capability probe: `setSupportedLocales` is + // optional on `II18nService`, so a provider that has not implemented it + // keeps today's behaviour rather than breaking. + // + // Only the REPORTED set narrows. The bundles stay loaded and stay + // servable — an unreported locale still returns its `sys_*` + // translations if asked for by code. Unloading them is a bigger change + // than this fix and buys nothing. + // + // A locale declared with no bundle behind it is still reported + // (declared-but-unserved) rather than intersected away — see + // `normalizeSupportedLocales` / `II18nService.setSupportedLocales` for + // why, and note that an intersection computed HERE would in any case be + // wrong: the platform bundles have not arrived yet at this point in the + // lifecycle. + // Guarded on "declared something" rather than called unconditionally, + // for the same reason `setDefaultLocale` above is: several AppPlugins + // can share one kernel (the config apps are AppPlugins too), and an app + // that declares no `i18n` block must not clear the narrowing another + // app declared. Absent stays absent — that is the no-narrowing default, + // not something anyone has to write. + const declaredLocales = i18nConfig?.supportedLocales; + if ( + Array.isArray(declaredLocales) && declaredLocales.length > 0 + && typeof i18nService.setSupportedLocales === 'function' + ) { + i18nService.setSupportedLocales(declaredLocales); + ctx.logger.debug('[i18n] Narrowed reported locales to the app\'s declared set', { + appId, supportedLocales: declaredLocales, + }); + } + if (bundles.length === 0) { return; } diff --git a/packages/runtime/src/i18n-supported-locales.test.ts b/packages/runtime/src/i18n-supported-locales.test.ts new file mode 100644 index 0000000000..64c36cb326 --- /dev/null +++ b/packages/runtime/src/i18n-supported-locales.test.ts @@ -0,0 +1,207 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `GET /i18n/locales` reports the locales the APP declared (#7679). + * + * WHAT WENT WRONG + * + * The showcase declares `i18n.supportedLocales = ['en','zh-CN']` and the route + * answered with four descriptors — `en`, `zh-CN`, `ja-JP`, `es-ES`. Nothing + * was broken in the envelope (#3636 fixed that) and nothing was broken in the + * loading either: every platform plugin (platform-objects, service-settings, + * service-storage, service-messaging, service-realtime, plugin-security, + * plugin-sharing, plugin-webhooks) ships an `en/zh-CN/ja-JP/es-ES` bundle and + * pushes it at `kernel:ready`, which is what a platform should do. + * + * The defect was that the LOADED set was reported as the OFFERED set. Those + * are different facts owned by different parties: what is loaded is decided by + * whichever plugins are installed, while what is offered is the app author's + * declaration. A picker built from this route — the platform's own + * Settings > Localization select included — therefore offered `ja-JP` and + * `es-ES`, in which only `sys_*` objects are translated. Picking one gives a + * mixed-language session for every piece of metadata the app owns. + * + * WHY THE TEST LOOKS LIKE THIS + * + * The two halves of the bug live in two packages — `AppPlugin` is the only + * layer that can see `supportedLocales`, and `getLocales()` is the only thing + * the route reads — so a unit test on either half alone can pass while the + * route still answers wrong. This suite wires the real `AppPlugin`, the real + * `createMemoryI18n` provider and the real dispatcher domain together and + * asserts the BODY, which is the thing the issue is actually about. + * + * It also reproduces the LIFECYCLE ORDER, which is load-bearing: the app + * plugin declares during its own `start`, and the platform bundles arrive + * afterwards at `kernel:ready`. Any implementation that narrows by pruning + * what is stored passes a naive test and fails here. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { AppPlugin } from './app-plugin.js'; +import { HttpDispatcher } from './http-dispatcher.js'; +import { createMemoryI18n } from '@objectstack/core'; +import { GetLocalesResponseSchema } from '@objectstack/spec/api'; +import type { PluginContext } from '@objectstack/core'; + +/** The `en/zh-CN/ja-JP/es-ES` bundle every platform plugin pushes. */ +const PLATFORM_BUNDLE: Record> = { + 'en': { objects: { sys_user: { label: 'User' } } }, + 'zh-CN': { objects: { sys_user: { label: '用户' } } }, + 'ja-JP': { objects: { sys_user: { label: 'ユーザー' } } }, + 'es-ES': { objects: { sys_user: { label: 'Usuario' } } }, +}; + +/** The app's own bundle — only the locales the showcase actually authors. */ +const APP_BUNDLE: Record> = { + 'en': { objects: { property: { label: 'Property' } } }, + 'zh-CN': { objects: { property: { label: '房源' } } }, +}; + +function makeContext(i18n: unknown): PluginContext { + return { + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name === 'i18n') return i18n; + if (name === 'objectql') return { registry: {} }; + return undefined; + }), + getServices: vi.fn(), + hook: vi.fn(), + trigger: vi.fn(), + } as unknown as PluginContext; +} + +/** + * Boot the shape the issue describes: an app declaring `i18nConfig`, started + * FIRST, with the platform's four-locale bundle pushed AFTERWARDS the way the + * `kernel:ready` hooks do it. + */ +async function bootStack(i18nConfig: Record | undefined) { + const i18n = createMemoryI18n(); + const ctx = makeContext(i18n); + + const plugin = new AppPlugin({ + id: 'com.test.showcase', + ...(i18nConfig ? { i18n: i18nConfig } : {}), + translations: [APP_BUNDLE], + }); + await plugin.start!(ctx); + + // …then `kernel:ready`, where the platform plugins push theirs. + for (const [locale, data] of Object.entries(PLATFORM_BUNDLE)) { + i18n.loadTranslations(locale, data); + } + + const kernel = { + getService: vi.fn(async (name: string) => (name === 'i18n' ? i18n : null)), + services: new Map(), + context: { getService: (name: string) => (name === 'i18n' ? i18n : null) }, + }; + return { i18n, ctx, dispatcher: new HttpDispatcher(kernel as never) }; +} + +/** `GET /i18n/locales`, parsed with the schema that declares its body. */ +async function getLocales(dispatcher: HttpDispatcher) { + const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} } as never); + expect(result.response?.status).toBe(200); + const parsed = GetLocalesResponseSchema.safeParse(result.response?.body?.data); + expect( + parsed.success, + `locales body does not match its declared schema: ${JSON.stringify(parsed.error?.issues)}`, + ).toBe(true); + return parsed.data!.locales; +} + +describe('GET /i18n/locales reports the app\'s declared supportedLocales (#7679)', () => { + it('an app declaring two locales is not offered four', async () => { + const { dispatcher } = await bootStack({ defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }); + + expect(await getLocales(dispatcher)).toEqual([ + { code: 'en', label: 'en', isDefault: true }, + { code: 'zh-CN', label: 'zh-CN', isDefault: false }, + ]); + }); + + it('the platform locales the app never opted into are gone from the body', async () => { + // Stated as its own assertion because this is the user-visible harm: + // `ja-JP` and `es-ES` are real and servable, so nothing else about the + // response looks wrong — they simply translate `sys_*` and nothing the + // app owns, which is what makes the picker offering them a trap. + const { dispatcher } = await bootStack({ defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }); + const codes = (await getLocales(dispatcher)).map((l) => l.code); + + expect(codes).not.toContain('ja-JP'); + expect(codes).not.toContain('es-ES'); + }); + + it('narrowing survives bundles pushed after the app plugin declared', async () => { + // The lifecycle order in one assertion: the four platform locales are + // loaded by `bootStack` AFTER `AppPlugin.start` has run. A narrowing + // implemented as a prune of what is stored would report them all. + const { i18n, dispatcher } = await bootStack({ defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }); + + i18n.loadTranslations('ko-KR', { objects: { sys_user: { label: '사용자' } } }); + expect((await getLocales(dispatcher)).map((l) => l.code)).toEqual(['en', 'zh-CN']); + }); + + it('DECISION 1 — an app declaring no i18n block still sees every loaded locale', async () => { + // The compatibility half. Every app written before #7679 declared + // nothing, and each one must keep the answer it has today; narrowing + // an undeclared app to zero — or to its default alone — would empty + // the picker on a stack whose author never opted into anything. + const { dispatcher } = await bootStack(undefined); + const codes = (await getLocales(dispatcher)).map((l) => l.code).sort(); + + expect(codes).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']); + }); + + it('DECISION 1 — an i18n block with a defaultLocale but no supportedLocales does not narrow', async () => { + const { dispatcher } = await bootStack({ defaultLocale: 'zh-CN' }); + const locales = await getLocales(dispatcher); + + expect(locales.map((l) => l.code).sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']); + // …and the locale it DID declare is still threaded, unchanged. + expect(locales.find((l) => l.code === 'zh-CN')?.isDefault).toBe(true); + }); + + it('DECISION 2 — a declared locale with no bundle is reported, not silently dropped', async () => { + // `fr-FR` is declared by the app and shipped by nobody. It is reported + // because the declaration is the app's statement of intent and the + // client is entitled to see it; intersecting it away would hide the + // authoring gap from the author AND from the client, and would make + // the body depend on how many bundles had loaded when it was built. + const { dispatcher } = await bootStack({ + defaultLocale: 'en', + supportedLocales: ['en', 'zh-CN', 'fr-FR'], + }); + + expect((await getLocales(dispatcher)).map((l) => l.code)).toEqual(['en', 'zh-CN', 'fr-FR']); + }); + + it('the reported set narrows but the translations stay servable', async () => { + // Out of scope for #7679 and pinned so the fix is not "completed" by + // unloading bundles: `GET /i18n/translations/ja-JP` still answers. + const { dispatcher } = await bootStack({ defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }); + + const result = await dispatcher.handleI18n('/translations/ja-JP', 'GET', {}, { request: {} } as never); + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.translations) + .toEqual({ objects: { sys_user: { label: 'ユーザー' } } }); + }); + + it('the declared order is the reported order', async () => { + // Authored order, not the insertion order of whichever plugin loaded + // first — a picker rendering the app's own ordering is the useful + // default, and the previous order was an accident of registration. + const { dispatcher } = await bootStack({ + defaultLocale: 'zh-CN', + supportedLocales: ['zh-CN', 'en'], + }); + + expect(await getLocales(dispatcher)).toEqual([ + { code: 'zh-CN', label: 'zh-CN', isDefault: true }, + { code: 'en', label: 'en', isDefault: false }, + ]); + }); +}); diff --git a/packages/services/service-i18n/src/file-i18n-adapter.test.ts b/packages/services/service-i18n/src/file-i18n-adapter.test.ts index f6823ef79a..1b916213a9 100644 --- a/packages/services/service-i18n/src/file-i18n-adapter.test.ts +++ b/packages/services/service-i18n/src/file-i18n-adapter.test.ts @@ -219,3 +219,97 @@ describe('FileI18nAdapter', () => { }); }); }); + +describe('FileI18nAdapter supportedLocales narrowing (#7679)', () => { + // The production provider's half of the fix. Both providers of the `i18n` + // slot serve `GET /i18n/locales` interchangeably, so narrowing only the + // in-memory fallback would leave the same route answering two different sets + // depending on whether `I18nServicePlugin` happened to be installed — the + // exact split #3636 and #3833 each cost a round on this route family. + + /** The four-locale platform reality this fix has to narrow. */ + function loadedFourLocales(): FileI18nAdapter { + const i18n = new FileI18nAdapter({ defaultLocale: 'en', fallbackLocale: 'en' }); + i18n.loadTranslations('en', { objects: { sys_user: { label: 'User' } } }); + i18n.loadTranslations('zh-CN', { objects: { sys_user: { label: '用户' } } }); + i18n.loadTranslations('ja-JP', { objects: { sys_user: { label: 'ユーザー' } } }); + i18n.loadTranslations('es-ES', { objects: { sys_user: { label: 'Usuario' } } }); + return i18n; + } + + it('reports only the declared locales — the #7679 repro', () => { + const i18n = loadedFourLocales(); + expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']); + + i18n.setSupportedLocales(['en', 'zh-CN']); + expect(i18n.getLocales()).toEqual(['en', 'zh-CN']); + }); + + it('narrows what is REPORTED, not what is LOADED', () => { + const i18n = loadedFourLocales(); + i18n.setSupportedLocales(['en', 'zh-CN']); + + expect(i18n.getLocales()).not.toContain('es-ES'); + expect(i18n.getTranslations('es-ES')).toEqual({ objects: { sys_user: { label: 'Usuario' } } }); + expect(i18n.t('objects.sys_user.label', 'es-ES')).toBe('Usuario'); + }); + + it('applies at READ time, so bundles loaded AFTER the declaration are narrowed too', () => { + const i18n = new FileI18nAdapter({ defaultLocale: 'en' }); + i18n.setSupportedLocales(['en', 'zh-CN']); + i18n.loadTranslations('en', { a: 'a' }); + i18n.loadTranslations('ja-JP', { a: 'あ' }); + + expect(i18n.getLocales()).toEqual(['en', 'zh-CN']); + }); + + it('DECISION 1 — an app that declares nothing reports every loaded locale', () => { + const i18n = loadedFourLocales(); + expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']); + + i18n.setSupportedLocales(undefined); + expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']); + i18n.setSupportedLocales([]); + expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']); + }); + + it('DECISION 2 — a declared locale with no bundle is REPORTED, not dropped', () => { + const i18n = loadedFourLocales(); + i18n.setSupportedLocales(['en', 'zh-CN', 'fr-FR']); + + expect(i18n.getLocales()).toEqual(['en', 'zh-CN', 'fr-FR']); + expect(i18n.getTranslations('fr-FR')).toEqual({}); + // Degrades to the configured fallback, exactly as a half-translated + // bundle's missing keys do — declaring a locale nobody shipped is an + // authoring gap, not a broken response. + expect(i18n.t('objects.sys_user.label', 'fr-FR')).toBe('User'); + }); + + it('narrows the authored overlay too — authored-only locales are not a bypass', () => { + const i18n = loadedFourLocales(); + i18n.replaceAuthoredTranslations({ 'ko-KR': { objects: { sys_user: { label: '사용자' } } } }); + expect(i18n.getLocales()).toContain('ko-KR'); + + i18n.setSupportedLocales(['en', 'zh-CN']); + expect(i18n.getLocales()).toEqual(['en', 'zh-CN']); + }); + + it('the caller cannot mutate the stored declaration through the array it passed or got back', () => { + const i18n = loadedFourLocales(); + const declared = ['en', 'zh-CN']; + i18n.setSupportedLocales(declared); + declared.push('ja-JP'); + expect(i18n.getLocales()).toEqual(['en', 'zh-CN']); + + i18n.getLocales().push('es-ES'); + expect(i18n.getLocales()).toEqual(['en', 'zh-CN']); + }); + + it('both providers answer the same declaration identically', () => { + // The property that matters more than either provider's own behaviour: + // whichever one mounts `GET /i18n/locales`, the body is the same. + const file = loadedFourLocales(); + file.setSupportedLocales(['en', 'zh-CN', 'fr-FR']); + expect(file.getLocales()).toEqual(['en', 'zh-CN', 'fr-FR']); + }); +}); diff --git a/packages/services/service-i18n/src/file-i18n-adapter.ts b/packages/services/service-i18n/src/file-i18n-adapter.ts index 81d0110ed6..30ca3acbdd 100644 --- a/packages/services/service-i18n/src/file-i18n-adapter.ts +++ b/packages/services/service-i18n/src/file-i18n-adapter.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { normalizeSupportedLocales } from '@objectstack/spec/system'; import type { II18nService } from '@objectstack/spec/contracts'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -119,6 +120,15 @@ export class FileI18nAdapter implements II18nService { private readonly mergedCache = new Map>(); private defaultLocale: string; private readonly fallbackLocale: string | undefined; + /** + * [#7679] The app's DECLARED `i18n.supportedLocales`, injected by + * `AppPlugin.loadTranslations` the same way `defaultLocale` is. `undefined` + * means the app declared nothing, which must keep reporting every loaded + * locale. Deliberately NOT a prune of `translations`: the platform plugins + * push their bundles at `kernel:ready`, after the app plugin has run, so + * this can only work as a read-time filter in `getLocales()`. + */ + private supportedLocales: string[] | undefined; constructor(options: FileI18nAdapterOptions = {}) { this.defaultLocale = options.defaultLocale ?? 'en'; @@ -189,12 +199,33 @@ export class FileI18nAdapter implements II18nService { this.mergedCache.clear(); } + /** + * Report the locales this stack offers. + * + * [#7679] When the app declared `i18n.supportedLocales`, that declaration IS + * the answer — in declared order, and including a declared locale no bundle + * was ever loaded for (declared-but-unserved). Reporting the declaration + * rather than an intersection is what gives a client the signal that a + * locale it is offered has nothing behind it yet; quietly dropping it leaves + * the gap invisible on both sides. It is also the only answer independent of + * how much had loaded by the time this was called — the platform plugins are + * still pushing bundles at `kernel:ready`. + * + * With nothing declared, the loaded set — the behaviour every app that never + * opted in already has. + */ getLocales(): string[] { + if (this.supportedLocales) return [...this.supportedLocales]; const locales = new Set(this.translations.keys()); for (const locale of this.authoredTranslations.keys()) locales.add(locale); return Array.from(locales); } + /** @see II18nService.setSupportedLocales — [#7679] */ + setSupportedLocales(locales: readonly string[] | undefined): void { + this.supportedLocales = normalizeSupportedLocales(locales); + } + getDefaultLocale(): string { return this.defaultLocale; } diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index a032c6c710..aef086ce3d 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -781,6 +781,7 @@ "isPlatformProvidedToolName (function)", "isPublicAudience (function)", "minioStorageExample (const)", + "normalizeSupportedLocales (function)", "objectFieldLabelKey (function)", "objectLabelKey (function)", "operationMessageTranslationKey (function)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 0564f2b104..8be122e10c 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -781,6 +781,7 @@ "isPlatformProvidedToolName": "src/system/constants/platform-tool-names.ts#isPlatformProvidedToolName (function)", "isPublicAudience": "src/system/book.zod.ts#isPublicAudience (function)", "minioStorageExample": "src/system/object-storage.zod.ts#minioStorageExample (const)", + "normalizeSupportedLocales": "src/system/i18n-resolver.ts#normalizeSupportedLocales (function)", "objectFieldLabelKey": "src/system/i18n-resolver.ts#objectFieldLabelKey (function)", "objectLabelKey": "src/system/i18n-resolver.ts#objectLabelKey (function)", "operationMessageTranslationKey": "src/system/operation-message.ts#operationMessageTranslationKey (function)", diff --git a/packages/spec/src/contracts/i18n-service.ts b/packages/spec/src/contracts/i18n-service.ts index 4fc7b261b3..457ef68e98 100644 --- a/packages/spec/src/contracts/i18n-service.ts +++ b/packages/spec/src/contracts/i18n-service.ts @@ -57,6 +57,45 @@ export interface II18nService { */ setDefaultLocale?(locale: string): void; + /** + * Narrow what `getLocales()` reports to the locales the APP declared + * (`i18n.supportedLocales` on the stack artifact). + * + * [#7679] `getLocales()` on its own can only report what is LOADED, and + * what is loaded is not the app's decision: every platform plugin pushes + * its own `en/zh-CN/ja-JP/es-ES` bundle at `kernel:ready`, so a showcase + * declaring `['en','zh-CN']` advertised four locales on + * `GET /i18n/locales`. A picker built from that route then offers locales + * in which only `sys_*` metadata is translated — a guaranteed + * mixed-language session for everything the app owns. + * + * The declared set is only visible at the runtime app-plugin layer, which + * is why it arrives here by injection rather than being read: this is the + * same threading `setDefaultLocale` already gets from + * `AppPlugin.loadTranslations`. + * + * Implementations MUST apply this as a filter at READ time, not as a prune + * of what is stored. Bundles keep arriving after the app plugin has run + * (the platform plugins' `kernel:ready` push), so a one-shot prune would + * narrow only whatever happened to be loaded first. Narrowing is about + * what is REPORTED; the extra bundles stay loaded and stay servable. + * + * Semantics implementations must honour, both covered by tests: + * - Absent, empty, or a non-array → NO narrowing. An app that declares + * nothing keeps today's behaviour (report every loaded locale); + * narrowing it to zero or to the default alone would silently regress + * every app that never opted in. + * - A declared locale with no loaded bundle is still REPORTED + * (declared-but-unserved), not silently intersected away. The + * declaration is the app's statement of intent, and a client that is + * handed a quietly shortened list has no way to see the gap. It also + * keeps the answer independent of plugin load order, which an + * intersection cannot be. + * + * @param locales - Declared BCP-47 locale codes, or `undefined` to clear + */ + setSupportedLocales?(locales: readonly string[] | undefined): void; + /** * Field labels for one object in one locale, keyed by field name. * diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 29ea468124..aa42b249b8 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -14,6 +14,7 @@ import { translateMetadataDocument, resolveObjectFieldLabels, toLocaleDescriptors, + normalizeSupportedLocales, } from './i18n-resolver'; // #4854 — the served view document is whatever THIS composer emits, so the // fixture below is generated by it rather than transcribed from a bug report. @@ -1677,3 +1678,63 @@ describe('toLocaleDescriptors', () => { ).toMatch(/equals `code`/i); }); }); + +describe('normalizeSupportedLocales (#7679)', () => { + // The narrowing rule both providers of the `i18n` slot share. Held here, + // next to `toLocaleDescriptors`, because both of them can be the thing + // mounting `GET /i18n/locales` and a second copy is how that route came to + // answer in two shapes before. + + it('returns the declared codes in DECLARED order', () => { + // Order is authored, so it is preserved: a picker rendering the app's own + // ordering is the useful default. The loaded-key order it replaces was an + // insertion-order accident of plugin registration. + expect(normalizeSupportedLocales(['zh-CN', 'en', 'ja-JP'])).toEqual(['zh-CN', 'en', 'ja-JP']); + }); + + it('de-duplicates and trims, keeping first occurrence', () => { + expect(normalizeSupportedLocales([' en ', 'zh-CN', 'en'])).toEqual(['en', 'zh-CN']); + }); + + it('DECISION 1 — absent means NO narrowing, never an empty set', () => { + // An app that declared nothing must keep reporting every loaded locale. + // Returning `[]` here instead of `undefined` would narrow every app that + // predates #7679 down to zero locales — a silent, total regression on a + // route whose only consumers are locale pickers. + expect(normalizeSupportedLocales(undefined)).toBeUndefined(); + expect(normalizeSupportedLocales(null as unknown as string[])).toBeUndefined(); + expect(normalizeSupportedLocales('en' as unknown as string[])).toBeUndefined(); + }); + + it('DECISION 1 — a declaration with no usable code is also NO narrowing', () => { + // `[]` is read as an authoring accident, not as "serve no locales": + // `TranslationConfigSchema` requires `supportedLocales` whenever `i18n` is + // declared at all, so nothing in the spec asks an author to write the + // empty array, while an empty REPORT breaks every client built on it. + expect(normalizeSupportedLocales([])).toBeUndefined(); + expect(normalizeSupportedLocales(['', ' '])).toBeUndefined(); + expect(normalizeSupportedLocales([42, null] as unknown as string[])).toBeUndefined(); + }); + + it('keeps the usable codes out of a partly-garbage declaration', () => { + expect(normalizeSupportedLocales(['en', 42, '', 'zh-CN'] as unknown as string[])) + .toEqual(['en', 'zh-CN']); + }); + + it('DECISION 2 — the declaration is passed through UNINTERSECTED', () => { + // This function never sees what is loaded, and that is the point: a + // declared locale with no bundle behind it survives to be REPORTED + // (declared-but-unserved). An intersection is not merely less honest, it + // is unstable — the platform plugins are still pushing bundles at + // `kernel:ready`, so its result would depend on when it ran. + expect(normalizeSupportedLocales(['en', 'fr-FR'])).toEqual(['en', 'fr-FR']); + }); + + it('feeds toLocaleDescriptors directly — the reported body for a narrowed app', () => { + const declared = normalizeSupportedLocales(['en', 'zh-CN']); + expect(toLocaleDescriptors(declared, 'en')).toEqual([ + { code: 'en', label: 'en', isDefault: true }, + { code: 'zh-CN', label: 'zh-CN', isDefault: false }, + ]); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 4af8c205ed..d7a7eb7aa5 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1130,6 +1130,48 @@ export function toLocaleDescriptors( return codes.map((code) => ({ code, label: code, isDefault: code === defaultLocale })); } +/** + * Normalize an app's declared `i18n.supportedLocales` into the value an + * `II18nService` stores for `setSupportedLocales` — or `undefined`, meaning + * "no narrowing". + * + * [#7679] Shared for the same reason `toLocaleDescriptors` above is: BOTH + * providers of the `i18n` slot implement `setSupportedLocales` + * (`createMemoryI18n` in core, `FileI18nAdapter` in service-i18n) and either + * one can be the thing mounting `GET /i18n/locales`. Two copies of "what + * counts as a declaration" is how the same route came to answer in two shapes + * before (#3636), one narrowing rule over. + * + * `undefined` — no narrowing — is returned for every input that is not a + * declaration of at least one usable code: + * - absent / not an array: the app opted into nothing, so it keeps reporting + * every loaded locale. Narrowing an undeclared app to zero (or to its + * default alone) would silently regress every app that predates #7679. + * - an array that yields no usable code (empty, or nothing but blanks and + * non-strings): read as an authoring accident rather than as "serve no + * locales at all". An empty report breaks every picker built on this route, + * and `TranslationConfigSchema` requires `supportedLocales` when `i18n` is + * declared at all — so the empty array is not a shape the spec asks anyone + * to write. + * + * Otherwise: the declared codes, trimmed, de-duplicated, in DECLARED order. + * Order is preserved because it is authored — a picker rendering the list in + * the order the app wrote it is the useful default, and the loaded-key order + * it replaces was an insertion-order accident of plugin registration. + */ +export function normalizeSupportedLocales( + locales: readonly string[] | undefined, +): string[] | undefined { + if (!Array.isArray(locales)) return undefined; + const seen = new Set(); + for (const raw of locales) { + if (typeof raw !== 'string') continue; + const code = raw.trim(); + if (code) seen.add(code); + } + return seen.size > 0 ? [...seen] : undefined; +} + /** * Enumerate an object's translated field labels out of ONE locale's * `TranslationData` — the `GET /i18n/labels/:object/:locale` body.