Skip to content

Commit 4cca74c

Browse files
os-zhuangclaude
andauthored
fix(i18n): one translation shape — the translation type speaks objects., not o. (#3778) (#3808)
A translation authored in the product saved successfully and rendered nothing. Not a resolver gap — a contract split. The `translation` metadata type (`allowRuntimeCreate: true`) was registered against `AppTranslationBundleSchema`, an object-first shape keyed on `o.<object>`. Every resolver, `os i18n extract`, `os i18n check`, the objectui hooks, and all nine shipped bundles read `objects.<object>`. Nothing bridged them, so the save path and the read path never met. A converter was the obvious fix and the wrong one: throwaway code that would start producing *working* `o.`-shaped rows, closing the migration-free window that exists precisely because the feature never functioned. The retired shape's real-world footprint was zero — all three `*.translation.ts` files in the tree were already `objects.`-shaped, contradicting the type's own registered schema. Converging is a registration fix, not a migration. BREAKING: `AppTranslationBundleSchema`, `ObjectTranslationNodeSchema` and their types are deleted with no deprecation cycle — nothing worked end-to-end through them, so there is no consumer to protect, and a deprecated-but-present schema is exactly the exemplar an agent copies into new code. `II18nService.getAppBundle` / `loadAppBundle` go with them (zero implementers — a capability the runtime never delivered). `TranslationItemSchema` replaces them: one locale of the same `TranslationData` groups a file bundle uses, plus the `locale` it translates, with a `defineTranslation()` factory. Three details are deliberate, all aimed at making the failure loud instead of silent: - `locale` is required, not inferred from the item name. The sync skips an item whose locale it cannot resolve, and a skip is invisible to whoever authored it. - Retired keys are rejected, not stripped. Zod drops undeclared keys silently, which would reproduce this bug exactly. A pre-parse guard turns that silence into a 422 naming the right group, and runs ahead of the parse so the keys stay out of the schema — the generated JSON Schema and the Studio editor never advertise a shape that cannot work. - `ObjectTranslationData.label` becomes optional. Partial translation is the normal state; requiring it forced authors to restate the source label just to validate, filling bundles with fake translations that mask coverage gaps. Also here: the authored sync warns (naming the row and the fix) on a row still in the retired shape instead of loading it into nowhere, and no longer merges publish bookkeeping into the translation layer. `GET /i18n/labels/:object/:locale` reads the nested field data it is actually given — it scanned for flat dotted `o.<obj>.fields.<field>` keys, a third dialect no producer ever wrote, so it always returned `{}`. A `translation` create seed makes the Studio create flow round-trip. Docs, the i18n skill, and the sweep-test fixture no longer teach the retired shape. Claude-Session: https://claude.ai/code/session_017wPvotUdM6WFtJm1KKryLE Co-authored-by: Claude <noreply@anthropic.com>
1 parent f92096b commit 4cca74c

21 files changed

Lines changed: 646 additions & 659 deletions
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/core": minor
4+
"@objectstack/service-i18n": minor
5+
---
6+
7+
fix(i18n)!: the `translation` metadata type speaks the same `objects.` shape everything else does (#3778)
8+
9+
A translation authored in the product saved successfully and then rendered
10+
nothing. Not a resolver gap — a contract split. The `translation` metadata type
11+
(`allowRuntimeCreate: true`, so Studio/the metadata API/an agent can author it)
12+
was registered against `AppTranslationBundleSchema`, an object-first shape keyed
13+
on `o.<object>`. Every resolver, `os i18n extract`, `os i18n check`, the objectui
14+
hooks, and all nine shipped bundles read `objects.<object>`. Nothing bridged the
15+
two, so the save path and the read path never met.
16+
17+
**Why converge instead of bridge.** A converter was the obvious fix and the
18+
wrong one: it would be throwaway code, and it would start producing *working*
19+
`o.`-shaped rows — closing the migration-free window that exists precisely
20+
because the feature never functioned. The retired shape's real-world footprint
21+
was zero: all three `*.translation.ts` files in the tree (platform-objects,
22+
CRM and todo examples) were already `objects.`-shaped, contradicting the type's
23+
own registered schema. Converging is a registration fix, not a migration.
24+
25+
**Breaking.** `AppTranslationBundleSchema`, `ObjectTranslationNodeSchema`, and
26+
their types are **deleted** — no deprecation cycle. Nothing worked end-to-end
27+
through them, so there is no functioning consumer to protect, and a
28+
deprecated-but-present schema is exactly the exemplar an AI agent copies into
29+
new code. The optional `II18nService.getAppBundle` / `loadAppBundle` methods go
30+
with them: zero implementers, so they advertised a capability the runtime never
31+
delivered.
32+
33+
**The replacement.** `TranslationItemSchema` — one locale of the same
34+
`TranslationData` groups a file bundle uses, plus the `locale` it translates,
35+
with a `defineTranslation()` factory. An item is one entry of a
36+
`TranslationBundle`; that is the whole type.
37+
38+
Three details are deliberate, all aimed at the failure being silent rather than
39+
loud:
40+
41+
- **`locale` is required**, not inferred from the item name. The sync skips an
42+
item whose locale it cannot resolve, and a skip is invisible to whoever — or
43+
whatever — authored it. (The name fallback still covers rows written before
44+
this.)
45+
- **Retired keys are rejected, not stripped.** Zod drops undeclared keys
46+
silently, which would reproduce this bug exactly: save succeeds, nothing
47+
renders. A pre-parse guard turns that silence into a 422 naming the group to
48+
use (`'o' … — use 'objects.<object_name>'`). It runs ahead of the parse so the
49+
retired keys stay out of the schema itself — the generated JSON Schema and the
50+
Studio editor never advertise a shape that cannot work.
51+
- **`ObjectTranslationData.label` is now optional.** Partial translation is the
52+
normal state and every resolver already treats each key as independent.
53+
Requiring it forced authors to restate the source label just to validate,
54+
filling bundles with fake translations that mask real coverage gaps.
55+
56+
Also in this change: the authored-translation sync warns (naming the row and the
57+
fix) when it meets a row still in the retired shape instead of loading it into
58+
nowhere, and no longer merges publish bookkeeping (`_lockReason`,
59+
`_packageVersion`, …) into the translation layer. `GET
60+
/i18n/labels/:object/:locale`'s fallback now reads the nested
61+
`objects.<obj>.fields.<field>.label` data it is actually given — it scanned for
62+
flat dotted `o.<obj>.fields.<field>` keys, a third dialect no producer ever
63+
wrote, so it always returned `{}`.
64+
65+
Migration: author every translation — file or runtime item — under `objects.`.
66+
`o``objects`, `app``apps`, `nav``apps.<app>.navigation.<id>.label`,
67+
`dashboard``dashboards`, `_globalOptions`
68+
`objects.<obj>.fields.<field>.options`, `_meta.locale` → top-level `locale`,
69+
`_actions.confirmMessage``_actions.confirmText`. `reports`, `notifications`,
70+
`errors`, and `namespace` had no runtime consumer and have no replacement.

content/docs/protocol/kernel/i18n-standard.mdx

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -154,41 +154,44 @@ Fallback to: en (system default) ✓
154154

155155
Translations are stored in **JSON files** organized by locale and namespace.
156156

157-
### Object-First Convention (Recommended)
157+
### Object-First Convention
158158

159159
ObjectStack uses an **object-first** convention where all translatable metadata
160-
for an object is aggregated under `o.{object_name}`. Global (non-object-bound)
161-
translations remain in dedicated top-level groups. This aligns with Salesforce DX
162-
and Dynamics conventions, enabling efficient translation workbench editing
163-
and automated coverage detection.
160+
for an object is aggregated under `objects.{object_name}`. Global
161+
(non-object-bound) translations remain in dedicated top-level groups. This
162+
aligns with Salesforce DX and Dynamics conventions, enabling efficient
163+
translation workbench editing and automated coverage detection.
164+
165+
There is exactly **one** shape. A file-authored bundle is a map of locale code →
166+
`TranslationData`; a `translation` metadata item authored at runtime is one
167+
`TranslationData` plus the `locale` it translates. The resolvers, `os i18n
168+
extract`, `os i18n check`, and the Studio editor all read the same keys.
164169

165170
```typescript
166-
// AppTranslationBundle for a single locale (e.g. zh-CN)
167-
const zh: AppTranslationBundle = {
171+
// One locale of a TranslationBundle (e.g. zh-CN)
172+
const zh: TranslationData = {
168173
// ── Object-first translations ─────────────────────────────────
169-
o: {
174+
objects: {
170175
account: {
171176
label: '客户',
172177
pluralLabel: '客户',
173178
description: '客户管理对象',
174179
fields: {
175180
name: { label: '客户名称', help: '公司法定名称' },
176181
industry: { label: '行业', options: { tech: '科技', finance: '金融' } },
182+
status: { options: { active: '活跃', inactive: '停用' } },
177183
},
178-
_options: { status: { active: '活跃', inactive: '停用' } },
179184
_views: { all_accounts: { label: '全部客户' } },
180185
_sections: { basic_info: { label: '基本信息' } },
181-
_actions: { convert: { label: '转换', confirmMessage: '确认转换?' } },
186+
_actions: { convert: { label: '转换', confirmText: '确认转换?' } },
182187
},
183188
},
184189

185190
// ── Global translations ───────────────────────────────────────
186-
_globalOptions: { currency: { usd: '美元', eur: '欧元' } },
187-
app: { crm: { label: '客户关系管理' } },
188-
nav: { home: '首页', settings: '设置' },
189-
dashboard: { sales_overview: { label: '销售概览' } },
190-
reports: { pipeline_report: { label: '管道报表' } },
191+
apps: { crm: { label: '客户关系管理', navigation: { home: { label: '首页' } } } },
192+
dashboards: { sales_overview: { label: '销售概览' } },
191193
pages: { landing: { title: '欢迎' } },
194+
globalActions: { export_csv: { label: '导出 CSV' } },
192195
messages: { 'common.save': '保存' },
193196
validationMessages: { 'discount_limit': '折扣不能超过40%' },
194197
};
@@ -198,7 +201,42 @@ const zh: AppTranslationBundle = {
198201
- ✅ All translatable content for one object in one place
199202
- ✅ CLI can generate translation skeletons per object
200203
- ✅ Workbench can show per-object coverage and diffs
201-
- ✅ No redundant category/fieldOptions/reports nodes
204+
- ✅ One shape for files and runtime authoring — what you author is what renders
205+
206+
<Callout type="info">
207+
**Retired: the `o.{object}` dialect.** A second, object-first shape keyed on
208+
`o.` (with `app`, `nav`, `dashboard`, `_globalOptions`, `_meta`) was once
209+
documented here for runtime-authored translations. No resolver ever read it,
210+
so translations authored in that shape saved successfully and rendered
211+
nothing. It was removed in #3778; the metadata door now rejects those keys
212+
with a message naming the group to use instead. Author everything —
213+
files and runtime items alike — under `objects.`.
214+
</Callout>
215+
216+
### Authoring a `translation` item at runtime
217+
218+
An admin (or an agent using the metadata API) authors one item per locale.
219+
The only difference from a file bundle is the top-level `locale`:
220+
221+
```typescript
222+
import { defineTranslation } from '@objectstack/spec/system';
223+
224+
export default defineTranslation({
225+
locale: 'zh-CN',
226+
objects: {
227+
account: {
228+
label: '客户',
229+
fields: { name: { label: '客户名称' } },
230+
},
231+
},
232+
messages: { 'common.save': '保存' },
233+
});
234+
```
235+
236+
`locale` is required — the runtime sync skips an item whose locale it cannot
237+
resolve, and a silent skip is exactly what makes a missing translation hard to
238+
diagnose. Items are merged over the static file bundles, so an authored value
239+
wins over a shipped one for the same key.
202240

203241
### Directory Structure
204242

content/docs/references/api/protocol.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -724,7 +724,7 @@ const result = AiInsightsRequest.parse(data);
724724
| Property | Type | Required | Description |
725725
| :--- | :--- | :--- | :--- |
726726
| **locale** | `string` || Locale code |
727-
| **translations** | `{ objects?: Record<string, { label: string; pluralLabel?: string; description?: string; fields?: Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>; … }>; apps?: Record<string, { label: string; description?: string; navigation?: Record<string, { label: string }> }>; messages?: Record<string, string>; validationMessages?: Record<string, string>; … }` || Translation data |
727+
| **translations** | `{ objects?: Record<string, { label?: string; pluralLabel?: string; description?: string; fields?: Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>; … }>; apps?: Record<string, { label: string; description?: string; navigation?: Record<string, { label: string }> }>; messages?: Record<string, string>; validationMessages?: Record<string, string>; … }` || Translation data |
728728

729729

730730
---

content/docs/references/system/translation.mdx

Lines changed: 27 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ Translation data for a single field.
1616
## TypeScript Usage
1717

1818
```typescript
19-
import { ActionResultDialogTranslation, AppTranslationBundle, CoverageBreakdownEntry, FieldTranslation, Locale, ObjectTranslationData, ObjectTranslationNode, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus } from '@objectstack/spec/system';
20-
import type { ActionResultDialogTranslation, AppTranslationBundle, CoverageBreakdownEntry, FieldTranslation, Locale, ObjectTranslationData, ObjectTranslationNode, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus } from '@objectstack/spec/system';
19+
import { ActionResultDialogTranslation, CoverageBreakdownEntry, FieldTranslation, Locale, ObjectTranslationData, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus, TranslationItem } from '@objectstack/spec/system';
20+
import type { ActionResultDialogTranslation, CoverageBreakdownEntry, FieldTranslation, Locale, ObjectTranslationData, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus, TranslationItem } from '@objectstack/spec/system';
2121

2222
// Validate data
2323
const result = ActionResultDialogTranslation.parse(data);
@@ -39,31 +39,6 @@ Translations for an action result dialog
3939
| **fields** | `Record<string, string>` | optional | Result field labels keyed by the literal field path declared in the action metadata (keys may contain dots) |
4040

4141

42-
---
43-
44-
## AppTranslationBundle
45-
46-
Object-first application translation bundle for a single locale
47-
48-
### Properties
49-
50-
| Property | Type | Required | Description |
51-
| :--- | :--- | :--- | :--- |
52-
| **_meta** | `{ locale?: string; direction?: Enum<'ltr' \| 'rtl'> }` | optional | Bundle-level metadata (locale, bidi direction) |
53-
| **namespace** | `string` | optional | Namespace for plugin isolation to avoid translation key collisions |
54-
| **o** | `Record<string, { label: string; pluralLabel?: string; description?: string; helpText?: string; … }>` | optional | Object-first translations keyed by object name |
55-
| **_globalOptions** | `Record<string, Record<string, string>>` | optional | Global picklist option translations keyed by option set name |
56-
| **app** | `Record<string, { label: string; description?: string }>` | optional | App translations keyed by app name |
57-
| **nav** | `Record<string, string>` | optional | Navigation item translations keyed by nav item name |
58-
| **dashboard** | `Record<string, { label?: string; description?: string }>` | optional | Dashboard translations keyed by dashboard name |
59-
| **reports** | `Record<string, { label?: string; description?: string }>` | optional | Report translations keyed by report name |
60-
| **pages** | `Record<string, { title?: string; description?: string }>` | optional | Page translations keyed by page name |
61-
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID (supports ICU MessageFormat) |
62-
| **validationMessages** | `Record<string, string>` | optional | Validation error message translations keyed by rule name (supports ICU MessageFormat) |
63-
| **notifications** | `Record<string, { title?: string; body?: string }>` | optional | Global notification translations keyed by notification name |
64-
| **errors** | `Record<string, string>` | optional | Global error message translations keyed by error code |
65-
66-
6742
---
6843

6944
## CoverageBreakdownEntry
@@ -109,7 +84,7 @@ Translation data for a single object
10984

11085
| Property | Type | Required | Description |
11186
| :--- | :--- | :--- | :--- |
112-
| **label** | `string` | | Translated singular label |
87+
| **label** | `string` | optional | Translated singular label |
11388
| **pluralLabel** | `string` | optional | Translated plural label |
11489
| **description** | `string` | optional | Translated object description |
11590
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
@@ -118,29 +93,6 @@ Translation data for a single object
11893
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
11994

12095

121-
---
122-
123-
## ObjectTranslationNode
124-
125-
Object-first aggregated translation node
126-
127-
### Properties
128-
129-
| Property | Type | Required | Description |
130-
| :--- | :--- | :--- | :--- |
131-
| **label** | `string` || Translated singular label |
132-
| **pluralLabel** | `string` | optional | Translated plural label |
133-
| **description** | `string` | optional | Translated object description |
134-
| **helpText** | `string` | optional | Translated help text for the object |
135-
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field translations keyed by field name |
136-
| **_options** | `Record<string, Record<string, string>>` | optional | Object-scoped picklist option translations keyed by field name |
137-
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object }>` | optional | View translations keyed by view name |
138-
| **_sections** | `Record<string, { label?: string }>` | optional | Section translations keyed by section name |
139-
| **_actions** | `Record<string, { label?: string; confirmMessage?: string; params?: Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>; resultDialog?: object }>` | optional | Action translations keyed by action name |
140-
| **_notifications** | `Record<string, { title?: string; body?: string }>` | optional | Notification translations keyed by notification name |
141-
| **_errors** | `Record<string, string>` | optional | Error message translations keyed by error code |
142-
143-
14496
---
14597

14698

@@ -191,7 +143,7 @@ Translation data for objects, apps, and UI messages
191143

192144
| Property | Type | Required | Description |
193145
| :--- | :--- | :--- | :--- |
194-
| **objects** | `Record<string, { label: string; pluralLabel?: string; description?: string; fields?: Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>; … }>` | optional | Object translations keyed by object name |
146+
| **objects** | `Record<string, { label?: string; pluralLabel?: string; description?: string; fields?: Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>; … }>` | optional | Object translations keyed by object name |
195147
| **apps** | `Record<string, { label: string; description?: string; navigation?: Record<string, { label: string }> }>` | optional | App translations keyed by app name |
196148
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
197149
| **validationMessages** | `Record<string, string>` | optional | Translatable validation error messages keyed by rule name (e.g., `{"discount_limit": "折扣不能超过40%"}`) |
@@ -237,3 +189,26 @@ Translation diff status: missing from bundle, redundant (no matching metadata),
237189

238190
---
239191

192+
## TranslationItem
193+
194+
One locale of translations — the `translation` metadata type
195+
196+
### Properties
197+
198+
| Property | Type | Required | Description |
199+
| :--- | :--- | :--- | :--- |
200+
| **objects** | `Record<string, { label?: string; pluralLabel?: string; description?: string; fields?: Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>; … }>` | optional | Object translations keyed by object name |
201+
| **apps** | `Record<string, { label: string; description?: string; navigation?: Record<string, { label: string }> }>` | optional | App translations keyed by app name |
202+
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
203+
| **validationMessages** | `Record<string, string>` | optional | Translatable validation error messages keyed by rule name (e.g., `{"discount_limit": "折扣不能超过40%"}`) |
204+
| **globalActions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string; params?: Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>; … }>` | optional | Global action translations keyed by action name |
205+
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, { label?: string }>; widgets?: Record<string, { title?: string; description?: string }> }>` | optional | Dashboard translations keyed by dashboard name |
206+
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string }>` | optional | Page translations keyed by page name |
207+
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, { title?: string; description?: string }>; keys?: Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>; … }>` | optional | Settings manifest translations keyed by namespace |
208+
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, { label?: string; description?: string }>; fields?: Record<string, { label?: string; helpText?: string; placeholder?: string }> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
209+
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
210+
| **locale** | `string` || BCP-47 locale this item translates (e.g. "zh-CN") |
211+
212+
213+
---
214+

content/docs/releases/v15.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -532,7 +532,7 @@ search guide with `sys_user`/picker coverage and existing-row backfill notes
532532
### i18n
533533

534534
- **`os i18n extract` emits action-param keys**
535-
(`o.<object>._actions.<action>.params.<param>.*`), so action dialog forms —
535+
(`objects.<object>._actions.<action>.params.<param>.*`), so action dialog forms —
536536
e.g. Setup → Create User — are translatable; platform-objects bundles
537537
regenerated for en/zh-CN/ja-JP/es-ES (#3030, #3033).
538538
- **Audit activity summaries localize to the workspace locale** (ADR-0053 verb

0 commit comments

Comments
 (0)