diff --git a/.changeset/25785-served-translations-without-metadata-forms.md b/.changeset/25785-served-translations-without-metadata-forms.md new file mode 100644 index 00000000000..5d57b133d65 --- /dev/null +++ b/.changeset/25785-served-translations-without-metadata-forms.md @@ -0,0 +1,21 @@ +--- +'@objectstack/spec': minor +'@objectstack/runtime': minor +--- + +**BREAKING** fix(i18n)!: the translation bundle a console downloads leaves out Studio's `metadataForms` + +Clause-②: yes (narrowing) +Metadata-protocol: narrowed + +`GET /i18n/translations/:locale` served the i18n service's whole locale tree, so every console open downloaded `metadataForms`, the translations of Studio's metadata-type authoring forms: 75 KB of the 221 KB raw zh-CN bundle on `examples/app-crm`. No browser reads that group from the bundle. Studio's forms already arrive translated, because `GET /meta` (and `/meta/types`) resolves `metadataForms.` on the server, from the same i18n service, before it serves each form. + +The route now serves every group except the ones the server resolves itself. The spec names them in `SERVER_RESOLVED_TRANSLATION_GROUPS`, which today holds only `metadataForms`. `GetTranslationsResponseSchema.translations` is now `ServedTranslationDataSchema`: the platform face without those groups, and closed, so a body that still carries one fails it. The runtime copies the tree and never edits the service's own one, which is the tree `/meta` translates from. The `ETag` still hashes the bytes served, so a warm reopen still gets `304`. + +**Why this is breaking, and why `minor`.** A response body that carries `metadataForms` was accepted by the published `GetTranslationsResponseSchema` and now fails it, the exported `GetTranslationsResponse` type loses that member, and the route stops serving a group it served. The published protocol records the move: the authorable surface gains the served face's keys (`ServedTranslationData`) and `GetTranslationsResponse:requestedLocale`, and the response face a client parses against is narrower, so the kind is `narrowed` and both packages that carry it — the spec that declares the face and the runtime that serves it — ship `minor`. What an author writes into a translation bundle does not move. + +**What to do.** Nothing, unless you read `metadataForms` from this route. Read it from `GET /meta/types` instead, which serves each form already translated into the requested locale, rather than from the bundle this route returns. Authoring is unchanged: `metadataForms` stays a group on every bundle face (`TranslationData`, `PlatformTranslationData`), in `i18n:extract` and in the i18n service. + +Also on this route: `requestedLocale`, which the door has always served when it fell back to another locale (`zh` served as `zh-CN`), is now declared on `GetTranslationsResponseSchema`, optional. It was served undeclared, so no generated client could read it. + + diff --git a/content/docs/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index ed03d92ead8..7979b59f9d2 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -470,9 +470,15 @@ through the one the renderer reads for that node: Both run in `GET /api/v1/meta`, which serves every type's `form` and `schema` already localised for the request's locale; a client deriving the schema itself -with `z.toJSONSchema` applies the second one to its own copy. Only `label` -crosses over to the schema (as `title`); `helpText` and `placeholder` stay on -the form. +with `z.toJSONSchema` applies the second one to its own copy, over a bundle it +carries itself. Only `label` crosses over to the schema (as `title`); `helpText` +and `placeholder` stay on the form. + +Because the server resolves this group, the bundle a console downloads when it +opens — `GET /api/v1/i18n/translations/:locale` — leaves `metadataForms` out: +`SERVER_RESOLVED_TRANSLATION_GROUPS` names it, and the response's `translations` +is `ServedTranslationData`, the platform groups without it. Author it like any +other group; it stays in the i18n service, which is where `/meta` reads it. **Naming an item-level property** therefore has three parts, none of which is a locale-catalog entry alone: the English name is a `.meta({ title: 'Action URL' })` diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index db943b5a160..b96dcc536e4 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1764,6 +1764,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **locale** | `string` | ✅ | Locale code | +| **requestedLocale** | `string` | optional | The locale the caller asked for, when the served locale is a fallback from it | | **translations** | `{ objects?: Record; picklists?: Record; apps?: Record; messages?: Record; … }` | ✅ | Translation data | ### Nested Shape: `GetTranslationsResponse.translations` @@ -1782,7 +1783,6 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | | **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | -| **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index d9de5d64b11..c4be7fb7e5e 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1548 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1549 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -32,9 +32,9 @@ counts are sums of the rows they head. Regenerate with | [Security Protocol](/docs/references/security) | 5 | 34 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | -| [System Protocol](/docs/references/system) | 37 | 286 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | +| [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 171 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **200** | **1548** | 14 protocol modules | +| **Total** | **200** | **1549** | 14 protocol modules | --- @@ -319,7 +319,7 @@ Studio designer metadata — the authoring surfaces for the protocols above. ## System Protocol -**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **37 pages, 286 schemas** +**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **37 pages, 287 schemas** The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. @@ -360,7 +360,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a | [`supplier-security.zod.ts`](/docs/references/system/supplier-security) | `SupplierAssessmentStatus`, `SupplierRiskLevel`, `SupplierSecurityAssessment`, `SupplierSecurityPolicy`, `SupplierSecurityRequirement` | | [`tenant.zod.ts`](/docs/references/system/tenant) | `DatabaseLevelIsolationStrategy`, `DatabaseProvider`, `QuotaEnforcementResult`, `RowLevelIsolationStrategy`, `SchemaLevelIsolationStrategy`, `Tenant`, `TenantConnectionConfig`, `TenantIsolationConfig`, `TenantIsolationLevel`, `TenantQuota`, `TenantSecurityPolicy`, `TenantUsage` | | [`tracing.zod.ts`](/docs/references/system/tracing) | `OpenTelemetryCompatibility`, `OtelExporterType`, `SamplingDecision`, `SamplingStrategyType`, `Span`, `SpanAttributeValue`, `SpanAttributes`, `SpanEvent`, `SpanKind`, `SpanLink`, `SpanStatus`, `TraceContext`, `TraceContextPropagation`, `TraceFlags`, `TracePropagationFormat`, `TraceSamplingConfig`, `TraceState`, `TracingConfig` | -| [`translation.zod.ts`](/docs/references/system/translation) | `ActionResultDialogTranslation`, `CoverageBreakdownEntry`, `FieldTranslation`, `Locale`, `ObjectTranslationData`, `PlatformTranslationBundle`, `PlatformTranslationData`, `TranslationBundle`, `TranslationConfig`, `TranslationCoverageResult`, `TranslationData`, `TranslationDiffItem`, `TranslationDiffStatus`, `TranslationItem` | +| [`translation.zod.ts`](/docs/references/system/translation) | `ActionResultDialogTranslation`, `CoverageBreakdownEntry`, `FieldTranslation`, `Locale`, `ObjectTranslationData`, `PlatformTranslationBundle`, `PlatformTranslationData`, `ServedTranslationData`, `TranslationBundle`, `TranslationConfig`, `TranslationCoverageResult`, `TranslationData`, `TranslationDiffItem`, `TranslationDiffStatus`, `TranslationItem` | | [`worker.zod.ts`](/docs/references/system/worker) | `BatchProgress`, `QueueConfig`, `Task`, `TaskExecutionResult`, `TaskPriority`, `TaskRetryPolicy`, `TaskStatus`, `WorkerStats` | --- diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 644d112e4c7..d16708d3bf6 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -13,8 +13,8 @@ description: "Translation schemas of the ObjectStack System Protocol — each pr ## TypeScript Usage ```typescript -import { ActionResultDialogTranslationSchema, CoverageBreakdownEntrySchema, FieldTranslationSchema, LocaleSchema, ObjectTranslationDataSchema, PlatformTranslationBundleSchema, PlatformTranslationDataSchema, TranslationBundleSchema, TranslationConfigSchema, TranslationCoverageResultSchema, TranslationDataSchema, TranslationDiffItemSchema, TranslationDiffStatusSchema, TranslationItemSchema } from '@objectstack/spec/system'; -import type { ActionResultDialogTranslation, CoverageBreakdownEntry, FieldTranslation, Locale, ObjectTranslationData, PlatformTranslationBundle, PlatformTranslationData, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus, TranslationItem } from '@objectstack/spec/system'; +import { ActionResultDialogTranslationSchema, CoverageBreakdownEntrySchema, FieldTranslationSchema, LocaleSchema, ObjectTranslationDataSchema, PlatformTranslationBundleSchema, PlatformTranslationDataSchema, ServedTranslationDataSchema, TranslationBundleSchema, TranslationConfigSchema, TranslationCoverageResultSchema, TranslationDataSchema, TranslationDiffItemSchema, TranslationDiffStatusSchema, TranslationItemSchema } from '@objectstack/spec/system'; +import type { ActionResultDialogTranslation, CoverageBreakdownEntry, FieldTranslation, Locale, ObjectTranslationData, PlatformTranslationBundle, PlatformTranslationData, ServedTranslationData, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus, TranslationItem } from '@objectstack/spec/system'; // Validate data const result = ActionResultDialogTranslationSchema.parse(data); @@ -317,6 +317,153 @@ Translation data for a single object | **banners** | `Record` | optional | Info banner translations keyed by the info_banner specifier id | +--- + +## ServedTranslationData + +Served translation data — the platform groups the browser reads, without the server-resolved `metadataForms` + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **objects** | `Record; … }>` | optional | Object translations keyed by object name | +| **picklists** | `Record }>` | optional | Picklist translations keyed by picklist name | +| **apps** | `Record }>` | optional | App translations keyed by app name | +| **messages** | `Record` | optional | UI message translations keyed by message ID | +| **globalActions** | `Record` | optional | Global action translations keyed by action name | +| **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | +| **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | +| **reports** | `Record` | optional | Report translations keyed by report name | +| **pages** | `Record` | optional | Page translations keyed by page name | +| **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | +| **positions** | `Record` | optional | Position translations keyed by position name | +| **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | +| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | +| **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | + +### Nested Shape: `ServedTranslationData.objects[string]` + +Translation data for a single object + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated singular label | +| **pluralLabel** | `string` | optional | Translated plural label | +| **description** | `string` | optional | Translated object description | +| **fields** | `Record }>` | optional | Field-level translations | +| **_views** | `Record` | optional | View translations keyed by view name | +| **_actions** | `Record` | optional | Action translations keyed by action name | +| **_sections** | `Record }>` | optional | Section translations keyed by section name | +| **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | +| **_validations** | `Record` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) | + +### Nested Shape: `ServedTranslationData.picklists[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated picklist label | +| **options** | `Record` | ✅ | Option value to translated label map — inherited by every field that references the picklist | + +### Nested Shape: `ServedTranslationData.apps[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | ✅ | Translated app label | +| **description** | `string` | optional | Translated app description | +| **navigation** | `Record` | optional | Navigation group translations keyed by group ID | + +### Nested Shape: `ServedTranslationData.globalActions[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated action label | +| **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | +| **confirmText** | `string` | optional | Translated confirmation prompt | +| **successMessage** | `string` | optional | Translated success toast/message | +| **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | +| **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | +| **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | + +### Nested Shape: `ServedTranslationData.dashboards[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated dashboard title | +| **description** | `string` | optional | Translated dashboard description | +| **actions** | `Record` | optional | Header action label translations keyed by action url/key | +| **widgets** | `Record` | optional | Widget translations keyed by widget id | +| **globalFilters** | `Record }>` | optional | Global-filter translations keyed by the filter `name` (a filter that authors no `name` is keyed by its `field`) | + +### Nested Shape: `ServedTranslationData.datasets[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated dataset label | +| **description** | `string` | optional | Translated dataset description | +| **dimensions** | `Record` | optional | Dimension translations keyed by dimension name (`DatasetDimensionSchema.name`) | +| **measures** | `Record` | optional | Measure translations keyed by measure name (`DatasetMeasureSchema.name`) | + +### Nested Shape: `ServedTranslationData.reports[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated report label — overlays the report's own `label` | +| **description** | `string` | optional | Translated report description — overlays the report's own `description` | + +### Nested Shape: `ServedTranslationData.pages[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated page label (nav / breadcrumb) | +| **description** | `string` | optional | Translated page description | +| **title** | `string` | optional | Translated `page:header` title. When omitted the header keeps its authored title; `label` stands in only where the header authors no title or its title restates the page label | +| **subtitle** | `string` | optional | Translated `page:header` subtitle | +| **components** | `Record` | optional | Per-component copy keyed by component id (`PageComponentSchema.id`) | + +### Nested Shape: `ServedTranslationData.flows[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated flow label | +| **successMessage** | `string` | optional | Translated completion toast — overlays the flow's own `successMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | +| **errorMessage** | `string` | optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | +| **screens** | `Record }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) | +| **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | +| **approvals** | `Record` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node | + +### Nested Shape: `ServedTranslationData.positions[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated position label — overlays the position's own `label` | +| **description** | `string` | optional | Translated position description — overlays the position's own `description` | + +### Nested Shape: `ServedTranslationData.permissions[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated permission-set label — overlays the set's own `label` | +| **description** | `string` | optional | Translated permission-set description — overlays the set's own `description` | + +### Nested Shape: `ServedTranslationData.settingsCommon` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **sourceLabels** | `{ env?: string; global?: string; tenant?: string; user?: string; … }` | optional | Source badge labels by resolution layer | + +### Nested Shape: `ServedTranslationData.settings[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **title** | `string` | optional | Translated settings manifest title | +| **description** | `string` | optional | Translated settings manifest description | +| **groups** | `Record` | optional | Group translations keyed by group key | +| **keys** | `Record }>` | optional | Per-setting field translations keyed by setting key | +| **actions** | `Record` | optional | Action button translations keyed by action id | +| **banners** | `Record` | optional | Info banner translations keyed by the info_banner specifier id | + + --- ## TranslationBundle diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md index eeed4d62d38..9c535ae34bf 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `system/` | 382 | +| `system/` | 383 | diff --git a/packages/runtime/src/domains/i18n-translations-served-groups.test.ts b/packages/runtime/src/domains/i18n-translations-served-groups.test.ts new file mode 100644 index 00000000000..55f453f8f88 --- /dev/null +++ b/packages/runtime/src/domains/i18n-translations-served-groups.test.ts @@ -0,0 +1,241 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25785 — the translation bundle a console open downloads leaves out + * Studio's `metadataForms`, and Studio's forms stay translated. + * + * ## The defect + * + * `GET /i18n/translations/:locale` served the i18n service's whole locale tree, + * `metadataForms` included: 75 KB of the 221 KB raw a console open downloaded + * on `examples/app-crm` zh-CN, the second-largest group after `objects`. No + * browser reads it there. Studio's authoring forms arrive already translated: + * the `/meta` (`/meta/types`) handler resolves `metadataForms.` on the + * server, over the same service, before it serves each form. + * + * ## What is pinned + * + * - A: both exits (the requested locale and the `zh` → `zh-CN` fallback) and + * both spellings serve every group but the server-resolved ones, unchanged, + * and the body is the closed `GetTranslationsResponseSchema`. + * - B: the service's own tree is never edited, and `/meta`, read over the same + * service AFTER a boot read, still serves Studio's form translated. That is + * the consumer an in-place delete would silently break. + * - C: the validator still hashes the bytes served, so a warm reopen is still + * `304`, and a change confined to a group that is not served does not move it. + */ + +import { createHash } from 'node:crypto'; +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from '@objectstack/rest'; +import { GetTranslationsResponseSchema } from '@objectstack/spec/api'; +import { SERVER_RESOLVED_TRANSLATION_GROUPS } from '@objectstack/spec/system'; + +import { handleI18nRequest } from './i18n.js'; +import { apiErrorResponse } from '../error-envelope.js'; +import type { DomainHandlerDeps } from '../domain-handler-registry.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; + +const MEMBER_EC = { userId: 'usr_member', isSystem: false, positions: [], permissions: [], systemPermissions: [] }; + +function member(headers: Record = {}): HttpProtocolContext { + return { request: { headers }, executionContext: { ...MEMBER_EC } } as unknown as HttpProtocolContext; +} + +type Bundles = Record>; + +/** Hands back the STORED tree, as the core fallback and service-i18n both do. */ +function makeService(bundles: Bundles) { + return { + getLocales: vi.fn(() => Object.keys(bundles)), + getDefaultLocale: vi.fn(() => 'en'), + getFallbackLocale: vi.fn(() => 'en'), + getTranslations: vi.fn((locale: string) => bundles[locale] ?? {}), + }; +} + +function makeDeps(service: ReturnType): DomainHandlerDeps { + return { + getService: vi.fn(async () => service), + resolveService: vi.fn(async () => service), + success: (data: any, meta?: any) => ({ status: 200, body: { success: true, data, meta } }), + error: (message: string, httpStatus = 500, details?: any) => + apiErrorResponse({ message, httpStatus, details }), + } as unknown as DomainHandlerDeps; +} + +async function read( + service: ReturnType, + subPath = '/translations/zh-CN', + query: Record = {}, + context: HttpProtocolContext = member(), +): Promise { + return handleI18nRequest(makeDeps(service), subPath, 'GET', query, context); +} + +function validatorOf(body: unknown): string { + return `W/"${createHash('sha256').update(JSON.stringify(body)).digest('base64url')}"`; +} + +/** The groups a console reads, as the platform bundle carries them. */ +const SERVED_GROUPS = { + objects: { lead: { label: '潜在客户', fields: { company: { label: '公司' } } } }, + apps: { crm: { label: '客户关系管理' } }, + settings: { mail: { title: '邮件投递' } }, + messages: { save: '保存' }, +}; + +/** Studio's dashboard authoring form, translated the way platform-objects ships it. */ +const METADATA_FORMS = { + dashboard: { + label: '仪表板', + sections: { layout: { label: '布局' } }, + fields: { columns: { label: '列数', helpText: '栅格列数' } }, + }, +}; + +function zhTree(): Record { + return { ...SERVED_GROUPS, metadataForms: METADATA_FORMS }; +} + +describe('#25785 A — the served document leaves the server-resolved groups out', () => { + it('names metadataForms as a server-resolved group (the constant this door reads)', () => { + expect(SERVER_RESOLVED_TRANSLATION_GROUPS).toContain('metadataForms'); + }); + + it.each([ + ['the path spelling', '/translations/zh-CN', {}], + ['the query spelling', '/translations', { locale: 'zh-CN' }], + ])('%s: every other group is served unchanged, metadataForms is not', async (_label, subPath, query) => { + const result = await read(makeService({ en: {}, 'zh-CN': zhTree() }), subPath, query); + expect(result.response.status).toBe(200); + expect(result.response.body.data).toEqual({ locale: 'zh-CN', translations: SERVED_GROUPS }); + expect(result.response.body.data.translations).not.toHaveProperty('metadataForms'); + }); + + it('the locale fallback exit (zh → zh-CN) leaves it out too, and its body is declared', async () => { + const result = await read(makeService({ en: {}, 'zh-CN': zhTree() }), '/translations/zh'); + expect(result.response.status).toBe(200); + expect(result.response.body.data).toEqual({ locale: 'zh-CN', requestedLocale: 'zh', translations: SERVED_GROUPS }); + // `requestedLocale` is declared on the response face (#25785), so the + // fallback body is read by the schema rather than tolerated by it. + expect(GetTranslationsResponseSchema.safeParse(result.response.body.data).success).toBe(true); + expect(Object.keys(GetTranslationsResponseSchema.shape)).toContain('requestedLocale'); + }); + + it('the body satisfies the closed GetTranslationsResponseSchema, which refuses the unserved group', async () => { + const result = await read(makeService({ en: {}, 'zh-CN': zhTree() })); + expect(GetTranslationsResponseSchema.safeParse(result.response.body.data).success).toBe(true); + + // Control: the same document WITH the group is refused by the schema, so + // the parse above passes because the door left it out, not because the + // schema reads nothing. + const unserved = GetTranslationsResponseSchema.safeParse({ locale: 'zh-CN', translations: zhTree() }); + expect(unserved.success).toBe(false); + expect(unserved.error?.issues.map((i) => i.code)).toContain('unrecognized_keys'); + }); + + it('a tree with nothing to leave out is served as it is', async () => { + const result = await read(makeService({ en: {}, 'zh-CN': { ...SERVED_GROUPS } })); + expect(result.response.body.data.translations).toEqual(SERVED_GROUPS); + }); +}); + +// ── B: the service's tree, and the consumer that reads it ─────────────────── + +function mockRes() { + return { json: vi.fn(), status: vi.fn().mockReturnThis(), header: vi.fn(), send: vi.fn() }; +} + +/** A RestServer whose `/meta` handler translates from `i18n` — the route Studio reads its forms from. */ +function restOver(i18n: ReturnType): RestServer { + const server = { + 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), + }; + const protocol = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), + getMetaTypes: vi.fn(async () => ({ + entries: [{ + type: 'dashboard', + label: 'Dashboard', + form: { + schemaId: 'dashboard', + type: 'simple', + sections: [{ name: 'layout', label: 'Layout', fields: [{ field: 'columns', type: 'number', label: 'Columns' }] }], + }, + }], + types: ['dashboard'], + registered: ['dashboard'], + })), + getMetaItems: vi.fn(async () => []), + getMetaItem: vi.fn(async () => undefined), + getMetaItemCached: undefined as any, + findData: vi.fn().mockResolvedValue([]), + }; + const rest = new RestServer( + server as any, protocol as any, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, + async () => i18n, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] }); + rest.registerRoutes(); + return rest; +} + +async function readStudioForm(rest: RestServer, locale: string): Promise { + const route = (rest as any).getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta'); + if (!route) throw new Error('route not registered: GET /api/v1/meta'); + const res = mockRes(); + await route.handler({ method: 'GET', params: {}, query: {}, body: {}, headers: { 'accept-language': locale } }, res); + const body = res.json.mock.calls.at(-1)?.[0]; + return body.entries.find((e: any) => e.type === 'dashboard'); +} + +describe("#25785 B — the service keeps the group, and Studio's forms stay translated", () => { + it("a boot read does not edit the service's tree", async () => { + const stored = zhTree(); + const service = makeService({ en: {}, 'zh-CN': stored }); + await read(service); + expect(stored.metadataForms).toBe(METADATA_FORMS); + expect(service.getTranslations('zh-CN')).toHaveProperty('metadataForms', METADATA_FORMS); + }); + + it('/meta, read over the same service after a boot read, serves the form translated', async () => { + const service = makeService({ en: {}, 'zh-CN': zhTree() }); + const boot = await read(service); + expect(boot.response.body.data.translations).not.toHaveProperty('metadataForms'); + + const entry = await readStudioForm(restOver(service), 'zh-CN'); + expect(entry.label).toBe('仪表板'); + expect(entry.form.sections[0].label).toBe('布局'); + expect(entry.form.sections[0].fields[0].label).toBe('列数'); + }); +}); + +// ── C: the validator, over the bytes now served ───────────────────────────── + +describe('#25785 C — a warm reopen is still answered 304', () => { + it('the validator hashes the served body, and naming it answers 304', async () => { + const service = makeService({ en: {}, 'zh-CN': zhTree() }); + const first = await read(service); + expect(first.response.headers.ETag).toBe(validatorOf(first.response.body)); + + const again = await read(service, '/translations/zh-CN', {}, member({ 'if-none-match': first.response.headers.ETag })); + expect(again.response.status).toBe(304); + expect(again.response.body).toBeUndefined(); + }); + + it('a change confined to an unserved group serves the same bytes, so the validator holds', async () => { + const bundles: Bundles = { en: {}, 'zh-CN': zhTree() }; + const service = makeService(bundles); + const before = (await read(service)).response.headers.ETag; + + bundles['zh-CN'] = { ...SERVED_GROUPS, metadataForms: { dashboard: { label: '看板' } } }; + expect((await read(service)).response.headers.ETag).toBe(before); + + bundles['zh-CN'] = { ...SERVED_GROUPS, messages: { save: '存储' }, metadataForms: METADATA_FORMS }; + expect((await read(service)).response.headers.ETag).not.toBe(before); + }); +}); diff --git a/packages/runtime/src/domains/i18n.ts b/packages/runtime/src/domains/i18n.ts index bb41d094d8c..3154a44df51 100644 --- a/packages/runtime/src/domains/i18n.ts +++ b/packages/runtime/src/domains/i18n.ts @@ -26,7 +26,8 @@ * The translation bundle is the one face a browser revalidates: it carries an * `ETag` and `Cache-Control: private, no-cache`, and a matching * `If-None-Match` is answered `304` with no body (#25785, see - * {@link translationsAnswer}). + * {@link translationsAnswer}). It is served without the groups the server + * resolves itself, Studio's `metadataForms` (see {@link servedTranslations}). */ import { createHash } from 'node:crypto'; @@ -34,7 +35,9 @@ import { resolveLocale, shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, } from '@objectstack/core'; -import { CoreServiceName, resolveObjectFieldLabels, toLocaleDescriptors } from '@objectstack/spec/system'; +import { + CoreServiceName, SERVER_RESOLVED_TRANSLATION_GROUPS, resolveObjectFieldLabels, toLocaleDescriptors, +} from '@objectstack/spec/system'; import { isServiceServeable } from '../service-serveable.js'; import type { TranslationData } from '@objectstack/spec/system'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; @@ -108,6 +111,28 @@ function ifNoneMatchHits(header: string | undefined, etag: string): boolean { }); } +/** + * [#25785] The document a translations read serves: the locale tree the i18n + * service holds, without `SERVER_RESOLVED_TRANSLATION_GROUPS`, the groups the + * server resolves before it serves what they translate. `metadataForms` is the + * one today: `/meta/types` answers Studio's authoring forms already translated + * from it, and no browser reads it here. `GetTranslationsResponseSchema` + * declares the served face without it. + * + * ⛔ A COPY, never an edit of `translations`. `getTranslations` hands back the + * service's own tree (the core fallback and service-i18n both return the stored + * object), and that tree is the one `/meta/types` translates from: deleting the + * group in place would leave every later Studio form in English. + */ +function servedTranslations(translations: Record): Record { + const resolvedOnServer: readonly string[] = SERVER_RESOLVED_TRANSLATION_GROUPS; + const served: Record = {}; + for (const [group, value] of Object.entries(translations)) { + if (!resolvedOnServer.includes(group)) served[group] = value; + } + return served; +} + /** * [#25785] Answer a translations read with its validator. Every console open * read the whole locale bundle again (80–88 KB compressed on a hosted tenant) @@ -239,11 +264,15 @@ export async function handleI18nRequest( const resolved = resolveLocale(locale, availableLocales); if (resolved && resolved !== locale) { translations = i18nService.getTranslations(resolved); - return translationsAnswer(deps, { locale: resolved, requestedLocale: locale, translations }, context); + return translationsAnswer( + deps, + { locale: resolved, requestedLocale: locale, translations: servedTranslations(translations) }, + context, + ); } } - return translationsAnswer(deps, { locale, translations }, context); + return translationsAnswer(deps, { locale, translations: servedTranslations(translations) }, context); } // GET /i18n/labels/:object/:locale OR /i18n/labels/:object?locale=xx diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index a4ab8b99755..9297c68fbf7 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -581,6 +581,7 @@ "RowLevelIsolationStrategy (type)", "RowLevelIsolationStrategyParsed (type)", "RowLevelIsolationStrategySchema (const)", + "SERVER_RESOLVED_TRANSLATION_GROUPS (const)", "SETTINGS_CHANGE_EVENT (const)", "SamplingDecision (const)", "SamplingDecision (type)", @@ -609,6 +610,8 @@ "SecurityEventCorrelation (type)", "SecurityEventCorrelationParsed (type)", "SecurityEventCorrelationSchema (const)", + "ServedTranslationData (type)", + "ServedTranslationDataSchema (const)", "ServerRateLimitConfig (type)", "ServerRateLimitConfigParsed (type)", "ServerRateLimitConfigSchema (const)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index b518780b24e..cafe22ee4db 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -832,6 +832,7 @@ "api/GetRunResponse:success", "api/GetTranslationsRequest:locale", "api/GetTranslationsResponse:locale", + "api/GetTranslationsResponse:requestedLocale", "api/GetTranslationsResponse:translations", "api/GetUiViewRequest:object", "api/GetUiViewRequest:type", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 7841b2ae9d0..9e001fd0299 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -1029,6 +1029,20 @@ "system/SecurityEventCorrelation:linkAuthToAudit", "system/SecurityEventCorrelation:linkEncryptionToAudit", "system/SecurityEventCorrelation:linkMaskingToAudit", + "system/ServedTranslationData:apps", + "system/ServedTranslationData:dashboards", + "system/ServedTranslationData:datasets", + "system/ServedTranslationData:flows", + "system/ServedTranslationData:globalActions", + "system/ServedTranslationData:messages", + "system/ServedTranslationData:objects", + "system/ServedTranslationData:pages", + "system/ServedTranslationData:permissions", + "system/ServedTranslationData:picklists", + "system/ServedTranslationData:positions", + "system/ServedTranslationData:reports", + "system/ServedTranslationData:settings", + "system/ServedTranslationData:settingsCommon", "system/ServerRateLimitConfig:enabled", "system/ServerRateLimitConfig:maxRequests", "system/ServerRateLimitConfig:windowMs", diff --git a/packages/spec/declaration-map/system.json b/packages/spec/declaration-map/system.json index 827500b073e..b68745bb36f 100644 --- a/packages/spec/declaration-map/system.json +++ b/packages/spec/declaration-map/system.json @@ -394,6 +394,8 @@ "SecurityContextConfigSchema": "system/SecurityContextConfig", "SecurityEventCorrelation": "system/SecurityEventCorrelation", "SecurityEventCorrelationSchema": "system/SecurityEventCorrelation", + "ServedTranslationData": "system/ServedTranslationData", + "ServedTranslationDataSchema": "system/ServedTranslationData", "ServerRateLimitConfig": "system/ServerRateLimitConfig", "ServerRateLimitConfigSchema": "system/ServerRateLimitConfig", "ServiceConfig": "system/ServiceConfig", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 39f5070c6b8..0edd01989c8 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 247, - "droppedRefinementSites": 866, + "publishedSchemasWithDroppedRefinements": 248, + "droppedRefinementSites": 869, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -1387,6 +1387,13 @@ "options[0].timezone" ] }, + "system/ServedTranslationData": { + "sites": [ + "flows.valueType.refusals.valueType.message", + "flows.valueType.screens.valueType.description", + "flows.valueType.screens.valueType.title" + ] + }, "system/ServerRateLimitConfig": { "sites": [ "" diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index f7ed892e55c..935a29155b9 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -557,6 +557,7 @@ "RowLevelIsolationStrategy": "src/system/tenant.zod.ts#RowLevelIsolationStrategy (type)", "RowLevelIsolationStrategyParsed": "src/system/tenant.zod.ts#RowLevelIsolationStrategyParsed (type)", "RowLevelIsolationStrategySchema": "src/system/tenant.zod.ts#RowLevelIsolationStrategySchema (const)", + "SERVER_RESOLVED_TRANSLATION_GROUPS": "src/system/translation.zod.ts#SERVER_RESOLVED_TRANSLATION_GROUPS (const)", "SETTINGS_CHANGE_EVENT": "src/system/settings-client.zod.ts#SETTINGS_CHANGE_EVENT (const)", "SamplingDecision": "src/system/tracing.zod.ts#SamplingDecision (type)", "SamplingStrategyType": "src/system/tracing.zod.ts#SamplingStrategyType (type)", @@ -583,6 +584,8 @@ "SecurityEventCorrelation": "src/system/security-context.zod.ts#SecurityEventCorrelation (type)", "SecurityEventCorrelationParsed": "src/system/security-context.zod.ts#SecurityEventCorrelationParsed (type)", "SecurityEventCorrelationSchema": "src/system/security-context.zod.ts#SecurityEventCorrelationSchema (const)", + "ServedTranslationData": "src/system/translation.zod.ts#ServedTranslationData (type)", + "ServedTranslationDataSchema": "src/system/translation.zod.ts#ServedTranslationDataSchema (const)", "ServerRateLimitConfig": "src/system/stack-server.zod.ts#ServerRateLimitConfig (type)", "ServerRateLimitConfigParsed": "src/system/stack-server.zod.ts#ServerRateLimitConfigParsed (type)", "ServerRateLimitConfigSchema": "src/system/stack-server.zod.ts#ServerRateLimitConfigSchema (const)", diff --git a/packages/spec/json-schema.manifest/system.json b/packages/spec/json-schema.manifest/system.json index b60b48513ea..1ab9724c7cd 100644 --- a/packages/spec/json-schema.manifest/system.json +++ b/packages/spec/json-schema.manifest/system.json @@ -215,6 +215,7 @@ "system/SearchProvider", "system/SecurityContextConfig", "system/SecurityEventCorrelation", + "system/ServedTranslationData", "system/ServerRateLimitConfig", "system/ServiceConfig", "system/ServiceCriticality", diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index a9e52b2a7b8..29ca838a5f8 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -28,7 +28,7 @@ import { import { RealtimePresenceSchema, TransportProtocol } from './realtime.zod'; import { ObjectPermissionSchema, EffectiveObjectPermissionSchema, FieldPermissionSchema } from '../security/permission.zod'; import { ActionDescriptorSchema } from '../automation/node-executor.zod'; -import { PlatformTranslationDataSchema } from '../system/translation.zod'; +import { ServedTranslationDataSchema } from '../system/translation.zod'; // #5950 / #5882 — the ADR-0010 read-side protection envelope both metadata-item // responses publish. Same three vocabularies the resolver filters against, so a // value this spec cannot name is a value the resolver would have dropped. @@ -3705,10 +3705,26 @@ export const GetTranslationsRequestSchema = lazySchema(() => z.object({ * `stack.translations` — so it carries the platform-only `settings` group and * is typed against the platform face, not the per-app one * (`system/translation.zod.ts`). + * + * Less the groups the server resolves itself (`SERVER_RESOLVED_TRANSLATION_GROUPS`, + * #25785): `metadataForms` stays in the i18n service, where `/meta/types` + * translates Studio's authoring forms from it, and is not part of this answer. + * `translations` is therefore {@link ServedTranslationDataSchema}, the platform + * face without those groups, closed so a body that still carries one fails it. + * + * `requestedLocale` is the code the caller ASKED for, carried only when the + * door fell back to another one: `locale` is always the locale the body + * translates, and the pair is how a caller learns that `zh` was served as + * `zh-CN`. The runtime door has served it since that fallback existed while + * this face did not declare it (#25785) — declared ≠ enforced, Prime + * Directive #10: an undeclared field is one no generated client can read and + * no conformance suite can hold. Optional, because the exit that serves the + * locale asked for omits it. */ export const GetTranslationsResponseSchema = lazySchema(() => z.object({ locale: z.string().describe('Locale code'), - translations: PlatformTranslationDataSchema.describe('Translation data'), + requestedLocale: z.string().optional().describe('The locale the caller asked for, when the served locale is a fallback from it'), + translations: ServedTranslationDataSchema.describe('Translation data'), })); export const GetFieldLabelsRequestSchema = lazySchema(() => z.object({ diff --git a/packages/spec/src/system/served-translation-data.test.ts b/packages/spec/src/system/served-translation-data.test.ts new file mode 100644 index 00000000000..5679a47fe3e --- /dev/null +++ b/packages/spec/src/system/served-translation-data.test.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25785 — the served translation document is the platform face without the + * groups the server resolves itself. + * + * `GET /i18n/translations/:locale` answers `GetTranslationsResponseSchema`, + * whose `translations` is {@link ServedTranslationDataSchema}. The runtime door + * leaves out {@link SERVER_RESOLVED_TRANSLATION_GROUPS} (`metadataForms`, which + * `/meta/types` resolves into Studio's forms on the server), and these pins keep + * the constant, the served face and the response schema one declaration: + * + * - the served face is exactly the platform face minus the constant, both ways; + * - a server-resolved group stays authorable on both bundle faces (only the + * served document leaves it out); + * - the served face is closed: a body still carrying the group is refused by + * name, which is what lets a response conformance suite catch a door that + * stops leaving it out. + */ + +import { describe, it, expect } from 'vitest'; +import { GetTranslationsResponseSchema } from '../api/protocol.zod'; +import { + PlatformTranslationDataSchema, + SERVER_RESOLVED_TRANSLATION_GROUPS, + ServedTranslationDataSchema, + TranslationDataSchema, +} from './translation.zod'; + +const keysOf = (schema: { shape: Record }) => Object.keys(schema.shape).sort(); + +describe('#25785 — the served translation face', () => { + it('is the platform face without SERVER_RESOLVED_TRANSLATION_GROUPS, in both directions', () => { + const platform = keysOf(PlatformTranslationDataSchema); + const expected = platform.filter((k) => !(SERVER_RESOLVED_TRANSLATION_GROUPS as readonly string[]).includes(k)); + // Positive control: the walk read real shapes, and the constant names a platform group. + expect(platform).toEqual(expect.arrayContaining(['objects', 'settings', 'metadataForms'])); + expect(expected.length).toBe(platform.length - SERVER_RESOLVED_TRANSLATION_GROUPS.length); + expect(keysOf(ServedTranslationDataSchema)).toEqual(expected); + }); + + it('is what GetTranslationsResponseSchema serves under `translations`', () => { + expect(keysOf(GetTranslationsResponseSchema.shape.translations)).toEqual(keysOf(ServedTranslationDataSchema)); + }); + + it.each(SERVER_RESOLVED_TRANSLATION_GROUPS)('%s stays authorable on both bundle faces', (group) => { + expect(Object.keys(TranslationDataSchema.shape)).toContain(group); + expect(Object.keys(PlatformTranslationDataSchema.shape)).toContain(group); + }); + + it('declares `requestedLocale`, optional, beside the locale it served', () => { + const shape = GetTranslationsResponseSchema.shape; + expect(Object.keys(shape).sort()).toEqual(['locale', 'requestedLocale', 'translations']); + // The fallback exit's body: `zh` asked for, `zh-CN` served (runtime `domains/i18n.ts`). + const fallback = { locale: 'zh-CN', requestedLocale: 'zh', translations: { messages: { save: '保存' } } }; + expect(GetTranslationsResponseSchema.safeParse(fallback).success).toBe(true); + // Optional: the exit that serves the locale asked for omits it. + expect(GetTranslationsResponseSchema.safeParse({ locale: 'zh-CN', translations: {} }).success).toBe(true); + // Declared, so it is typed rather than merely tolerated: a non-string is refused. + expect(GetTranslationsResponseSchema.safeParse({ ...fallback, requestedLocale: 7 }).success).toBe(false); + }); + + it('accepts a document carrying the served groups', () => { + const served = { + objects: { lead: { label: '潜在客户', fields: { company: { label: '公司' } } } }, + settings: { mail: { title: '邮件投递' } }, + messages: { save: '保存' }, + }; + expect(ServedTranslationDataSchema.safeParse(served).success).toBe(true); + expect(GetTranslationsResponseSchema.safeParse({ locale: 'zh-CN', translations: served }).success).toBe(true); + }); + + it.each(SERVER_RESOLVED_TRANSLATION_GROUPS)('refuses a document still carrying %s, by name', (group) => { + const result = ServedTranslationDataSchema.safeParse({ messages: { save: '保存' }, [group]: {} }); + expect(result.success).toBe(false); + const issue = result.error?.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue).toBeDefined(); + expect((issue as { keys?: string[] }).keys).toEqual([group]); + + const response = GetTranslationsResponseSchema.safeParse({ locale: 'zh-CN', translations: { [group]: {} } }); + expect(response.success).toBe(false); + }); +}); diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index c14a149ddfd..ed6804cf2d6 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -2009,6 +2009,58 @@ export const PlatformTranslationDataSchema = lazySchema(() => strictObject({ export type PlatformTranslationData = z.input; +/** + * The platform groups the SERVED document leaves out — + * `GET /i18n/translations/:locale` answers {@link ServedTranslationDataSchema}, + * the platform face without these (#25785). + * + * A group belongs here when the server resolves it before it serves the + * surface the group translates, so no browser reads it from the served + * document. `metadataForms` is the one member: it translates Studio's + * metadata-type authoring forms, and those reach Studio already translated — + * the `/meta/types` handler applies `resolveMetadataTypeLabel`, + * `resolveMetadataFormLabels` and `resolveMetadataFormSchemaTitles` to each + * entry over the i18n service's in-process bundle before it answers. Carried in + * the served document as well, it was the second-largest group of every console + * open (75 KB of 221 KB raw on `examples/app-crm` zh-CN) and nothing read it. + * + * ⛔ Only the served document leaves these out. Every bundle face still + * declares the group, and the i18n service still holds it: the serving door + * copies the document without them and never edits the service's own tree, + * which is the tree `/meta/types` translates from. + */ +export const SERVER_RESOLVED_TRANSLATION_GROUPS = ['metadataForms'] as const; + +/** + * One locale of the SERVED translation document — what + * `GET /i18n/translations/:locale` answers: {@link PlatformTranslationDataSchema} + * without {@link SERVER_RESOLVED_TRANSLATION_GROUPS}. + * + * Its own `strictObject` rather than `.omit()` on the platform face, for the + * reason {@link appTranslationDataShape} gives against `.extend()`: the derived + * schema would keep the error map that closed over the base's keys, so a body + * still carrying `metadataForms` would be told the key it should not carry is + * a declared one. Closed, a served body that carries a server-resolved group + * fails this schema, which is what the response conformance suites parse with. + */ +export const ServedTranslationDataSchema = lazySchema(() => { + const { metadataForms: _serverResolved, ...appGroups } = appTranslationDataShape(); + return strictObject({ + surface: 'this locale of the served translation document', + history: TRANSLATION_HISTORY, + guidance: { + metadataForms: + "the served document leaves 'metadataForms' out: Studio's authoring forms are translated " + + 'on the server, and /meta/types serves each form already in the requested locale.', + }, + }, { + ...appGroups, + ...platformSettingsShape(), + }).describe('Served translation data — the platform groups the browser reads, without the server-resolved `metadataForms`'); +}); + +export type ServedTranslationData = z.input; + // ──────────────────────────────────────────────────────────────────────────── // Translation Bundle (all locales) // ──────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index fd307dadac2..635b0205ca6 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -282,7 +282,7 @@ import type * as M192 from './data/column-apply-on.zod.js'; import type * as M193 from './data/seed-file.zod.js'; // --------------------------------------------------------------------------- -// 791 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 792 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1441,6 +1441,9 @@ export type Iso_system_translation__LocaleSchema = Assert, z.infer< typeof M152.ObjectTranslationDataSchema > >>; export type Iso_system_translation__PlatformTranslationBundleSchema = Assert, z.infer< typeof M152.PlatformTranslationBundleSchema > >>; export type Iso_system_translation__PlatformTranslationDataSchema = Assert, z.infer< typeof M152.PlatformTranslationDataSchema > >>; +// #25785: the served face is the platform face less one `.optional()` group, so its +// two states coincide as the platform face's do. +export type Iso_system_translation__ServedTranslationDataSchema = Assert, z.infer< typeof M152.ServedTranslationDataSchema > >>; export type Iso_system_translation__TranslationBundleSchema = Assert, z.infer< typeof M152.TranslationBundleSchema > >>; export type Iso_system_translation__TranslationConfigSchema = Assert, z.infer< typeof M152.TranslationConfigSchema > >>; export type Iso_system_translation__TranslationCoverageResultSchema = Assert, z.infer< typeof M152.TranslationCoverageResultSchema > >>; @@ -1716,7 +1719,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 791 isomorphic pins', () => { + it('still declares all 792 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2618,7 +2621,14 @@ describe('ADR-0122 type-alias convention', () => { // string with a pattern, so it is pinned here // (Iso_data_seedFile__SeedFilePathSchema) rather than given a `…Parsed` // synonym. +1 added. - expect(pins).toHaveLength(791); + // + // 791 -> 792 is #25785: `ServedTranslationDataSchema` + // (`system/translation.zod.ts`, module slot M152), the served face of the + // translation document — the platform face less the server-resolved + // `metadataForms` group, so it gains no default or transform and is pinned + // here (Iso_system_translation__ServedTranslationDataSchema) beside its + // platform sibling rather than given a `…Parsed` synonym. +1 added. + expect(pins).toHaveLength(792); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either diff --git a/ui/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts b/ui/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts index 5514062b059..fd9e885bbf7 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts @@ -31,8 +31,16 @@ * (a hand-rolled walker would have had to re-derive `header.showTitle` and * would have got the section names wrong — they are slugged labels, not the * `label` text); - * - when the environment's own translation bundle reaches this surface, it - * drops in as a higher-precedence source with no call-site change. + * - when the environment's own `metadataForms` translations reach this + * surface, they drop in as a higher-precedence source with no call-site + * change. Their channel is `/api/v1/meta/types`, which serves each form + * already localized from the environment's bundle: the console's own + * translation bundle (`/api/v1/i18n/translations/:locale`) does not carry + * the group at all (objectstack#25785 — it was 75 KB of 221 KB on app-crm + * zh-CN, read by nothing in the browser). So the higher-precedence source + * for a form the SERVER renders is already in place; this overlay is for + * the forms this console renders from its own copy of the spec, which no + * server answer passes through. * * ## Scope: `en` + `zh`, matching this console's carve-out * diff --git a/ui/packages/i18n/src/utils/spec-translations.ts b/ui/packages/i18n/src/utils/spec-translations.ts index be3c8c318c7..0f3638a4788 100644 --- a/ui/packages/i18n/src/utils/spec-translations.ts +++ b/ui/packages/i18n/src/utils/spec-translations.ts @@ -60,18 +60,28 @@ export interface SpecTranslationData { } /** - * The top-level groups of the translation document the server serves at - * `/api/v1/i18n/translations/:locale` — the keys of the `translations` shape - * of `@objectstack/spec`'s `GetTranslationsResponseSchema`: every per-app + * The top-level groups this transform recognises in the translation document + * the server serves at `/api/v1/i18n/translations/:locale` — every per-app * `TranslationData` group plus the platform-only `settings`, because the * served document is the merge of the app's bundle and the platform's. * + * It is a SUPERSET of the keys of the `translations` shape of + * `@objectstack/spec`'s `GetTranslationsResponseSchema`, deliberately: that + * shape is the groups the server still SERVES, and a group can leave it while + * authored bundles and older servers keep carrying it. `metadataForms` is the + * first one to have left (objectstack#25785 — Studio's authoring forms arrive + * already translated from `/api/v1/meta/types`, so the console bundle stopped + * carrying their translations); it stays listed here, so a payload from a + * server that still serves it is namespaced like any other rather than landing + * untouched at the root. Nothing reads the group on this side. + * * Written out rather than imported: `@object-ui/i18n` deliberately does not * depend on `@objectstack/spec`. The console does, so the pin lives beside * its loader — `apps/console/src/loadLanguage.test.tsx` walks the spec's own * key list and requires a payload carrying any one group, alone, to take the - * transform branch. A group the spec adds and this list lacks turns that test - * red instead of dropping the group's bundles silently (objectui#10235). + * transform branch. A group the spec SERVES and this list lacks turns that + * test red instead of dropping the group's bundles silently (objectui#10235); + * a group listed here and no longer served is not red, by that direction. */ const SPEC_TRANSLATION_GROUPS = [ 'objects',