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
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/spec': minor
'@objectstack/service-settings': minor
Expand Down Expand Up @@ -66,8 +66,9 @@

### Unchanged

The registered `translation` metadata type (`TranslationItemSchema`) still
declares `settings` — this ruling covers the file-authored bundle. `GET
The registered `translation` metadata type (`TranslationItemSchema`) is not
changed by THIS entry — this ruling covers the file-authored bundle. (Superseded
in the same release: #19620 narrows the item door too; see its own changeset.) `GET
/api/v1/i18n/translations/:locale` still declares it on its response, because the
served document is the merged tree; `GetTranslationsResponseSchema` is typed
against the platform face for exactly that reason.
Expand Down
71 changes: 71 additions & 0 deletions .changeset/19620-translation-item-settings-platform-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
'@objectstack/spec': minor
'@objectstack/core': minor
---

**BREAKING for runtime-authored `translation` items** — the registered `translation` metadata type no longer declares `settings`: platform settings copy is platform-only at BOTH application doors (#19620)

Clause-②: no

`TranslationItemSchema` — one `translation` metadata item, authored with
`defineTranslation`, in Studio, or through the metadata API — now takes the same
ten groups as a per-app bundle entry (`TranslationData`). `settings`, and its
singular `setting`, are refused by name with the platform-only prescription,
exactly as the per-app bundle has refused them since #15178. The file door and
the item door are two authoring surfaces for one app metadata type, so they
accept one shape.

### Migration — FROM → TO

| You wrote | Write instead |
| --- | --- |
| `defineTranslation({ locale: 'zh-CN', settings: { mail: { title: '邮件投递' } } })` | delete the `settings` group — there is no application-side replacement key |
| a `translation` item saved through the metadata API or Studio carrying `settings` | delete the `settings` group; the save answers `422 INVALID_METADATA` until you do |
| `const t: TranslationItem = { locale: 'en', settings: … }` | move the copy to the PLATFORM bundle (`PlatformTranslationData`), or delete it |

**The one-line fix: delete the `settings` group from the item.** Settings copy is
not application-authorable — `settings` is keyed by `SettingsManifest.namespace`
and only platform code declares a manifest. `settingsCommon` is **not** affected:
the Settings UI shell strings (the source badges, under
`settingsCommon.sourceLabels`) stay on both application faces.
Run `os migrate meta --from 17` to list the mechanical edits for existing
sources; apply them by hand.

### Rows already stored are converted, not refused

A `translation` row saved before this change keeps loading. The runtime
translation sync (`@objectstack/core`'s `authored-translation-sync`) reads
`sys_metadata` itself and used to merge the RAW stored payload; it now replays
the ADR-0087 conversion chain over each row before merging it, the same policy
as every other stored-metadata read seam. `translation-per-app-settings-removed`
has learned the item shape, so a stored row's `settings` is dropped there, the
rest of the item (`objects`, `apps`, …) still loads, and the server logs one
warning per row naming the row, the group and the conversion. Run
`os migrate meta --stored --apply` to persist the canonical rows.

### What changes on screen, which is not nothing

On the item door the group was STRONGER than on the bundle door. A published
item is loaded into the runtime-authored layer, which both i18n adapters read
**over** the shipped bundles — so an item's `settings` overrode the platform's
own Settings copy for its locale, rather than only filling gaps. After
upgrading, re-read the Settings screens in each locale such an item covered:
where it overrode a platform string, **the platform's string renders again**;
where it filled a gap the platform bundle leaves, the **manifest's own literal
renders, which is English**. If a platform string is wrong or missing for your
locale, correct it in the platform bundle (`@objectstack/service-settings`'s
`settingsBuiltinTranslations`).

No deprecation window: the item door refuses the key by name from this major.

### Unchanged

The platform face — `PlatformTranslationDataSchema`, `settingsBuiltinTranslations`,
and `GET /api/v1/i18n/translations/:locale`, whose served document is the merged
tree — still declares `settings`. The liveness ledger's `translation.settings`
row is deleted because the key left the ITEM's shape; the platform capability it
evidenced is untouched.

Ruling batch #210 item 2 letter B (2026-09-22) — maintainer 「210 同意」.

<!-- adr-0087: not-required (already-registered translation-per-app-settings-removed, translation-per-app-settings-platform-only) both entries already existed for the per-app bundle door; this change EXTENDS them to the translation item door in the same unreleased major — the D2 conversion learns the bare item shape and the D3 semantic entry covers both doors -->
4 changes: 3 additions & 1 deletion content/docs/protocol/kernel/i18n-standard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,9 @@ Three rules the shape enforces, all of them closed since #4001:
it.** It is keyed by `SettingsManifest.namespace`, and only platform code
declares a manifest — so the only namespaces an application could ever
address are the platform's own. Writing it in `stack.translations` (or in
`defineTranslationBundle`) is refused by name with that prescription; the
`defineTranslationBundle`) is refused by name with that prescription, and so
is writing it on a `translation` metadata item (`defineTranslation`, or a save
through the metadata API) — the item door takes the same per-app face; the
platform's own bundles author it against `PlatformTranslationData`, and the
served document (`GET /i18n/translations/:locale`) carries it because it is
the merge of every loaded bundle.
Expand Down
11 changes: 0 additions & 11 deletions content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,6 @@ One locale of translations — the `translation` metadata type
| **flows** | `Record<string, { label?: string; screens?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |
| **locale** | `string` | ✅ | BCP-47 locale this item translates (e.g. "zh-CN") |
| **name** | `string` | optional | Item name — conventionally the locale code (`zh-CN`); the runtime sync falls back to it when `locale` is absent |
| **label** | `string` | optional | Human-readable label shown in metadata lists |
Expand Down Expand Up @@ -604,16 +603,6 @@ Translation data for a single object
| :--- | :--- | :--- | :--- |
| **sourceLabels** | `{ env?: string; global?: string; tenant?: string; user?: string; … }` | optional | Source badge labels by resolution layer |

### Nested Shape: `TranslationItem.settings[string]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **title** | `string` | optional | Translated settings manifest title |
| **description** | `string` | optional | Translated settings manifest description |
| **groups** | `Record<string, { title?: string; description?: string }>` | optional | Group translations keyed by group key |
| **keys** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Per-setting field translations keyed by setting key |
| **actions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string }>` | optional | Action button translations keyed by action id |


---

12 changes: 7 additions & 5 deletions content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,13 @@ Two things to know:
a silent skip is the hardest kind of missing translation to diagnose.
- Only the groups on this page are accepted, and since #4001 that is literally
true: a key none of them declares is rejected, in a runtime item **and** in a
file-authored bundle. One group differs between the two doors: `settings` is
**platform-only** — a file bundle refuses it by name, and although the
registered `translation` item still declares it, the only namespaces it can
address are the platform's own, because only platform code declares a settings
manifest. Keys from the retired `o.<object>` shape (`o`, `app`,
file-authored bundle. `settings` is **platform-only** and both doors refuse it
by name: it is keyed by a settings manifest's namespace, and only platform code
declares a manifest, so the only namespaces it could address are the platform's
own. On a runtime item it used to be accepted — and because published items
layer over the shipped bundles, it overrode the platform's own Settings copy. A
row stored with it before the door closed loads without that group, with a
warning in the server log. Keys from the retired `o.<object>` shape (`o`, `app`,
`nav`, `dashboard`, `_globalOptions`, `_meta`, …) carry a message naming the
group to use instead — they used to save cleanly and then render nothing
(#3778). Everything else gets the nearest declared key suggested.
Expand Down
2 changes: 1 addition & 1 deletion docs/qa/platform-checklist/areas/i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@
],
"traps": ["stale-console-bundle", "hydration-race", "wrong-panel", "dispatcher-vs-hono-route"],
"source": [
"packages/spec/src/system/translation.zod.ts#appTranslationDataShape (appTranslationDataShape — the group vocabulary an APPLICATION may author: objects/_views/_actions/_sections, apps.navigation, messages, globalActions, dashboards, datasets, pages, flows, metadataForms, settingsCommon. `settings` is platform-only since #15178 and lives in platformSettingsShape, spread into PlatformTranslationDataSchema and TranslationItemSchema)",
"packages/spec/src/system/translation.zod.ts#appTranslationDataShape (appTranslationDataShape — the group vocabulary an APPLICATION may author: objects/_views/_actions/_sections, apps.navigation, messages, globalActions, dashboards, datasets, pages, flows, metadataForms, settingsCommon. `settings` is platform-only since #15178 and lives in platformSettingsShape, spread into PlatformTranslationDataSchema only — TranslationItemSchema refuses it too since #19620)",
"examples/app-showcase/src/system/translations/index.ts (full-column coverage rationale)",
"packages/services/service-i18n/src/i18n-service-plugin.ts#i18n (GET /i18n/locales | /translations/:locale | /labels/:object/:locale; { success, data } envelope #3636/#3675; resolveObjectFieldLabels nested shape #3778/#3833; the plugin mount and the dispatcher /i18n domain serve the same routes interchangeably)",
"packages/services/service-i18n/src/file-i18n-adapter.ts#getLocales (getLocales / getTranslations — unloaded locale → {}; fallbackLocale applies per-KEY in t(), not to the bulk route)",
Expand Down
120 changes: 120 additions & 0 deletions packages/core/src/fallbacks/authored-translation-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #19620 — the stored-row half of retiring `settings` from the `translation`
* item door (ruling batch #210 item 2 letter B).
*
* Narrowing `TranslationItemSchema` refuses NEW items carrying `settings` at
* the metadata door, but it cannot reach a row already stored: this sync reads
* `sys_metadata` itself and used to merge the RAW payload into the authored
* layer, which both i18n adapters read OVER the shipped bundles. So a row
* written before the door closed kept overriding the platform's own Settings
* copy on every boot. The sync is a rehydration seam and now replays the
* ADR-0087 chain over each row before merging it, which is what drops the
* group — loudly, once per row.
*
* `@objectstack/spec` resolves to its BUILT `dist/` here (no alias), and the
* conversion that does the dropping lives there: rebuild spec before reading
* a result from this file.
*/

import { describe, expect, it, vi } from 'vitest';

import { readAuthoredTranslationLayer, wireAuthoredTranslationSync } from './authored-translation-sync.js';
import { createMemoryI18n } from './memory-i18n.js';

type AnyRecord = Record<string, any>;

const row = (name: string, payload: AnyRecord) => ({
type: 'translation',
name,
state: 'active',
metadata: JSON.stringify(payload),
});

const engineOf = (rows: AnyRecord[]) => ({
find: vi.fn(async (_object: string, q: AnyRecord) => (q?.where?.type === 'translation' ? rows : [])),
});

/** An item stored before the door closed: `settings` beside an app-owned group. */
const storedWithSettings = () => row('zh-CN', {
locale: 'zh-CN',
settings: { mail: { title: '应用改写的邮件标题', keys: { host: { label: '应用改写的主机' } } } },
apps: { crm: { label: '客户关系管理' } },
});

describe('authored-translation sync replays the conversion chain over stored rows (#19620)', () => {
it('drops a stored item\'s `settings` before the merge and keeps the rest of the item', async () => {
const warn = vi.fn();
const layer = await readAuthoredTranslationLayer(engineOf([storedWithSettings()]), { warn });

expect(layer).not.toBeNull();
expect(layer!['zh-CN']).not.toHaveProperty('settings');
// The app-owned group on the same row still loads — the row is converted,
// not skipped.
expect(layer!['zh-CN']).toEqual({ apps: { crm: { label: '客户关系管理' } } });
});

it('says so, naming the row, the group and the conversion — never a silent strip', async () => {
const warn = vi.fn();
await readAuthoredTranslationLayer(engineOf([storedWithSettings()]), { warn });

expect(warn).toHaveBeenCalledTimes(1);
const line = String(warn.mock.calls[0]?.[0]);
expect(line).toContain("authored translation 'zh-CN'");
expect(line).toContain("'settings' → '(removed)'");
expect(line).toContain("'translation-per-app-settings-removed'");
expect(line).toContain('os migrate meta --stored --apply');
});

it('warns once per row per wiring, not on every sync', async () => {
const warn = vi.fn();
const warnedConversions = new Set<string>();
const engine = engineOf([storedWithSettings()]);
await readAuthoredTranslationLayer(engine, { warn }, { warnedConversions });
await readAuthoredTranslationLayer(engine, { warn }, { warnedConversions });

expect(warn).toHaveBeenCalledTimes(1);
});

it('CONTROL — a canonical row converts to itself and warns nothing', async () => {
const warn = vi.fn();
const layer = await readAuthoredTranslationLayer(
engineOf([row('zh-CN', { locale: 'zh-CN', apps: { crm: { label: '客户关系管理' } } })]),
{ warn },
);

expect(layer!['zh-CN']).toEqual({ apps: { crm: { label: '客户关系管理' } } });
expect(warn).not.toHaveBeenCalled();
});

it('end to end: the platform\'s own settings copy renders again, not the stored override', async () => {
// The platform bundle as `SettingsServicePlugin` contributes it — static.
const i18n = createMemoryI18n();
i18n.loadTranslations('zh-CN', {
settings: { mail: { title: '邮件投递', keys: { host: { label: 'SMTP 主机' } } } },
});

const hooks = new Map<string, Array<() => Promise<void> | void>>();
const warn = vi.fn();
const services: AnyRecord = { i18n, objectql: engineOf([storedWithSettings()]) };
wireAuthoredTranslationSync({
logger: { warn, info: vi.fn(), debug: vi.fn() },
getService: (name: string) => {
if (name in services) return services[name];
throw new Error(`service '${name}' not registered`);
},
hook: (name, fn) => hooks.set(name, [...(hooks.get(name) ?? []), fn]),
});
for (const fn of hooks.get('kernel:ready') ?? []) await fn();
for (const fn of hooks.get('metadata:reloaded') ?? []) await fn();

// Before the seam replayed the chain, the authored layer (read OVER the
// static bundle) answered these two with the stored row's strings.
expect(i18n.t('settings.mail.title', 'zh-CN')).toBe('邮件投递');
expect(i18n.t('settings.mail.keys.host.label', 'zh-CN')).toBe('SMTP 主机');
expect(i18n.t('apps.crm.label', 'zh-CN')).toBe('客户关系管理');
// Two syncs, one warning: the wiring owns its dedupe set.
expect(warn).toHaveBeenCalledTimes(1);
});
});
Loading
Loading