diff --git a/.changeset/i18n-declared-fallback-chain-rest.md b/.changeset/i18n-declared-fallback-chain-rest.md new file mode 100644 index 0000000000..fb056b1e3e --- /dev/null +++ b/.changeset/i18n-declared-fallback-chain-rest.md @@ -0,0 +1,27 @@ +--- +"@objectstack/rest": patch +--- + +fix(rest): metadata label lookup honours the stack's declared `i18n.fallbackLocale` / `defaultLocale` instead of falling through to the `en` bundle (#14882) + +On a workspace whose labels are authored in `zh-CN` (`defaultLocale: 'zh-CN'`, +`fallbackLocale: 'zh-CN'`) and which ships only a courtesy `en` translation bundle, +`GET /api/v1/meta/object/:name`, the `/meta/:type` list, `GET /api/v1/meta` and the +public-form schema served the ENGLISH bundle labels to a `zh-CN` request (`Entry Sheet` +for an authored `填报单`, `KPI Assessment` for `KPI 考核管理`). The document translators walk +`requested locale → fallback chain → authored label` and default the chain to a literal +`['en']`; every REST seam passed none, so the declared fallback never reached the chain +and `en` was consulted before the authored label. + +Every metadata translation seam now passes `fallbackChain: [i18n.getFallbackLocale()]` — +the locale the i18n service's own `t()` falls back to, which `I18nServicePlugin` receives +from the stack config as `fallbackLocale || defaultLocale || 'en'`. For the workspace +above a `zh-CN` request now resolves `zh-CN → zh-CN → authored label` (the authored +Chinese labels), an `en` request still gets the `en` bundle, and a `zh-CN` bundle, when one +is shipped, still wins over the authored label. + +Feature-detected: an i18n service that does not declare a fallback (the method is +optional on `II18nService`; the core in-memory fallback has none) gets no chain and the +resolver's own default applies exactly as before. A stack declaring `defaultLocale: 'zh-CN'` +with `fallbackLocale: 'en'` is likewise unchanged — the declared `en` is honoured as it +reads. diff --git a/.changeset/i18n-declared-fallback-chain-service.md b/.changeset/i18n-declared-fallback-chain-service.md new file mode 100644 index 0000000000..1979908b5f --- /dev/null +++ b/.changeset/i18n-declared-fallback-chain-service.md @@ -0,0 +1,11 @@ +--- +"@objectstack/service-i18n": minor +--- + +feat(service-i18n): `FileI18nAdapter.getFallbackLocale()` reports the `fallbackLocale` the adapter was constructed with (#14882) + +Implements the new optional `II18nService.getFallbackLocale()`. `I18nServicePlugin` +already receives `fallbackLocale || defaultLocale || 'en'` from the stack's `i18n` +config on both boot paths (`os serve`, the dev plugin); this makes that declaration +readable, so the REST metadata reads pass the document translators the same fallback +locale `t()` itself consults. Returns `undefined` when no `fallbackLocale` was given. diff --git a/.changeset/i18n-declared-fallback-chain-spec.md b/.changeset/i18n-declared-fallback-chain-spec.md new file mode 100644 index 0000000000..a030769f8b --- /dev/null +++ b/.changeset/i18n-declared-fallback-chain-spec.md @@ -0,0 +1,26 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `II18nService.getFallbackLocale()` — the declared fallback locale is readable, so the metadata-document translators can be handed the chain the deployment declared (#14882) + +`ResolveOptions.fallbackChain` on the `@objectstack/spec/system` label +resolvers (`translateMetadataDocument`, `translateObject`, `translateApp`, +`resolveViewLabel`, …) is the ordered list of locales consulted after the +requested one and BEFORE the authored label. Nothing on `II18nService` +exposed the deployment's declared fallback (`i18n.fallbackLocale`, else +`defaultLocale`), so no serving layer could thread it, and every caller fell +to the resolver's literal `['en']` default. A `zh-CN` workspace that shipped a +courtesy `en` bundle therefore served English bundle text to a `zh-CN` +request ahead of its own authored Chinese labels. + +- New optional contract member `II18nService.getFallbackLocale?(): string | undefined` + — the locale the service's own `t()` consults second. `undefined` (or the + method absent) means nothing was declared, and a serving layer must then + leave the resolver's default in place rather than invent a chain. +- The `fallbackChain` documentation now states who supplies it (the serving + layer, from `getFallbackLocale()`) and that the `['en']` default applies + only when a caller declares no chain at all. The resolver's behaviour for + a caller that passes nothing is unchanged. + +Additive: no existing implementation or caller changes shape. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 7da996623b..54e8df7a9c 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 106 sites**. |:--|:---|:---|:---|:---| | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5048`, `:6474`, `:6722`, `:7153`, `:7346` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5084`, `:6510`, `:6758`, `:7189`, `:7382` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | diff --git a/packages/rest/src/meta-i18n-declared-fallback-chain.test.ts b/packages/rest/src/meta-i18n-declared-fallback-chain.test.ts new file mode 100644 index 0000000000..a59c5a9fa3 --- /dev/null +++ b/packages/rest/src/meta-i18n-declared-fallback-chain.test.ts @@ -0,0 +1,321 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #14882 — the metadata reads hand the document translators the DECLARED + * fallback chain, not the resolver's literal `['en']`. + * + * The RULE lives in `@objectstack/spec/system`: the label resolvers walk + * `requested locale → fallbackChain → authored label`, and honour whatever + * chain they are handed (pinned in `i18n-resolver.test.ts`). What can only be + * tested here is the PLUMBING — that every seam translating a metadata + * document passes `fallbackChain: [i18n.getFallbackLocale()]`, the locale the + * i18n service's own `t()` falls back to, which `I18nServicePlugin` receives + * from the stack's `i18n` config as `fallbackLocale || defaultLocale || 'en'`. + * Before this, every seam passed NO chain, so the declared `fallbackLocale` + * never reached the resolver and `en` was consulted before the authored label. + * + * The fixture is the card's workspace: labels authored in the default locale + * (`zh-CN`), a courtesy `en` bundle for English users, and NO `zh-CN` bundle + * — `getLocales()` still reports `zh-CN` (declared), with an empty bundle + * behind it, exactly what `buildTranslationBundle` sees on the reporter's + * stack. `subject_type` has no `en` entry: the reporter's own control, the + * one field that stayed Chinese while its siblings flipped to English. + * + * Seams covered: `GET /meta/:type/:name` (object and app), `GET /meta/:type` + * (list), `GET /meta` (the types listing) — and the feature-detection + * contract for a service that declares no fallback. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server.js'; + +// --------------------------------------------------------------------------- +// Fixtures — the card's workspace +// --------------------------------------------------------------------------- + +const SHEET = { + name: 'kpi_entry_sheet', + label: '填报单', + pluralLabel: '填报单', + fields: { + name: { name: 'name', type: 'text', label: '填报单名称' }, + status: { name: 'status', type: 'select', label: '状态' }, + total_score: { name: 'total_score', type: 'number', label: '最终得分' }, + subject_type: { name: 'subject_type', type: 'select', label: '主体类型' }, + }, +}; + +const KPI_APP = { name: 'kpi_app', label: 'KPI 考核管理', navigation: [] }; + +/** The courtesy `en` bundle — `defineTranslationBundle({ en: {...} })`. */ +const EN_DATA = { + objects: { + kpi_entry_sheet: { + label: 'Entry Sheet', + pluralLabel: 'Entry Sheets', + fields: { name: { label: 'Sheet' }, status: { label: 'Status' }, total_score: { label: 'Final Score' } }, + }, + }, + apps: { kpi_app: { label: 'KPI Assessment' } }, + metadataForms: { object: { label: 'Object' } }, +}; + +/** What `os i18n extract --locales=zh-CN` would ship — the reporter's workaround. */ +const ZH_DATA = { objects: { kpi_entry_sheet: { label: '填报单(bundle)' } } }; + +const AUTHORED = { + label: '填报单', pluralLabel: '填报单', + name: '填报单名称', status: '状态', total_score: '最终得分', subject_type: '主体类型', +}; +const ENGLISH = { + label: 'Entry Sheet', pluralLabel: 'Entry Sheets', + name: 'Sheet', status: 'Status', total_score: 'Final Score', subject_type: '主体类型', +}; + +// --------------------------------------------------------------------------- +// Doubles +// --------------------------------------------------------------------------- + +/** + * An `II18nService` double shaped like `FileI18nAdapter` on the reporter's + * stack: every declared locale is reported, an undeclared bundle reads as + * `{}`, and `getFallbackLocale()` answers what the plugin was constructed + * with. `fallbackLocale: null` builds a service that does NOT implement the + * accessor at all (the older-provider / in-memory-fallback control). + */ +function i18nFor(opts: { + bundles: Record; + defaultLocale: string; + fallbackLocale: string | undefined | null; +}) { + const svc: any = { + getLocales: () => Object.keys(opts.bundles), + getTranslations: (locale: string) => opts.bundles[locale] ?? {}, + getDefaultLocale: () => opts.defaultLocale, + }; + if (opts.fallbackLocale !== null) svc.getFallbackLocale = () => opts.fallbackLocale; + return svc; +} + +/** The card's stack: `defaultLocale: 'zh-CN'`, `fallbackLocale: 'zh-CN'`, `en` bundle only. */ +const CARD = () => i18nFor({ bundles: { 'zh-CN': {}, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: 'zh-CN' }); + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + return { json: vi.fn(), status: vi.fn().mockReturnThis(), header: vi.fn(), send: vi.fn() }; +} + +const singular = (type: string) => (type.endsWith('s') ? type.slice(0, -1) : type); +const DOCUMENTS: Record = { object: SHEET, app: KPI_APP }; + +function protocol() { + return { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', + routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn(async () => ({ + entries: [{ type: 'object', label: '对象' }], + types: ['object', 'app'], + registered: ['object', 'app'], + })), + getMetaItems: vi.fn(async ({ type }: any) => (DOCUMENTS[singular(type)] ? [DOCUMENTS[singular(type)]] : [])), + getMetaItem: vi.fn(async ({ type, name }: any) => ({ + type: singular(type), + name, + item: DOCUMENTS[singular(type)], + lock: 'none', + editable: true, + })), + getMetaItemCached: undefined as any, + // [#8284] The packaged base equals the served document: nothing was + // authored on top of it, so the catalog applies — the path the card + // measured, where the wrong CATALOG locale answered. + getPackagedObjectBase: vi.fn((name: string) => (name === SHEET.name ? SHEET : undefined)), + findData: vi.fn().mockResolvedValue([]), + }; +} + +function makeRest(i18n: any) { + const rest = new RestServer( + mockServer() as any, protocol() as any, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, + // i18nServiceProvider — the 14th constructor argument. + async () => i18n, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] }); + rest.registerRoutes(); + return rest; +} + +function routeFor(rest: RestServer, path: string) { + const route = (rest as any).getRoutes().find((r: any) => r.method === 'GET' && r.path === path); + if (!route) throw new Error(`route not registered: GET ${path}`); + return route; +} + +/** Indexed rather than `.at(-1)`: this package's `lib` target predates ES2022. */ +function lastBody(res: ReturnType): any { + const calls = res.json.mock.calls; + return calls.length ? calls[calls.length - 1][0] : undefined; +} + +/** `Accept-Language` absent when `locale` is `undefined` — the card's "no header" request. */ +const headersFor = (locale: string | undefined) => (locale ? { 'accept-language': locale } : {}); + +async function readItem(rest: RestServer, type: string, name: string, locale: string | undefined): Promise { + const res = mockRes(); + await routeFor(rest, '/api/v1/meta/:type/:name').handler( + { method: 'GET', params: { type, name }, query: {}, body: {}, headers: headersFor(locale) }, + res, + ); + return lastBody(res)?.item; +} + +async function readList(rest: RestServer, type: string, locale: string | undefined): Promise { + const res = mockRes(); + await routeFor(rest, '/api/v1/meta/:type').handler( + { method: 'GET', params: { type }, query: {}, body: {}, headers: headersFor(locale) }, + res, + ); + const body = lastBody(res); + return Array.isArray(body) ? body : body?.items ?? []; +} + +async function readTypes(rest: RestServer, locale: string | undefined): Promise { + const res = mockRes(); + await routeFor(rest, '/api/v1/meta').handler( + { method: 'GET', params: {}, query: {}, body: {}, headers: headersFor(locale) }, + res, + ); + return lastBody(res); +} + +const labelsOf = (doc: any) => ({ + label: doc.label, + pluralLabel: doc.pluralLabel, + name: doc.fields.name.label, + status: doc.fields.status.label, + total_score: doc.fields.total_score.label, + subject_type: doc.fields.subject_type.label, +}); + +// --------------------------------------------------------------------------- +// §1 — the card: a zh-CN request on a zh-CN workspace, en bundle present +// --------------------------------------------------------------------------- + +describe('#14882 §1 — a zh-CN request resolves to the authored labels', () => { + it('GET /meta/object/:name serves the authored Chinese labels', async () => { + expect(labelsOf(await readItem(makeRest(CARD()), 'object', 'kpi_entry_sheet', 'zh-CN'))).toEqual(AUTHORED); + }); + + it('GET /meta/app/:name serves the authored app label', async () => { + expect((await readItem(makeRest(CARD()), 'app', 'kpi_app', 'zh-CN')).label).toBe('KPI 考核管理'); + }); + + it('with NO Accept-Language the workspace default (zh-CN) answers the same', async () => { + expect(labelsOf(await readItem(makeRest(CARD()), 'object', 'kpi_entry_sheet', undefined))).toEqual(AUTHORED); + expect((await readItem(makeRest(CARD()), 'app', 'kpi_app', undefined)).label).toBe('KPI 考核管理'); + }); + + it('the list read agrees with the single read', async () => { + const [sheet] = await readList(makeRest(CARD()), 'object', 'zh-CN'); + expect(labelsOf(sheet)).toEqual(AUTHORED); + const [app] = await readList(makeRest(CARD()), 'app', 'zh-CN'); + expect(app.label).toBe('KPI 考核管理'); + }); + + it('the types listing keeps its authored metadata-type label', async () => { + const body = await readTypes(makeRest(CARD()), 'zh-CN'); + expect(body.entries.find((e: any) => e.type === 'object').label).toBe('对象'); + }); +}); + +// --------------------------------------------------------------------------- +// §2 — the en bundle still serves an en request +// --------------------------------------------------------------------------- + +describe('#14882 §2 — an en request still gets the courtesy en bundle', () => { + it('GET /meta/object/:name serves the en bundle, subject_type stays authored', async () => { + expect(labelsOf(await readItem(makeRest(CARD()), 'object', 'kpi_entry_sheet', 'en'))).toEqual(ENGLISH); + }); + + it('GET /meta/app/:name serves the en app label', async () => { + expect((await readItem(makeRest(CARD()), 'app', 'kpi_app', 'en')).label).toBe('KPI Assessment'); + }); + + it('the types listing serves the en metadata-type label', async () => { + const body = await readTypes(makeRest(CARD()), 'en'); + expect(body.entries.find((e: any) => e.type === 'object').label).toBe('Object'); + }); +}); + +// --------------------------------------------------------------------------- +// §3 — the controls the card itself measured +// --------------------------------------------------------------------------- + +describe('#14882 §3 — controls', () => { + it('remove the en bundle: the zh-CN answer does not move', async () => { + const noEn = i18nFor({ bundles: { 'zh-CN': {} }, defaultLocale: 'zh-CN', fallbackLocale: 'zh-CN' }); + expect(labelsOf(await readItem(makeRest(noEn), 'object', 'kpi_entry_sheet', 'zh-CN'))).toEqual(AUTHORED); + }); + + it('ship a zh-CN bundle: it still wins over the authored label', async () => { + // The reporter's workaround (`os i18n extract --locales=zh-CN`) is + // the documented per-locale layout and must keep working: a bundle + // entry for the requested locale IS the translation. + const withZh = i18nFor({ + bundles: { 'zh-CN': ZH_DATA, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: 'zh-CN', + }); + const item = await readItem(makeRest(withZh), 'object', 'kpi_entry_sheet', 'zh-CN'); + expect(item.label).toBe('填报单(bundle)'); + // A field the zh-CN bundle does not mention still resolves through the + // declared chain (zh-CN → zh-CN → authored), never through `en`. + expect(item.fields.name.label).toBe('填报单名称'); + }); +}); + +// --------------------------------------------------------------------------- +// §4 — the feature-detection contract: no declaration, no invented chain +// --------------------------------------------------------------------------- + +describe('#14882 §4 — a service that declares no fallback keeps the resolver default', () => { + // The serving layer threads a DECLARATION; it does not derive one. An + // i18n provider without the accessor (or answering `undefined`) gets no + // chain, so the resolver's own `['en']` default applies exactly as it did + // before this card — the pre-#14882 answer, pinned so a later "helpful" + // derivation from `getDefaultLocale()` cannot land unnoticed (it would + // decide the contract question §5 leaves open). + it('a provider without getFallbackLocale answers as before (en consulted)', async () => { + const legacy = i18nFor({ bundles: { 'zh-CN': {}, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: null }); + expect((await readItem(makeRest(legacy), 'object', 'kpi_entry_sheet', 'zh-CN')).label).toBe('Entry Sheet'); + }); + + it('a provider answering undefined answers as before (en consulted)', async () => { + const undeclared = i18nFor({ bundles: { 'zh-CN': {}, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: undefined }); + expect((await readItem(makeRest(undeclared), 'object', 'kpi_entry_sheet', 'zh-CN')).label).toBe('Entry Sheet'); + }); +}); + +// --------------------------------------------------------------------------- +// §5 — a stack that DECLARES en as its fallback is honoured as it reads +// --------------------------------------------------------------------------- + +describe('#14882 §5 — a declared en fallback still consults en before the authored label', () => { + // `defaultLocale: 'zh-CN'`, `fallbackLocale: 'en'`, no zh-CN bundle. + // Pinned as it answers today — the `en` bundle — because whether the + // authored label is the default-locale source (and so should outrank a + // declared fallback's bundle) is a CONTRACT question this card does not + // decide. #14882 changes which chain reaches the resolver, nothing else. + it('GET /meta/object/:name serves the en bundle for a zh-CN request', async () => { + const enFallback = i18nFor({ bundles: { 'zh-CN': {}, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: 'en' }); + expect((await readItem(makeRest(enFallback), 'object', 'kpi_entry_sheet', 'zh-CN')).label).toBe('Entry Sheet'); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 1f1ee559ad..ed87a658cd 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3185,6 +3185,39 @@ export class RestServer { return undefined; } + /** + * [#14882] The `ResolveOptions` every metadata-document translation in + * this server hands `@objectstack/spec/system`: the request's locale plus + * the deployment's DECLARED fallback chain. + * + * The resolvers walk `requested locale → fallbackChain → authored label`, + * and default the chain to a literal `['en']` when a caller passes none. + * Every seam here used to pass none, so the stack's `i18n.fallbackLocale` + * never reached the chain: a `zh-CN` workspace that shipped a courtesy + * `en` bundle served `Entry Sheet` to a `zh-CN` request, ahead of its own + * authored `填报单`, because `en` was consulted before the authored label. + * + * The chain is read from the i18n service — `getFallbackLocale()`, the + * locale its own `t()` falls back to, which `I18nServicePlugin` receives + * as `fallbackLocale || defaultLocale || 'en'` from the stack config — so + * a bundle label and a `t()` message agree on which locale comes second. + * Feature-detected like `getPackagedObjectBase`: a service that does not + * declare a fallback (the method is optional on `II18nService`, and the + * core in-memory fallback has no declared one) gets NO chain, so the + * resolver's own default applies exactly as before — the serving layer + * threads a declaration, it does not invent one. ⛔ Not derived from + * `getDefaultLocale()`: that would decide, for a stack declaring + * `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'`, whether the + * authored label or the `en` bundle answers a `zh-CN` request — a + * contract question this seam must not answer on its own. + */ + private static translateOptionsFor(i18n: any, locale: string): { locale: string; fallbackChain?: string[] } { + const fallback = i18n && typeof i18n.getFallbackLocale === 'function' ? i18n.getFallbackLocale() : undefined; + return typeof fallback === 'string' && fallback.length > 0 + ? { locale, fallbackChain: [fallback] } + : { locale }; + } + /** * An `II18nService.t`-compatible lookup for the request's environment, or * `undefined` when no i18n service is registered. Handed to the import @@ -3261,7 +3294,10 @@ export class RestServer { (item as any)?.name, ) : undefined; - return translateMetadataDocument(metaType, item, bundle, { locale, packagedBase }); + return translateMetadataDocument(metaType, item, bundle, { + ...RestServer.translateOptionsFor(i18n, locale), + packagedBase, + }); } /** @@ -3503,7 +3539,7 @@ export class RestServer { // the OUTER `{ type, items }`), so every element translates directly — // #5563 removed the per-element shape sniff that stood here. const translated = arr.map((item) => translateMetadataDocument(metaType, item, bundle, { - locale, + ...RestServer.translateOptionsFor(i18n, locale), packagedBase: this.packagedObjectBase(p, metaType, item?.name), })); return Array.isArray(items) ? translated : { ...items, items: translated }; @@ -3530,7 +3566,7 @@ export class RestServer { resolveMetadataTypeDescription, resolveMetadataFormLabels, } = await import('@objectstack/spec/system'); - const opts = { locale } as const; + const opts = RestServer.translateOptionsFor(i18n, locale); const entries = payload.entries.map((entry: any) => { if (!entry || typeof entry !== 'object' || typeof entry.type !== 'string') return entry; const next: any = { ...entry }; @@ -9628,7 +9664,7 @@ export class RestServer { // one surface still serving the packaged // string back at a tenant who renamed it. objectSchema = translateMetadataDocument('object', objectSchema, bundle, { - locale, + ...RestServer.translateOptionsFor(i18n, locale), packagedBase: this.packagedObjectBase(p, 'object', objectSchema?.name), }); } diff --git a/packages/services/service-i18n/README.md b/packages/services/service-i18n/README.md index 068f2312a8..c6cd5d7ac5 100644 --- a/packages/services/service-i18n/README.md +++ b/packages/services/service-i18n/README.md @@ -43,7 +43,7 @@ locale" to set: every call names the locale it wants. |:---|:---|:---|:---| | `defaultLocale` | `string` | `'en'` | Reported by `getDefaultLocale()`; used as the adapter's default. | | `localesDir` | `string` | none | Directory of `{locale}.json` files loaded at construction. | -| `fallbackLocale` | `string` | none | Consulted when a key is missing in the requested locale. | +| `fallbackLocale` | `string` | none | Consulted when a key is missing in the requested locale; reported by `getFallbackLocale()`, which the REST metadata reads pass to the document translators as their fallback chain (#14882). | | `registerRoutes` | `boolean` | `true` | Register the REST routes at `kernel:ready`. | | `basePath` | `string` | `'/api/v1/i18n'` | Base path for those routes. | @@ -83,7 +83,7 @@ number / relative-time formatting in this package — use `Intl` for those. ## Service API `II18nService` (from `@objectstack/spec/contracts`) declares four required members plus -optional ones; `FileI18nAdapter` implements the required four and three of the optional. +optional ones; `FileI18nAdapter` implements the required four and four of the optional. ```typescript import type { II18nService } from '@objectstack/spec/contracts'; @@ -95,6 +95,7 @@ import type { II18nService } from '@objectstack/spec/contracts'; // getLocales() -> string[] // optional, implemented here // getDefaultLocale() / setDefaultLocale(locale) +// getFallbackLocale() -> string | undefined (the locale t() consults second) // setSupportedLocales(locales | undefined) ``` 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 1b916213a9..e09b0db863 100644 --- a/packages/services/service-i18n/src/file-i18n-adapter.test.ts +++ b/packages/services/service-i18n/src/file-i18n-adapter.test.ts @@ -16,6 +16,7 @@ describe('FileI18nAdapter', () => { expect(typeof i18n.getLocales).toBe('function'); expect(typeof i18n.getDefaultLocale).toBe('function'); expect(typeof i18n.setDefaultLocale).toBe('function'); + expect(typeof i18n.getFallbackLocale).toBe('function'); }); it('should default to "en" locale', () => { @@ -34,6 +35,37 @@ describe('FileI18nAdapter', () => { expect(i18n.getDefaultLocale()).toBe('ja'); }); + // #14882 — the declared fallback locale is readable, so the REST metadata + // reads can hand the document translators the chain `t()` itself walks. + describe('getFallbackLocale', () => { + it('reports the fallback locale it was constructed with', () => { + expect(new FileI18nAdapter({ fallbackLocale: 'zh-CN' }).getFallbackLocale()).toBe('zh-CN'); + }); + + it('reports undefined when no fallback locale was declared', () => { + expect(new FileI18nAdapter().getFallbackLocale()).toBeUndefined(); + // `defaultLocale` alone is NOT a fallback declaration at this layer — + // the boot paths collapse `fallbackLocale || defaultLocale` before + // constructing the adapter, and this accessor reports what arrived. + expect(new FileI18nAdapter({ defaultLocale: 'zh-CN' }).getFallbackLocale()).toBeUndefined(); + }); + + it('answers the locale t() falls back to', () => { + const i18n = new FileI18nAdapter({ defaultLocale: 'zh-CN', fallbackLocale: 'zh-CN' }); + i18n.loadTranslations('en', { objects: { kpi_entry_sheet: { label: 'Entry Sheet' } } }); + // `t()` does not consult `en` for a zh-CN key on this adapter, and the + // accessor says the same — the document translators are handed exactly + // this locale as their chain. + expect(i18n.t('objects.kpi_entry_sheet.label', 'zh-CN')).toBe('objects.kpi_entry_sheet.label'); + expect(i18n.getFallbackLocale()).toBe('zh-CN'); + + const english = new FileI18nAdapter({ defaultLocale: 'zh-CN', fallbackLocale: 'en' }); + english.loadTranslations('en', { objects: { kpi_entry_sheet: { label: 'Entry Sheet' } } }); + expect(english.t('objects.kpi_entry_sheet.label', 'zh-CN')).toBe('Entry Sheet'); + expect(english.getFallbackLocale()).toBe('en'); + }); + }); + it('should return empty translations for unknown locale', () => { const i18n = new FileI18nAdapter(); expect(i18n.getTranslations('fr')).toEqual({}); diff --git a/packages/services/service-i18n/src/file-i18n-adapter.ts b/packages/services/service-i18n/src/file-i18n-adapter.ts index 30ca3acbdd..e70b2cb1bc 100644 --- a/packages/services/service-i18n/src/file-i18n-adapter.ts +++ b/packages/services/service-i18n/src/file-i18n-adapter.ts @@ -234,6 +234,24 @@ export class FileI18nAdapter implements II18nService { this.defaultLocale = locale; } + /** + * The locale `t()` consults after the requested one — the `fallbackLocale` + * this adapter was constructed with, which `I18nServicePlugin` receives + * from the stack's `i18n` config (`fallbackLocale || defaultLocale || 'en'`, + * collapsed by the `os serve` / dev-plugin boot). + * + * [#14882] Exposed so the REST metadata reads pass the SAME locale to the + * document translators' `fallbackChain` that `t()` falls back to; without + * it the translators defaulted to `['en']` and a `zh-CN` workspace's + * courtesy `en` bundle outranked its authored Chinese labels. + * `undefined` when the adapter was constructed without one. + * + * @see II18nService.getFallbackLocale + */ + getFallbackLocale(): string | undefined { + return this.fallbackLocale; + } + /** * Load all JSON translation files from a directory. * Each file should be named `{locale}.json`. diff --git a/packages/services/service-i18n/src/i18n-service-plugin.test.ts b/packages/services/service-i18n/src/i18n-service-plugin.test.ts index d6d2c08c0a..ba6d1f72b0 100644 --- a/packages/services/service-i18n/src/i18n-service-plugin.test.ts +++ b/packages/services/service-i18n/src/i18n-service-plugin.test.ts @@ -107,6 +107,17 @@ describe('I18nServicePlugin', () => { const registeredService = ctx.registerService.mock.calls[0][1]; expect(registeredService.getDefaultLocale()).toBe('zh-CN'); }); + + it('threads fallbackLocale through to the registered service (#14882)', async () => { + // What `os serve` / the dev plugin pass for a zh-CN workspace + // (`fallbackLocale || defaultLocale || 'en'`); the REST metadata reads + // read it back through `getFallbackLocale()`. + const plugin = new I18nServicePlugin({ defaultLocale: 'zh-CN', fallbackLocale: 'zh-CN' }); + await plugin.init!(ctx as any); + + const registeredService = ctx.registerService.mock.calls[0][1]; + expect(registeredService.getFallbackLocale()).toBe('zh-CN'); + }); }); // -- Route self-registration ---------------------------------------------- diff --git a/packages/spec/src/contracts/i18n-service.ts b/packages/spec/src/contracts/i18n-service.ts index 457ef68e98..7ff1bf45b4 100644 --- a/packages/spec/src/contracts/i18n-service.ts +++ b/packages/spec/src/contracts/i18n-service.ts @@ -57,6 +57,35 @@ export interface II18nService { */ setDefaultLocale?(locale: string): void; + /** + * The locale `t()` consults after the requested one — the deployment's + * DECLARED fallback (`i18n.fallbackLocale`, else `defaultLocale`, the + * collapse both boot paths perform before constructing the service: + * `os serve` and the dev plugin hand `I18nServicePlugin` + * `fallbackLocale || defaultLocale || 'en'`). + * + * [#14882] Declared so the serving layer can thread it into the + * metadata-document translators (`@objectstack/spec/system`'s + * `ResolveOptions.fallbackChain`). Those resolvers walk + * `requested locale → fallback chain → authored label`, and without this + * accessor no caller could tell them what the deployment declared, so the + * chain fell to the resolver's literal `['en']`: a `zh-CN` workspace that + * shipped a courtesy `en` bundle served ENGLISH bundle text to a `zh-CN` + * request, ahead of its own authored Chinese labels, because `en` was + * consulted before the authored label was ever reached. + * + * Contract for the value: it is the locale this service's own `t()` falls + * back to, so a label resolved from a bundle by the document translators + * and a message resolved by `t()` agree on which locale is consulted + * second. `undefined` means NOTHING was declared — a provider that has no + * fallback of its own omits the method or answers `undefined`, and the + * serving layer then leaves the resolver's own default in place rather + * than inventing a chain. + * + * @returns BCP-47 locale code, or `undefined` when no fallback is declared + */ + getFallbackLocale?(): string | undefined; + /** * Narrow what `getLocales()` reports to the locales the APP declared * (`i18n.supportedLocales` on the stack artifact). diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index a3094d8d2d..fa31dfc843 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -3381,3 +3381,118 @@ describe('TranslationDataSchema — datasets (#14253)', () => { .toThrow(/its author-facing text is `label`/); }); }); + +// --------------------------------------------------------------------------- +// #14882 — the fallback chain is the DECLARED one, not a literal `en` +// --------------------------------------------------------------------------- + +describe('#14882 — a declared fallback chain, at the resolver', () => { + /** + * The card's workspace, at the resolver: labels authored in the default + * locale (`zh-CN`), a courtesy `en` bundle for English users, NO `zh-CN` + * bundle entry. `subject_type` has no `en` entry — the control the reporter + * measured, the one field that stayed Chinese while its siblings flipped. + * + * The resolver itself always honoured a chain it was handed; what this + * block pins is the SHAPE the serving layer now hands it (`fallbackChain: + * [declared fallbackLocale]`) and what that shape answers. The seam that + * builds it is pinned in `@objectstack/rest`. + */ + const SHEET: any = { + name: 'kpi_entry_sheet', + label: '填报单', + pluralLabel: '填报单', + fields: { + name: { name: 'name', type: 'text', label: '填报单名称' }, + status: { name: 'status', type: 'select', label: '状态' }, + total_score: { name: 'total_score', type: 'number', label: '最终得分' }, + subject_type: { name: 'subject_type', type: 'select', label: '主体类型' }, + }, + }; + const KPI_APP: any = { name: 'kpi_app', label: 'KPI 考核管理', navigation: [] }; + /** Exactly what `buildTranslationBundle` produces for the card: an EMPTY + * `zh-CN` entry (the locale is declared, nothing is loaded for it) beside + * the courtesy `en` bundle. */ + const EN_ONLY: TranslationBundle = { + 'zh-CN': {}, + en: { + objects: { + kpi_entry_sheet: { + label: 'Entry Sheet', + pluralLabel: 'Entry Sheets', + fields: { name: { label: 'Sheet' }, status: { label: 'Status' }, total_score: { label: 'Final Score' } }, + }, + }, + apps: { kpi_app: { label: 'KPI Assessment' } }, + }, + }; + /** The options the serving layer builds for `i18n.fallbackLocale: 'zh-CN'`. */ + const ZH_WORKSPACE = { locale: 'zh-CN', fallbackChain: ['zh-CN'] }; + + const labelsOf = (doc: any) => ({ + label: doc.label, + pluralLabel: doc.pluralLabel, + name: doc.fields.name.label, + status: doc.fields.status.label, + total_score: doc.fields.total_score.label, + subject_type: doc.fields.subject_type.label, + }); + const AUTHORED = { + label: '填报单', pluralLabel: '填报单', + name: '填报单名称', status: '状态', total_score: '最终得分', subject_type: '主体类型', + }; + const ENGLISH = { + label: 'Entry Sheet', pluralLabel: 'Entry Sheets', + name: 'Sheet', status: 'Status', total_score: 'Final Score', subject_type: '主体类型', + }; + + it('a zh-CN request on a zh-CN workspace resolves to the authored labels, en bundle present', () => { + expect(labelsOf(translateMetadataDocument('object', SHEET, EN_ONLY, ZH_WORKSPACE))).toEqual(AUTHORED); + expect(translateMetadataDocument('app', KPI_APP, EN_ONLY, ZH_WORKSPACE).label).toBe('KPI 考核管理'); + }); + + it('control — remove the en bundle and the answer does not move', () => { + expect(labelsOf(translateMetadataDocument('object', SHEET, { 'zh-CN': {} }, ZH_WORKSPACE))).toEqual(AUTHORED); + expect(labelsOf(translateMetadataDocument('object', SHEET, undefined, ZH_WORKSPACE))).toEqual(AUTHORED); + }); + + it('an en request on the same workspace still gets the en bundle', () => { + const en = { locale: 'en', fallbackChain: ['zh-CN'] }; + expect(labelsOf(translateMetadataDocument('object', SHEET, EN_ONLY, en))).toEqual(ENGLISH); + expect(translateMetadataDocument('app', KPI_APP, EN_ONLY, en).label).toBe('KPI Assessment'); + }); + + it('a zh-CN bundle, when the workspace ships one, still wins over the authored label', () => { + // The reporter's documented per-locale layout (`os i18n extract + // --locales=zh-CN`) keeps working: an entry for the requested locale IS + // the translation; the authored label is only the floor under the chain. + const withZh: TranslationBundle = { + ...EN_ONLY, + 'zh-CN': { objects: { kpi_entry_sheet: { label: '填报单(bundle)' } } }, + }; + expect(translateMetadataDocument('object', SHEET, withZh, ZH_WORKSPACE).label).toBe('填报单(bundle)'); + }); + + it('a chain that DECLARES en still consults en before the authored label', () => { + // A stack declaring `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'` + // and no zh-CN bundle. Pinned as it answers today — `en` bundle text — + // because whether the authored label is the default-locale source (and + // so should outrank a declared fallback's bundle) is a CONTRACT question + // this card does not decide. #14882 changes only which chain the serving + // layer hands in; a declared `en` is honoured exactly as it reads. + expect(translateMetadataDocument('object', SHEET, EN_ONLY, { locale: 'zh-CN', fallbackChain: ['en'] }).label) + .toBe('Entry Sheet'); + }); + + it("a caller that declares NO chain keeps the resolver's literal en default", () => { + // The pre-#14882 shape every zh-CN request walked, kept green on purpose: + // the resolver's default is unchanged by this card (its only production + // caller now declares a chain), so a caller passing nothing still gets + // `['en']`. Whether that default should become "no fallback" is singled + // out for contract review, not decided here. + expect(translateMetadataDocument('object', SHEET, EN_ONLY, { locale: 'zh-CN' }).label).toBe('Entry Sheet'); + // An explicit empty chain is "requested locale, then the authored label". + expect(translateMetadataDocument('object', SHEET, EN_ONLY, { locale: 'zh-CN', fallbackChain: [] }).label) + .toBe('填报单'); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 1ad0166dd1..3af78cfd5d 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -31,8 +31,8 @@ * globalActions..label / .description / .confirmText / * .successMessage / .params..* * - * Lookup order: requested locale → each entry of `fallbackChain` (defaults to - * `['en']`) → literal `label` from the metadata. Helpers never throw — they + * Lookup order: requested locale → each entry of `fallbackChain` → literal + * `label` from the metadata. Helpers never throw — they * always return at minimum the metadata literal so unconfigured languages * gracefully degrade. * @@ -194,7 +194,20 @@ export interface ResolveOptions { locale?: string; /** * Ordered fallback locales to consult after `locale` and before returning - * the literal label. Defaults to `['en']`. + * the literal label. + * + * [#14882] This is the deployment's DECLARED chain, supplied by the caller + * that can see the declaration: the serving layer reads + * `II18nService.getFallbackLocale()` (`i18n.fallbackLocale`, else + * `defaultLocale` — the collapse both boot paths perform) and passes it + * here, so a `zh-CN` workspace resolves `zh-CN → zh-CN → authored label` + * and a courtesy `en` bundle is consulted only when `en` is requested. + * Every entry is consulted BEFORE the authored label, so an entry the + * deployment did not declare is a locale that can outrank the author. + * + * Defaults to `['en']` only when the caller declares nothing at all + * (no `fallbackChain` key); an explicit `[]` means "requested locale, then + * the authored label". */ fallbackChain?: string[]; }