Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .changeset/i18n-translation-item-shape-3778.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
---
"@objectstack/spec": minor
"@objectstack/core": minor
"@objectstack/service-i18n": minor
---

fix(i18n)!: the `translation` metadata type speaks the same `objects.` shape everything else does (#3778)

A translation authored in the product saved successfully and then rendered
nothing. Not a resolver gap — a contract split. The `translation` metadata type
(`allowRuntimeCreate: true`, so Studio/the metadata API/an agent can author it)
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 the
two, so the save path and the read path never met.

**Why converge instead of bridge.** A converter was the obvious fix and the
wrong one: it would be throwaway code, and it 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 (platform-objects,
CRM and todo examples) 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** — no deprecation cycle. Nothing worked end-to-end
through them, so there is no functioning consumer to protect, and a
deprecated-but-present schema is exactly the exemplar an AI agent copies into
new code. The optional `II18nService.getAppBundle` / `loadAppBundle` methods go
with them: zero implementers, so they advertised a capability the runtime never
delivered.

**The replacement.** `TranslationItemSchema` — one locale of the same
`TranslationData` groups a file bundle uses, plus the `locale` it translates,
with a `defineTranslation()` factory. An item is one entry of a
`TranslationBundle`; that is the whole type.

Three details are deliberate, all aimed at the failure being silent rather than
loud:

- **`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 — or
whatever — authored it. (The name fallback still covers rows written before
this.)
- **Retired keys are rejected, not stripped.** Zod drops undeclared keys
silently, which would reproduce this bug exactly: save succeeds, nothing
renders. A pre-parse guard turns that silence into a 422 naming the group to
use (`'o' … — use 'objects.<object_name>'`). It runs ahead of the parse so the
retired keys stay out of the schema itself — the generated JSON Schema and the
Studio editor never advertise a shape that cannot work.
- **`ObjectTranslationData.label` is now optional.** Partial translation is the
normal state and every resolver already treats each key as independent.
Requiring it forced authors to restate the source label just to validate,
filling bundles with fake translations that mask real coverage gaps.

Also in this change: the authored-translation sync warns (naming the row and the
fix) when it meets a row still in the retired shape instead of loading it into
nowhere, and no longer merges publish bookkeeping (`_lockReason`,
`_packageVersion`, …) into the translation layer. `GET
/i18n/labels/:object/:locale`'s fallback now reads the nested
`objects.<obj>.fields.<field>.label` 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 `{}`.

Migration: author every translation — file or runtime item — under `objects.`.
`o` → `objects`, `app` → `apps`, `nav` → `apps.<app>.navigation.<id>.label`,
`dashboard` → `dashboards`, `_globalOptions` →
`objects.<obj>.fields.<field>.options`, `_meta.locale` → top-level `locale`,
`_actions.confirmMessage` → `_actions.confirmText`. `reports`, `notifications`,
`errors`, and `namespace` had no runtime consumer and have no replacement.
70 changes: 54 additions & 16 deletions content/docs/protocol/kernel/i18n-standard.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,41 +154,44 @@ Fallback to: en (system default) ✓

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

### Object-First Convention (Recommended)
### Object-First Convention

ObjectStack uses an **object-first** convention where all translatable metadata
for an object is aggregated under `o.{object_name}`. Global (non-object-bound)
translations remain in dedicated top-level groups. This aligns with Salesforce DX
and Dynamics conventions, enabling efficient translation workbench editing
and automated coverage detection.
for an object is aggregated under `objects.{object_name}`. Global
(non-object-bound) translations remain in dedicated top-level groups. This
aligns with Salesforce DX and Dynamics conventions, enabling efficient
translation workbench editing and automated coverage detection.

There is exactly **one** shape. A file-authored bundle is a map of locale code →
`TranslationData`; a `translation` metadata item authored at runtime is one
`TranslationData` plus the `locale` it translates. The resolvers, `os i18n
extract`, `os i18n check`, and the Studio editor all read the same keys.

```typescript
// AppTranslationBundle for a single locale (e.g. zh-CN)
const zh: AppTranslationBundle = {
// One locale of a TranslationBundle (e.g. zh-CN)
const zh: TranslationData = {
// ── Object-first translations ─────────────────────────────────
o: {
objects: {
account: {
label: '客户',
pluralLabel: '客户',
description: '客户管理对象',
fields: {
name: { label: '客户名称', help: '公司法定名称' },
industry: { label: '行业', options: { tech: '科技', finance: '金融' } },
status: { options: { active: '活跃', inactive: '停用' } },
},
_options: { status: { active: '活跃', inactive: '停用' } },
_views: { all_accounts: { label: '全部客户' } },
_sections: { basic_info: { label: '基本信息' } },
_actions: { convert: { label: '转换', confirmMessage: '确认转换?' } },
_actions: { convert: { label: '转换', confirmText: '确认转换?' } },
},
},

// ── Global translations ───────────────────────────────────────
_globalOptions: { currency: { usd: '美元', eur: '欧元' } },
app: { crm: { label: '客户关系管理' } },
nav: { home: '首页', settings: '设置' },
dashboard: { sales_overview: { label: '销售概览' } },
reports: { pipeline_report: { label: '管道报表' } },
apps: { crm: { label: '客户关系管理', navigation: { home: { label: '首页' } } } },
dashboards: { sales_overview: { label: '销售概览' } },
pages: { landing: { title: '欢迎' } },
globalActions: { export_csv: { label: '导出 CSV' } },
messages: { 'common.save': '保存' },
validationMessages: { 'discount_limit': '折扣不能超过40%' },
};
Expand All@@ -198,7 +201,42 @@ const zh: AppTranslationBundle = {
- ✅ All translatable content for one object in one place
- ✅ CLI can generate translation skeletons per object
- ✅ Workbench can show per-object coverage and diffs
- ✅ No redundant category/fieldOptions/reports nodes
- ✅ One shape for files and runtime authoring — what you author is what renders

<Callout type="info">
**Retired: the `o.{object}` dialect.** A second, object-first shape keyed on
`o.` (with `app`, `nav`, `dashboard`, `_globalOptions`, `_meta`) was once
documented here for runtime-authored translations. No resolver ever read it,
so translations authored in that shape saved successfully and rendered
nothing. It was removed in #3778; the metadata door now rejects those keys
with a message naming the group to use instead. Author everything —
files and runtime items alike — under `objects.`.
</Callout>

### Authoring a `translation` item at runtime

An admin (or an agent using the metadata API) authors one item per locale.
The only difference from a file bundle is the top-level `locale`:

```typescript
import { defineTranslation } from '@objectstack/spec/system';

export default defineTranslation({
locale: 'zh-CN',
objects: {
account: {
label: '客户',
fields: { name: { label: '客户名称' } },
},
},
messages: { 'common.save': '保存' },
});
```

`locale` is required — the runtime sync skips an item whose locale it cannot
resolve, and a silent skip is exactly what makes a missing translation hard to
diagnose. Items are merged over the static file bundles, so an authored value
wins over a shipped one for the same key.

### Directory Structure

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -724,7 +724,7 @@ const result = AiInsightsRequest.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **locale** | `string` | ✅ | Locale code |
| **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 |
| **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 |


---
Expand Down
79 changes: 27 additions & 52 deletions content/docs/references/system/translation.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,8 +16,8 @@ Translation data for a single field.
## TypeScript Usage

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

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


---

## AppTranslationBundle

Object-first application translation bundle for a single locale

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **_meta** | `{ locale?: string; direction?: Enum<'ltr' \| 'rtl'> }` | optional | Bundle-level metadata (locale, bidi direction) |
| **namespace** | `string` | optional | Namespace for plugin isolation to avoid translation key collisions |
| **o** | `Record<string, { label: string; pluralLabel?: string; description?: string; helpText?: string; … }>` | optional | Object-first translations keyed by object name |
| **_globalOptions** | `Record<string, Record<string, string>>` | optional | Global picklist option translations keyed by option set name |
| **app** | `Record<string, { label: string; description?: string }>` | optional | App translations keyed by app name |
| **nav** | `Record<string, string>` | optional | Navigation item translations keyed by nav item name |
| **dashboard** | `Record<string, { label?: string; description?: string }>` | optional | Dashboard translations keyed by dashboard name |
| **reports** | `Record<string, { label?: string; description?: string }>` | optional | Report translations keyed by report name |
| **pages** | `Record<string, { title?: string; description?: string }>` | optional | Page translations keyed by page name |
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID (supports ICU MessageFormat) |
| **validationMessages** | `Record<string, string>` | optional | Validation error message translations keyed by rule name (supports ICU MessageFormat) |
| **notifications** | `Record<string, { title?: string; body?: string }>` | optional | Global notification translations keyed by notification name |
| **errors** | `Record<string, string>` | optional | Global error message translations keyed by error code |


---

## CoverageBreakdownEntry
Expand DownExpand Up@@ -109,7 +84,7 @@ Translation data for a single object

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


---

## ObjectTranslationNode

Object-first aggregated translation node

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **label** | `string` | ✅ | Translated singular label |
| **pluralLabel** | `string` | optional | Translated plural label |
| **description** | `string` | optional | Translated object description |
| **helpText** | `string` | optional | Translated help text for the object |
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field translations keyed by field name |
| **_options** | `Record<string, Record<string, string>>` | optional | Object-scoped picklist option translations keyed by field name |
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object }>` | optional | View translations keyed by view name |
| **_sections** | `Record<string, { label?: string }>` | optional | Section translations keyed by section name |
| **_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 |
| **_notifications** | `Record<string, { title?: string; body?: string }>` | optional | Notification translations keyed by notification name |
| **_errors** | `Record<string, string>` | optional | Error message translations keyed by error code |


---


Expand DownExpand Up@@ -191,7 +143,7 @@ Translation data for objects, apps, and UI messages

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **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 |
| **apps** | `Record<string, { label: string; description?: string; navigation?: Record<string, { label: string }> }>` | optional | App translations keyed by app name |
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
| **validationMessages** | `Record<string, string>` | optional | Translatable validation error messages keyed by rule name (e.g., `{"discount_limit": "折扣不能超过40%"}`) |
Expand DownExpand Up@@ -237,3 +189,26 @@ Translation diff status: missing from bundle, redundant (no matching metadata),

---

## TranslationItem

One locale of translations — the `translation` metadata type

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **apps** | `Record<string, { label: string; description?: string; navigation?: Record<string, { label: string }> }>` | optional | App translations keyed by app name |
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
| **validationMessages** | `Record<string, string>` | optional | Translatable validation error messages keyed by rule name (e.g., `{"discount_limit": "折扣不能超过40%"}`) |
| **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 |
| **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 |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string }>` | optional | Page translations keyed by page name |
| **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 |
| **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 |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
| **locale** | `string` | ✅ | BCP-47 locale this item translates (e.g. "zh-CN") |


---

2 changes: 1 addition & 1 deletion content/docs/releases/v15.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -532,7 +532,7 @@ search guide with `sys_user`/picker coverage and existing-row backfill notes
### i18n

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