Skip to content

Commit 4ec3987

Browse files
feat(spec)!: the translation item door refuses settings — platform-only at both application doors (#19620) (#19945)
Fixes #19620 Clause-②: no ## What is ruled, and what landed Ruling-ref `5770445203` — batch #210 item 2, letter **B**, maintainer 「210 同意」: `settings` leaves `TranslationItemSchema` together with the singular alias `setting: 'settings'`; the item door refuses it at parse with the platform-only prescription; the `settings` liveness row retires; the D2 conversion `translation-per-app-settings-removed` learns the item shape; the ADR-0087 semantic entry is extended. Step ① (the production reading) was waived by the maintainer (records `5796717943`, `5797554787`), so this PR runs step ② and then step ③, and the D2 item-shape conversion takes the **migrate** branch with a loud notice. Nothing is folded into PR #19600. ## Measured first: stored rows (dispatch assumption 3) The question was whether teaching the conversion the item shape removes a stored item's `settings` before the runtime reader merges it. **It does not, and not for one reason but two.** Probes (scratch scripts, not committed), read against this worktree: ```text # at origin/main fdeeea0, spec dist built from it [A] applyConversionsToStoredItem(translation, item-with-settings): settings survives = true ; notices = [] [B] readAuthoredTranslationLayer(raw row) -> layer[zh-CN].settings = {"mail":{"title":"我的邮件",...}} # after the conversion learned the item shape (411513f), spec dist rebuilt SINGULAR_TO_PLURAL.translation = undefined ; PLURAL_TO_SINGULAR.translations = undefined [stored seam] settings survives = true notices = [] [chain over translations collection] settings survives = false notices = ["translation-per-app-settings-removed"] ``` 1. `authored-translation-sync` reads `sys_metadata` itself and deep-merged the RAW stored payload; it never called the conversion chain. 2. Even the seams that DO call `applyConversionsToStoredItem` (the metadata protocol's stored reads, `DatabaseLoader.rowToData`, `os migrate meta --stored`) returned every `translation` row untouched: the stored pass wraps a row in its stack collection, and the manifest-collection maps carry no `translations` spelling. So no seam had ever replayed ANY translation conversion over a stored row. And the override is real: both i18n adapters read the runtime-authored layer OVER the static bundles (`deepMerge(static, authored)` in `packages/core/src/fallbacks/memory-i18n.ts` and `packages/services/service-i18n/src/file-i18n-adapter.ts`), so a stored item's `settings` beat the platform's own copy — the card's confidence gap 2 is closed, and the core test below pins it end to end. So the stored-row half lands in two places: the stored pass reaches `translation` rows (spec, `conversions/stored.ts`), and the runtime sync becomes a rehydration seam that calls it before merging (core, `authored-translation-sync.ts` — the claim's conditional surface). ## Step ② — conversion, semantic entry, stored rows - `packages/spec/src/conversions/registry.ts` — `translation-per-app-settings-removed` walks the bare item shape too: an entry carrying `locale`, or one with a declared translation group at its top level (a row written before `locale` was required, which the sync still reads by its name). Only the item's own top-level `settings` is stripped; an object literally named `settings` under `objects` stays. Surface, summary and docblock say both doors; the fixture gains the item and a locale-less control. - `packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts` — extended to both doors: the item OVERRODE the platform copy (a bundle entry only filled gaps), so overridden keys go back to the platform string and filled gaps to the manifest literal; `acceptanceCriteria` no longer says the item is unchanged. `migrations/registry.ts` regenerated with `gen:migration-registry`; the hand-written step-18 rationale sentence that said the conversion never touches a `translation` item is rewritten. - `packages/spec/src/conversions/stored.ts` — `STORED_ONLY_COLLECTIONS` maps a stored `translation` (and the legacy plural `translations`) row to the `translations` collection. Kept out of the shared maps, which `check:stack-collection-maps` holds to the stack schema. - `packages/core/src/fallbacks/authored-translation-sync.ts` — each row replays the full chain through `applyConversionsToStoredItem` before the merge (the same policy as every other stored-read seam, PD #12), and each conversion is logged at `warn` once per row per wiring, naming the row, the group (`'settings' → '(removed)'`), the conversion id, and `os migrate meta --stored --apply`. - Liveness: the `settings` row of `packages/spec/liveness/translation.json` is deleted (strict-delete route: the key left the walked shape, a surviving row would be an ORPHAN). Its `_note` and the README's `translation` row say what the deletion does NOT mean: the row's `live` evidence read the SERVED tree, which the platform bundle feeds, so the platform capability is untouched. `check:liveness` then named `translation/settings` a stale row of the shrink-only `undrilled-containers.baseline.json`; it is deleted. `state-counts.md` regenerated. ## Step ③ — the schema, the alias, the pins - `packages/spec/src/system/translation.zod.ts` — `TranslationItemSchema` spreads `appTranslationDataShape()` only (the per-app face, ten groups); `setting: 'settings'` leaves its alias table; `settings` and `setting` are answered by `ITEM_TRANSLATION_KEY_GUIDANCE` with the item's own `ITEM_SETTINGS_PLATFORM_ONLY`, because the bundle door's sentence ("the platform overwrote it anyway") is false for an item. `settingsCommon` stays on both faces. - `packages/spec/authorable-surface/system.json` — `system/TranslationItem:settings` deleted deliberately (the check (a) tripwire the strict-delete route owes). The build's check (c) adjudicated it by proof 4: ```text 1 baseline deletion(s) since fdeeea0 carry their own proof (#4650): - system/TranslationItem:settings — def reachable from the metadata-type roots; writing 'settings' on it is REFUSED as an unrecognized key and the refusal carries the prescription its `strictObject` declaration owes it ... ``` - Pins flipped (`packages/spec/src/system/translation.test.ts`): "still accepts every declared group together" now asserts the item refuses exactly one key, `[['unrecognized_keys', ['settings']]]`; "still accepts it on the platform face" keeps the platform assertions and drops the item one; a new block refuses `settings` and `setting` on the item (issue path, keys, `PLATFORM group`, `PlatformTranslationData`, no rename suggestion), refuses through `defineTranslation`, with a control. - Door-level pin (`packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts`, section 4): saving a `translation` item carrying `settings` / `setting` answers `code: 'INVALID_METADATA'`, `status: 422`, the issue names the key and says `PLATFORM group`, and nothing is stored; a control saves the same item without it. It rides that file's already-pinned engine double, so the engine-double ledger does not move. - Stored-row pins: `packages/spec/src/conversions/stored.test.ts` (both row spellings drop `settings` with exactly one notice; a canonical row passes through by reference) and the new `packages/core/src/fallbacks/authored-translation-sync.test.ts` (dropped before the merge, rest of the item kept, one warning naming row/group/conversion, once per wiring, canonical-row control, and end to end over `createMemoryI18n`: the platform's `邮件投递` renders, not the stored override). - Published prose made false by this change: `content/docs/ui/translations.mdx` (said the item still declares it), `content/docs/protocol/kernel/i18n-standard.mdx` (adds the item door), `docs/qa/platform-checklist/areas/i18n.json` (anchor prose); `content/docs/references/system/translation.mdx` regenerated with `gen:docs`. - `.changeset/19620-translation-item-settings-platform-only.md` — `@objectstack/spec` and `@objectstack/core` `minor` (launch-window convention), BREAKING banner, FROM → TO table, one-line fix, the stored-row behaviour, ADR-0087 disposition `not-required (already-registered …)` because both entries existed and are extended here. ## PR #19600's acceptance note is superseded PR #19600 (merged) records "`TranslationItemSchema` is UNCHANGED and still declares `settings`" and, as its first acceptance note, "The `translation` metadata-type door is untouched and still accepts `settings`." **Both are superseded by this PR**: the item door refuses `settings` with the platform-only prescription, and rows stored before are converted at every stored seam. The seat carries this sentence to #19600 as a comment. ## ⚠️ One red gate by design — a pending release note corrected, confirmation requested `.changeset/15178-translation-bundle-split-settings-platform-only.md` is unreleased and its "Unchanged" section said the registered `translation` item still declares `settings` — false once this PR lands in the same release. It is **corrected, not restored** (one sentence: not changed by THAT entry, superseded in the same release by this PR's changeset). `node scripts/check-empty-changeset.mjs --base origin/main` therefore exits 1 in its DELIBERATE CORRECTION class, whose own text says the remedy is to say so on the PR and get it confirmed. **Please confirm this correction**; the alternative, restoring the file from base, republishes the false sentence. No `skip-changeset` is involved. ## Declared file-surface deviations The claim declared `translation.zod.ts` + tests, `liveness/translation.json`, the conversion + semantic entry + registries, regenerated artefacts, `.changeset/`, and conditionally `authored-translation-sync.ts` (taken: measured necessary above). Outside it, each forced rather than chosen: 1. `packages/spec/src/conversions/stored.ts` + `stored.test.ts` — the stored pass returned `translation` rows untouched (measured above); without it the migrate branch the ruling orders reaches no stored row. Same defect class, a one-entry map; no open PR on it was checked (not measured — ordinary concurrency). 2. `packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts` — the ruling's `code` + `status` pin lives at the metadata door, not in the schema package. 3. `packages/spec/scripts/liveness/undrilled-containers.baseline.json` — the shrink-only row `check:liveness` named stale once the key left the shape. 4. `content/docs/ui/translations.mdx`, `content/docs/protocol/kernel/i18n-standard.mdx`, `docs/qa/platform-checklist/areas/i18n.json`, `packages/spec/liveness/README.md` — published claims this change makes false. 5. `.changeset/15178-…` — the correction above. ## Verification Commits `411513f09` (step ②), `7ba3d25d9` (step ③), `d94e300e4` (regenerated artefacts), `889861a04` (stored pass, door pins, changeset), `b0f2b0ef0` (the changeset's ADR-0087 marker line only). **Tests** (targeted, through `scripts/pm/os-verify-lock.sh`; run at `889861a04` — `b0f2b0ef0` changes only the changeset marker, which no test reads; the 422 file re-run at `b0f2b0ef0`): | Package | Scope | Result | | --- | --- | --- | | `@objectstack/spec` | `translation`, `i18n-resolver`, `conversions/*`, `migrations/*`, `retired-key-migrate-sentence`, `alias-integrity`, `type-alias-convention.pin`, `metadata-plugin` | 14 files / 912 tests pass | | `@objectstack/core` | `fallbacks/authored-translation-sync.test.ts` (new), `fallbacks/fallbacks.test.ts` | 2 files / 66 tests pass | | `@objectstack/metadata-protocol` | whole package (dependency closure built first) | 188 files pass, 3 skipped / 2676 tests pass, 19 skipped | | `@objectstack/metadata-protocol` | the 422 file, verbose, at `b0f2b0ef0` | 11 / 11, the three new cases named | | `@objectstack/service-i18n` | whole package, against the rebuilt core `dist` | 5 files / 74 tests pass | **Typecheck:** `@objectstack/spec` (`tsc --noEmit` + scripts + test layer), `@objectstack/core`, `@objectstack/metadata-protocol` — all exit 0. **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 116 commands on the actual diff (21 paths vs merge base `fdeeea0cc`); all 116 run at `b0f2b0ef0` with the exit code recorded, and `--ran` reconciles: **116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN**. 115 exit 0; the one exit 1 is `check-empty-changeset` (above). The whole workspace was built first (`turbo run build --filter='./packages/*' --filter='./packages/*/*'`, 72 tasks) so the five built-output gates (`check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n-walk-parity`, `check:lean-entry-closure`, `check:type-check-debt`) measured instead of exiting 3. `check:type-check-debt`: "4 ledger entr(ies) re-measured, 53 raw tsc error(s) total, none above its recorded number." **Lint, narrowed and proven:** ESLint over the 10 changed `.ts` files, `--no-inline-config --format json`: 10 files linted, 0 errors, 0 warnings. Population: the config's own globs (`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus `NEVER_LINTED`) take all 10 of the diff's script files. Invariance: `eslint.config.mjs` never enables type-aware linting (its own comment: "no `parserOptions.project`, no typed `@typescript-eslint` rules"), so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is CI's. **Ablations** (each through `scripts/ablation-replace.mjs`, after the fix was committed; anchor hit 1 → 0 and a changed blob proved the mutation on disk; each restore proved blob == HEAD and `git diff HEAD` empty; the tests read `src/` by relative import, so no `dist/` was involved). Direction predicted before running: | Mutation | Predicted | Observed | | --- | --- | --- | | A — `...platformSettingsShape()` back on the item shape | the `settings` pins red; the singular `setting` pin stays green (its guidance still refuses it) | 3 red (declared-groups, `settings` refusal, `defineTranslation`); `setting` green | | B — stored pass without `STORED_ONLY_COLLECTIONS` | both stored-row cases red, control green | 2 red, control green | | C — sync merges without the chain replay | 4 core cases red, canonical control green | 4 red, 1 green | | D — conversion's item branch returns the entry | fixture replay + stored cases red | 4 red across `conversions`, `stored`, `migrations` | **Reverse verification of the rebuilt `.d.ts`:** a probe file in `packages/core/src` typed `const rejected: TranslationItem = { locale: 'en', settings: … }` beside an `apps` control; `tsc --noEmit -p packages/core` answered `TS2353 … 'settings' does not exist in type …` on the `settings` line only; the probe was removed by a trap and the tree read clean. ## Acceptance notes - **Same-class correction riding the stored-pass change.** The docblocks of `translation-validation-messages-removed` and `translation-component-submit-label-removed` already claimed stored `translation` rows replay through them; until this PR none did. Both strip keys no resolver reads, so the only observable change for them is a one-time stored-row warning when an old row is read. - **What an operator sees.** Reading an old row that still carries `settings` through the metadata API now serves it without the group and logs the protocol's stored-row warning; the runtime sync logs its own warning once. A Studio re-save or `os migrate meta --stored --apply` persists the canonical row. - **`setting` (singular) is not converted.** An alias only ever suggested a rename in the rejection; the item door never accepted `setting`, so no stored row can carry it. - **The sync's warning is conversion-agnostic.** It names the row, the dropped group and the conversion; the platform-only reason is carried by the item-door refusal and by the D3 semantic entry (which `os migrate meta --from 17` reports as a semantic TODO, per that command's own docblock — not run here), not restated per conversion in the consumer. - **Pinned sibling (objectui `62597c588`)** — `TranslationPreview.tsx` still lists a `settings` group and `clientValidation.ts` prose counts "19 keys". Neither breaks: the binding imports `TranslationItemSchema` itself and its parity test compares the schema with itself; the preview group simply never renders now. Stale prose / dead UI in the sibling; carrier: none; not filed. - Size: 21 files, +714 / −173 (887 changed lines), under the 5,000-line human-merge threshold. No governed surface is touched. --- _Generated by [Claude Code](https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ae7a35a commit 4ec3987

21 files changed

Lines changed: 714 additions & 173 deletions

‎.changeset/15178-translation-bundle-split-settings-platform-only.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,9 @@ and the rejection carries the prescription above.
6666

6767
### Unchanged
6868

69-
The registered `translation` metadata type (`TranslationItemSchema`) still
70-
declares `settings` — this ruling covers the file-authored bundle. `GET
69+
The registered `translation` metadata type (`TranslationItemSchema`) is not
70+
changed by THIS entry — this ruling covers the file-authored bundle. (Superseded
71+
in the same release: #19620 narrows the item door too; see its own changeset.) `GET
7172
/api/v1/i18n/translations/:locale` still declares it on its response, because the
7273
served document is the merged tree; `GetTranslationsResponseSchema` is typed
7374
against the platform face for exactly that reason.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
---
5+
6+
**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)
7+
8+
Clause-②: no
9+
10+
`TranslationItemSchema` — one `translation` metadata item, authored with
11+
`defineTranslation`, in Studio, or through the metadata API — now takes the same
12+
ten groups as a per-app bundle entry (`TranslationData`). `settings`, and its
13+
singular `setting`, are refused by name with the platform-only prescription,
14+
exactly as the per-app bundle has refused them since #15178. The file door and
15+
the item door are two authoring surfaces for one app metadata type, so they
16+
accept one shape.
17+
18+
### Migration — FROM → TO
19+
20+
| You wrote | Write instead |
21+
| --- | --- |
22+
| `defineTranslation({ locale: 'zh-CN', settings: { mail: { title: '邮件投递' } } })` | delete the `settings` group — there is no application-side replacement key |
23+
| 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 |
24+
| `const t: TranslationItem = { locale: 'en', settings: … }` | move the copy to the PLATFORM bundle (`PlatformTranslationData`), or delete it |
25+
26+
**The one-line fix: delete the `settings` group from the item.** Settings copy is
27+
not application-authorable — `settings` is keyed by `SettingsManifest.namespace`
28+
and only platform code declares a manifest. `settingsCommon` is **not** affected:
29+
the Settings UI shell strings (the source badges, under
30+
`settingsCommon.sourceLabels`) stay on both application faces.
31+
Run `os migrate meta --from 17` to list the mechanical edits for existing
32+
sources; apply them by hand.
33+
34+
### Rows already stored are converted, not refused
35+
36+
A `translation` row saved before this change keeps loading. The runtime
37+
translation sync (`@objectstack/core`'s `authored-translation-sync`) reads
38+
`sys_metadata` itself and used to merge the RAW stored payload; it now replays
39+
the ADR-0087 conversion chain over each row before merging it, the same policy
40+
as every other stored-metadata read seam. `translation-per-app-settings-removed`
41+
has learned the item shape, so a stored row's `settings` is dropped there, the
42+
rest of the item (`objects`, `apps`, …) still loads, and the server logs one
43+
warning per row naming the row, the group and the conversion. Run
44+
`os migrate meta --stored --apply` to persist the canonical rows.
45+
46+
### What changes on screen, which is not nothing
47+
48+
On the item door the group was STRONGER than on the bundle door. A published
49+
item is loaded into the runtime-authored layer, which both i18n adapters read
50+
**over** the shipped bundles — so an item's `settings` overrode the platform's
51+
own Settings copy for its locale, rather than only filling gaps. After
52+
upgrading, re-read the Settings screens in each locale such an item covered:
53+
where it overrode a platform string, **the platform's string renders again**;
54+
where it filled a gap the platform bundle leaves, the **manifest's own literal
55+
renders, which is English**. If a platform string is wrong or missing for your
56+
locale, correct it in the platform bundle (`@objectstack/service-settings`'s
57+
`settingsBuiltinTranslations`).
58+
59+
No deprecation window: the item door refuses the key by name from this major.
60+
61+
### Unchanged
62+
63+
The platform face — `PlatformTranslationDataSchema`, `settingsBuiltinTranslations`,
64+
and `GET /api/v1/i18n/translations/:locale`, whose served document is the merged
65+
tree — still declares `settings`. The liveness ledger's `translation.settings`
66+
row is deleted because the key left the ITEM's shape; the platform capability it
67+
evidenced is untouched.
68+
69+
Ruling batch #210 item 2 letter B (2026-09-22) — maintainer 「210 同意」.
70+
71+
<!-- 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 -->

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,9 @@ Three rules the shape enforces, all of them closed since #4001:
406406
it.** It is keyed by `SettingsManifest.namespace`, and only platform code
407407
declares a manifest — so the only namespaces an application could ever
408408
address are the platform's own. Writing it in `stack.translations` (or in
409-
`defineTranslationBundle`) is refused by name with that prescription; the
409+
`defineTranslationBundle`) is refused by name with that prescription, and so
410+
is writing it on a `translation` metadata item (`defineTranslation`, or a save
411+
through the metadata API) — the item door takes the same per-app face; the
410412
platform's own bundles author it against `PlatformTranslationData`, and the
411413
served document (`GET /i18n/translations/:locale`) carries it because it is
412414
the merge of every loaded bundle.

‎content/docs/references/system/translation.mdx‎

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,6 @@ One locale of translations — the `translation` metadata type
506506
| **flows** | `Record<string, { label?: string; screens?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
507507
| **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 |
508508
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
509-
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |
510509
| **locale** | `string` | ✅ | BCP-47 locale this item translates (e.g. "zh-CN") |
511510
| **name** | `string` | optional | Item name — conventionally the locale code (`zh-CN`); the runtime sync falls back to it when `locale` is absent |
512511
| **label** | `string` | optional | Human-readable label shown in metadata lists |
@@ -604,16 +603,6 @@ Translation data for a single object
604603
| :--- | :--- | :--- | :--- |
605604
| **sourceLabels** | `{ env?: string; global?: string; tenant?: string; user?: string; … }` | optional | Source badge labels by resolution layer |
606605

607-
### Nested Shape: `TranslationItem.settings[string]`
608-
609-
| Property | Type | Required | Description |
610-
| :--- | :--- | :--- | :--- |
611-
| **title** | `string` | optional | Translated settings manifest title |
612-
| **description** | `string` | optional | Translated settings manifest description |
613-
| **groups** | `Record<string, { title?: string; description?: string }>` | optional | Group translations keyed by group key |
614-
| **keys** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Per-setting field translations keyed by setting key |
615-
| **actions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string }>` | optional | Action button translations keyed by action id |
616-
617606

618607
---
619608

‎content/docs/ui/translations.mdx‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -217,11 +217,13 @@ Two things to know:
217217
a silent skip is the hardest kind of missing translation to diagnose.
218218
- Only the groups on this page are accepted, and since #4001 that is literally
219219
true: a key none of them declares is rejected, in a runtime item **and** in a
220-
file-authored bundle. One group differs between the two doors: `settings` is
221-
**platform-only** — a file bundle refuses it by name, and although the
222-
registered `translation` item still declares it, the only namespaces it can
223-
address are the platform's own, because only platform code declares a settings
224-
manifest. Keys from the retired `o.<object>` shape (`o`, `app`,
220+
file-authored bundle. `settings` is **platform-only** and both doors refuse it
221+
by name: it is keyed by a settings manifest's namespace, and only platform code
222+
declares a manifest, so the only namespaces it could address are the platform's
223+
own. On a runtime item it used to be accepted — and because published items
224+
layer over the shipped bundles, it overrode the platform's own Settings copy. A
225+
row stored with it before the door closed loads without that group, with a
226+
warning in the server log. Keys from the retired `o.<object>` shape (`o`, `app`,
225227
`nav`, `dashboard`, `_globalOptions`, `_meta`, …) carry a message naming the
226228
group to use instead — they used to save cleanly and then render nothing
227229
(#3778). Everything else gets the nearest declared key suggested.

‎docs/qa/platform-checklist/areas/i18n.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@
299299
],
300300
"traps": ["stale-console-bundle", "hydration-race", "wrong-panel", "dispatcher-vs-hono-route"],
301301
"source": [
302-
"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)",
302+
"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)",
303303
"examples/app-showcase/src/system/translations/index.ts (full-column coverage rationale)",
304304
"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)",
305305
"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)",
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #19620 — the stored-row half of retiring `settings` from the `translation`
5+
* item door (ruling batch #210 item 2 letter B).
6+
*
7+
* Narrowing `TranslationItemSchema` refuses NEW items carrying `settings` at
8+
* the metadata door, but it cannot reach a row already stored: this sync reads
9+
* `sys_metadata` itself and used to merge the RAW payload into the authored
10+
* layer, which both i18n adapters read OVER the shipped bundles. So a row
11+
* written before the door closed kept overriding the platform's own Settings
12+
* copy on every boot. The sync is a rehydration seam and now replays the
13+
* ADR-0087 chain over each row before merging it, which is what drops the
14+
* group — loudly, once per row.
15+
*
16+
* `@objectstack/spec` resolves to its BUILT `dist/` here (no alias), and the
17+
* conversion that does the dropping lives there: rebuild spec before reading
18+
* a result from this file.
19+
*/
20+
21+
import { describe, expect, it, vi } from 'vitest';
22+
23+
import { readAuthoredTranslationLayer, wireAuthoredTranslationSync } from './authored-translation-sync.js';
24+
import { createMemoryI18n } from './memory-i18n.js';
25+
26+
type AnyRecord = Record<string, any>;
27+
28+
const row = (name: string, payload: AnyRecord) => ({
29+
type: 'translation',
30+
name,
31+
state: 'active',
32+
metadata: JSON.stringify(payload),
33+
});
34+
35+
const engineOf = (rows: AnyRecord[]) => ({
36+
find: vi.fn(async (_object: string, q: AnyRecord) => (q?.where?.type === 'translation' ? rows : [])),
37+
});
38+
39+
/** An item stored before the door closed: `settings` beside an app-owned group. */
40+
const storedWithSettings = () => row('zh-CN', {
41+
locale: 'zh-CN',
42+
settings: { mail: { title: '应用改写的邮件标题', keys: { host: { label: '应用改写的主机' } } } },
43+
apps: { crm: { label: '客户关系管理' } },
44+
});
45+
46+
describe('authored-translation sync replays the conversion chain over stored rows (#19620)', () => {
47+
it('drops a stored item\'s `settings` before the merge and keeps the rest of the item', async () => {
48+
const warn = vi.fn();
49+
const layer = await readAuthoredTranslationLayer(engineOf([storedWithSettings()]), { warn });
50+
51+
expect(layer).not.toBeNull();
52+
expect(layer!['zh-CN']).not.toHaveProperty('settings');
53+
// The app-owned group on the same row still loads — the row is converted,
54+
// not skipped.
55+
expect(layer!['zh-CN']).toEqual({ apps: { crm: { label: '客户关系管理' } } });
56+
});
57+
58+
it('says so, naming the row, the group and the conversion — never a silent strip', async () => {
59+
const warn = vi.fn();
60+
await readAuthoredTranslationLayer(engineOf([storedWithSettings()]), { warn });
61+
62+
expect(warn).toHaveBeenCalledTimes(1);
63+
const line = String(warn.mock.calls[0]?.[0]);
64+
expect(line).toContain("authored translation 'zh-CN'");
65+
expect(line).toContain("'settings' → '(removed)'");
66+
expect(line).toContain("'translation-per-app-settings-removed'");
67+
expect(line).toContain('os migrate meta --stored --apply');
68+
});
69+
70+
it('warns once per row per wiring, not on every sync', async () => {
71+
const warn = vi.fn();
72+
const warnedConversions = new Set<string>();
73+
const engine = engineOf([storedWithSettings()]);
74+
await readAuthoredTranslationLayer(engine, { warn }, { warnedConversions });
75+
await readAuthoredTranslationLayer(engine, { warn }, { warnedConversions });
76+
77+
expect(warn).toHaveBeenCalledTimes(1);
78+
});
79+
80+
it('CONTROL — a canonical row converts to itself and warns nothing', async () => {
81+
const warn = vi.fn();
82+
const layer = await readAuthoredTranslationLayer(
83+
engineOf([row('zh-CN', { locale: 'zh-CN', apps: { crm: { label: '客户关系管理' } } })]),
84+
{ warn },
85+
);
86+
87+
expect(layer!['zh-CN']).toEqual({ apps: { crm: { label: '客户关系管理' } } });
88+
expect(warn).not.toHaveBeenCalled();
89+
});
90+
91+
it('end to end: the platform\'s own settings copy renders again, not the stored override', async () => {
92+
// The platform bundle as `SettingsServicePlugin` contributes it — static.
93+
const i18n = createMemoryI18n();
94+
i18n.loadTranslations('zh-CN', {
95+
settings: { mail: { title: '邮件投递', keys: { host: { label: 'SMTP 主机' } } } },
96+
});
97+
98+
const hooks = new Map<string, Array<() => Promise<void> | void>>();
99+
const warn = vi.fn();
100+
const services: AnyRecord = { i18n, objectql: engineOf([storedWithSettings()]) };
101+
wireAuthoredTranslationSync({
102+
logger: { warn, info: vi.fn(), debug: vi.fn() },
103+
getService: (name: string) => {
104+
if (name in services) return services[name];
105+
throw new Error(`service '${name}' not registered`);
106+
},
107+
hook: (name, fn) => hooks.set(name, [...(hooks.get(name) ?? []), fn]),
108+
});
109+
for (const fn of hooks.get('kernel:ready') ?? []) await fn();
110+
for (const fn of hooks.get('metadata:reloaded') ?? []) await fn();
111+
112+
// Before the seam replayed the chain, the authored layer (read OVER the
113+
// static bundle) answered these two with the stored row's strings.
114+
expect(i18n.t('settings.mail.title', 'zh-CN')).toBe('邮件投递');
115+
expect(i18n.t('settings.mail.keys.host.label', 'zh-CN')).toBe('SMTP 主机');
116+
expect(i18n.t('apps.crm.label', 'zh-CN')).toBe('客户关系管理');
117+
// Two syncs, one warning: the wiring owns its dedupe set.
118+
expect(warn).toHaveBeenCalledTimes(1);
119+
});
120+
});

0 commit comments

Comments
 (0)