Skip to content

Commit 3071d3e

Browse files
fix(platform-objects): a host translation bundle for a platform key wins over the platform bundle in either boot order (#23019)
Fixes #22972 Clause-②: no ## What a host sees A host's translation bundle for a platform key now overrides `@objectstack/platform-objects`' text whichever of the two loads first. `content/docs/ui/translations.mdx` already says such a bundle "would override the platform's". Before this change, measured on both real i18n providers (the kernel's `createMemoryI18n` and service-i18n's `FileI18nAdapter`): a host zh-CN bundle that set `objects.sys_organization.label` to 「工作区」 and loaded before the platform bundles still served 「组织」. Every `defineStack({ translations })` app loads its bundles in `AppPlugin.start()`, before `kernel:ready`, so every such app was affected for any platform key it repeated. No platform text changes. The framework's zh-CN `sys_organization` stays 「组织」 (triage 6121140193: option (a) is not taken). No override layer, option or contract key is added. ## The change, `packages/platform-objects/src/plugin.ts` only - **Under, not over.** For each bundle and locale the plugin now loads `layUnder(bundle, i18n.getTranslations(locale))`. That value is the platform bundle with every leaf the service already holds handed back on top. The provider's own deep merge then keeps every host key and adds only the platform leaves the host lacks. `layUnder` recurses where both sides hold an object, so a host field label deep in `objects.NAME.fields` wins and its platform siblings (`help`) still land. In a shape conflict the loaded value is kept, so no host key is replaced or deleted. It walks the platform bundle's keys only, mutates neither input, and is not exported. - **When.** The bundles now load in `start()` as soon as an i18n service exists. A `kernel:ready` backstop, registered in `init()`, covers a kernel whose i18n service appears only later in Phase 2: an `AppPlugin` on a lean kernel registers its in-memory fallback during its own `start()`. The bundles are loaded once per service instance. - **Fail-safe.** A provider with no `getTranslations` still gets the platform bundles, loaded the old way (over), and the boot warns once that host bundles loaded earlier do not override them. ## Mechanism assumptions, measured (dispatch Section 2) 1. **Load call.** `II18nService.loadTranslations(locale, data)`. Both providers deep-merge it onto the locale's bundle layer, so the last writer wins (`memory-i18n.ts` and `file-i18n-adapter.ts`, `loadTranslations`). Confirmed. 2. **`getTranslations(locale)` returns the merged state, but not only the bundle layer.** Both providers merge the runtime-authored overlay (Studio `translation` items, the authored-translation sync) over the bundles. The memory provider also resolves locale fallback: with only `zh` loaded, `getTranslations('zh-CN')` answers the `zh` data. `FileI18nAdapter` answers exact-locale only. - Measured on both providers: if that answer is laid back after the overlay is synced, the overlay is written into the bundle layer. Clearing the overlay afterwards still serves the authored text (`pluralLabel` stayed `AUTHORED`). - The overlay is synced at `kernel:ready` by `wireAuthoredTranslationSync`, which `AppPlugin` wires in `init()` (`packages/runtime/src/app-plugin.ts`). Hooks dispatch in registration order (`packages/core/src/hook-dispatch.ts`), so that sync ran before the `kernel:ready` hook this plugin used to register in `start()`. - So the literal route ("lay the existing keys back" at `kernel:ready`) would make a deleted Studio override of a platform key keep serving until restart. The route therefore keeps the ruling's direction (load under) and moves the read ahead of the sync, into `start()`. Ablation 2 below turns exactly that pin red. 3. **A host bundle loaded after `kernel:ready` already won.** It still does (pin 3). 4. **A provider without `getTranslations`.** The fail-safe described above, pinned (pin 10). ## Pins: `packages/platform-objects/src/plugin.translations.test.ts`, on the real in-memory i18n service 1. Precondition: the host label differs from the platform's, so no pass is vacuous. 2. A host bundle loaded BEFORE the platform bundles serves 「工作区」, including a nested field label. 3. A host bundle loaded AFTER `kernel:ready` serves 「工作区」. 4. CONTROL: a key the host does not ship (`pluralLabel`, a field `help`) serves the platform text, and a host-only key survives. 5. CONTROL: a locale the host does not ship (`ja-JP`, `en`) serves the platform text unchanged. 6. An authored overlay synced at `kernel:ready` is never written into the bundle layer. After the overlay is deleted, the platform text is back. 7. An i18n service that appears only after `start()` gets the bundles on `kernel:ready`, and the host still wins. 8. On that late path, the authored overlay of a plugin ordered after this one is never written in either. 9. Each bundle loads once per service: 8 loads (2 bundles × 4 locales), and the backstop adds none. 10. Fail-safe: a provider with no `getTranslations` still gets the bundles, and exactly one warn names `getTranslations`. ## Reverse verification Each leg ran on the committed state through `scripts/ablation-replace.mjs`, which proves the mutation landed (anchor count 1 → 0, blob changed) and the restore held (blob == HEAD `9dc317736f49`, `git diff HEAD` empty). The test imports `./plugin.js` by relative path, so no `dist/` is involved. - **Ablation 1, merge order reverted** (the platform bundle loaded as-is): 3 red, each `expected '组织' to be '工作区'`. They are pin 2, pin 7 and pin 8. 7 green. - **Ablation 2, the load moved back to a `kernel:ready` hook** (the literal route): 1 red, pin 6, `expected '已编写' to be '组织'`. The deleted override kept serving. - **Ablation 3, the backstop registered in `start()` instead of `init()`**: 1 red, pin 8, `expected '已编写' to be '组织'`. - The first attempts at ablations 1 and 3 were no-ops. The tool refused them because the replacement text contained the anchor's own text, and no test ran. Each was re-run with a distinct marker. ## Tests and gates (at `c4588f7da6`, after merging `origin/main` `67baccc554`) - `pnpm --filter @objectstack/platform-objects test`: 70 files, 1096 tests passed (the new file: 10). - `pnpm --filter @objectstack/platform-objects typecheck`: exit 0. The test layer (`tsconfig.test.json`) compiles the new file with no entry added to `test-typecheck-debt.json`. - Dispatch gate list re-derived on this diff: the same 63 commands. 63 run, every exit code recorded, and `dispatch-gates --ran` reports 0 NOT-MEASURED. - `check:dual-build-cjs-loads`, `check:i18n` and `check:lean-entry-closure` first refused with `PREREQUISITE NOT MET` (exit 3, unbuilt workspace). They were re-run after the workspace build existed and all three exit 0. `check:i18n`: 9 packages, all bundles in sync. - Measured (scratch, not committed): this package's two bundles share 0 leaf keys with the eight other platform bundles in all four locales, and Setup and MetadataForms share none with each other. The example apps' bundles (crm, showcase, todo) repeat no platform key. Moving this load therefore changes no other package's or example's served text. - Left to CI (no local invocation exists): the Test Core shards, Dogfood Regression Gate, Dogfood Verify CLI, Build Core, Temporal Conformance and the workspace Type Check lanes. ## Acceptance notes - **Same family, not changed here.** The eight other platform bundles still load on `kernel:ready` with a plain `loadTranslations`: plugin-approvals, plugin-audit, plugin-security, plugin-sharing and plugin-webhooks, and service-messaging, service-realtime, service-settings and service-storage. So a host bundle for one of their keys that loads earlier still loses. The ruling scoped this card to one package. No published statement covers those keys, and no host was measured shipping one. - **Locale-fallback read on the memory provider.** When the platform loads a locale that has no exact entry yet, `getTranslations` answers a related locale (`zh` for `zh-CN`). That locale's host values are then laid over the platform keys of the platform locale. The effect: host text wins on platform keys, consistent with the precedence above. `FileI18nAdapter` reads exact-locale only. - **Why the fix rides on timing.** `II18nService` has no read of the bundle layer alone, so "under" depends on reading before the authored overlay is synced. The ruling excludes a new contract key, so none is added. - **Delivery.** This reaches cloud with its framework move (objectstack-ai/cloud#2709). --- _Generated by [Claude Code](https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9432bac commit 3071d3e

3 files changed

Lines changed: 364 additions & 34 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
A host's translation bundle for a platform key now overrides the platform's text, whichever loads first
6+
7+
Clause-②: no
8+
9+
`PlatformObjectsPlugin` loaded its translation bundles (the Setup App, the `sys_*` objects, `metadataForms.*`) on `kernel:ready`, and the i18n service merges each load over the last. A bundle a host loaded earlier, during its own `init()` or `start()` (every `defineStack({ translations })` app is one), was overwritten for every key the platform also ships. Measured on both i18n providers: a host zh-CN bundle that set `objects.sys_organization.label` to 「工作区」 still served 「组织」. The docs already said such a bundle "would override the platform's".
10+
11+
The platform bundles now load UNDER what the service already holds for each locale.
12+
13+
- **A host's key wins in either order.** A bundle loaded before the platform's is laid back on top of it, and a bundle loaded after it wins as before.
14+
- **Everything the host does not ship still serves the platform's text.** That covers sibling keys of a host key (a host field label leaves the platform's `help` for that field in place), and every locale the host does not ship.
15+
- **Studio-authored translations are unaffected.** The bundles now load in `start()`, as soon as an i18n service exists, which is before the authored overlay is synced at `kernel:ready`. Deleting an authored override of a platform key brings back the platform text, as before. A kernel whose i18n service only appears later in Phase 2 (an `AppPlugin` registering the in-memory fallback on a lean kernel) gets the bundles on `kernel:ready` instead.
16+
- **A provider without `getTranslations`** still gets the platform bundles, loaded over what is there as before, and the boot warns once that host bundles loaded earlier do not override it.
17+
18+
No platform text changes: zh-CN `sys_organization` is still 「组织」. A host that wants different text ships it in its own bundle. The other platform packages' bundles share no key with this package's, and no example app's bundle repeats a platform key, so nothing else changes what it serves.
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { createMemoryI18n } from '@objectstack/core';
5+
import { PlatformObjectsPlugin } from './plugin.js';
6+
import { SetupAppTranslations } from './apps/translations/index.js';
7+
8+
/**
9+
* A host's translation bundle for a platform key wins over this package's
10+
* bundle in either boot order — the precedence `content/docs/ui/translations.mdx`
11+
* publishes ("yours would override the platform's"). Pinned on a REAL
12+
* `II18nService` (the kernel's in-memory provider, the one every kernel without
13+
* `I18nServicePlugin` serves), because the defect lived in how two loads
14+
* compose inside that service, which a recording double cannot show.
15+
*/
16+
17+
const LABEL = 'objects.sys_organization.label';
18+
const PLURAL = 'objects.sys_organization.pluralLabel';
19+
const SLUG_LABEL = 'objects.sys_organization.fields.slug.label';
20+
const SLUG_HELP = 'objects.sys_organization.fields.slug.help';
21+
const HOST_ONLY = 'objects.sys_organization.fields.host_only.label';
22+
23+
/** A host's zh-CN bundle: one top-level label, one nested field label, one key of its own. */
24+
const HOST_ZH = {
25+
objects: {
26+
sys_organization: {
27+
label: '工作区',
28+
fields: { slug: { label: '工作区标识' }, host_only: { label: '仅宿主' } },
29+
},
30+
},
31+
};
32+
33+
const platform = (locale: string): any => (SetupAppTranslations as any)[locale].objects.sys_organization;
34+
35+
/** Fake PluginContext whose hooks run in registration order, as both kernels run them. */
36+
function makeCtx() {
37+
const services = new Map<string, any>();
38+
const byEvent: Record<string, Array<() => Promise<void> | void>> = {};
39+
const warns: string[] = [];
40+
const ctx: any = {
41+
logger: { info: () => {}, warn: (m: string) => { warns.push(String(m)); } },
42+
_warns: warns,
43+
registerService: (name: string, svc: any) => { services.set(name, svc); },
44+
getService: (name: string) => {
45+
const s = services.get(name);
46+
if (!s) throw new Error(`service '${name}' not registered`);
47+
return s;
48+
},
49+
hook: (event: string, fn: () => Promise<void> | void) => { (byEvent[event] ??= []).push(fn); },
50+
_flush: async (event: string) => { for (const h of byEvent[event] ?? []) await h(); },
51+
};
52+
return ctx;
53+
}
54+
55+
async function boot(ctx: any, plugin = new PlatformObjectsPlugin()) {
56+
await plugin.init(ctx);
57+
await plugin.start(ctx);
58+
await ctx._flush('kernel:ready');
59+
}
60+
61+
describe('PlatformObjectsPlugin: a host translation bundle wins over the platform bundle', () => {
62+
it('precondition: the host label differs from the platform label, so a pass cannot be vacuous', () => {
63+
expect(platform('zh-CN').label).not.toBe('工作区');
64+
expect(platform('zh-CN').fields.slug.label).not.toBe('工作区标识');
65+
});
66+
67+
it('a host bundle loaded BEFORE the platform bundles serves the host label', async () => {
68+
const ctx = makeCtx();
69+
const i18n = createMemoryI18n();
70+
ctx.registerService('i18n', i18n);
71+
i18n.loadTranslations('zh-CN', HOST_ZH); // a host plugin's init-time bundle
72+
73+
await boot(ctx);
74+
75+
expect(i18n.t(LABEL, 'zh-CN')).toBe('工作区');
76+
expect(i18n.t(SLUG_LABEL, 'zh-CN')).toBe('工作区标识');
77+
});
78+
79+
it('a host bundle loaded AFTER kernel:ready also serves the host label', async () => {
80+
const ctx = makeCtx();
81+
const i18n = createMemoryI18n();
82+
ctx.registerService('i18n', i18n);
83+
const plugin = new PlatformObjectsPlugin();
84+
await plugin.init(ctx);
85+
await plugin.start(ctx);
86+
ctx.hook('kernel:ready', () => i18n.loadTranslations('zh-CN', HOST_ZH)); // registered after the plugin's
87+
88+
await ctx._flush('kernel:ready');
89+
90+
expect(i18n.t(LABEL, 'zh-CN')).toBe('工作区');
91+
expect(i18n.t(SLUG_LABEL, 'zh-CN')).toBe('工作区标识');
92+
});
93+
94+
it('CONTROL: a key the host does not ship serves the platform text, and a host-only key survives', async () => {
95+
const ctx = makeCtx();
96+
const i18n = createMemoryI18n();
97+
ctx.registerService('i18n', i18n);
98+
i18n.loadTranslations('zh-CN', HOST_ZH);
99+
100+
await boot(ctx);
101+
102+
expect(i18n.t(PLURAL, 'zh-CN')).toBe(platform('zh-CN').pluralLabel);
103+
expect(i18n.t(SLUG_HELP, 'zh-CN')).toBe(platform('zh-CN').fields.slug.help);
104+
expect(i18n.t(HOST_ONLY, 'zh-CN')).toBe('仅宿主');
105+
});
106+
107+
it('CONTROL: a locale the host does not ship serves the platform text unchanged', async () => {
108+
const ctx = makeCtx();
109+
const i18n = createMemoryI18n();
110+
ctx.registerService('i18n', i18n);
111+
i18n.loadTranslations('zh-CN', HOST_ZH);
112+
113+
await boot(ctx);
114+
115+
expect(i18n.t(LABEL, 'ja-JP')).toBe(platform('ja-JP').label);
116+
expect(i18n.t(SLUG_LABEL, 'ja-JP')).toBe(platform('ja-JP').fields.slug.label);
117+
expect(i18n.t(LABEL, 'en')).toBe(platform('en').label);
118+
});
119+
120+
it('an authored overlay synced at kernel:ready is never written into the bundle layer', async () => {
121+
// `AppPlugin` wires the authored-translation sync in init(), so its
122+
// kernel:ready hook is registered ahead of anything registered in start().
123+
const ctx = makeCtx();
124+
const i18n = createMemoryI18n();
125+
ctx.registerService('i18n', i18n);
126+
ctx.hook('kernel:ready', () => {
127+
i18n.replaceAuthoredTranslations({
128+
'zh-CN': { objects: { sys_organization: { pluralLabel: '已编写' } } },
129+
});
130+
});
131+
132+
await boot(ctx);
133+
expect(i18n.t(PLURAL, 'zh-CN')).toBe('已编写');
134+
135+
i18n.replaceAuthoredTranslations({}); // the Studio override is deleted
136+
expect(i18n.t(PLURAL, 'zh-CN')).toBe(platform('zh-CN').pluralLabel);
137+
});
138+
139+
it('an i18n service that appears only after start() gets the bundles on kernel:ready, host still winning', async () => {
140+
const ctx = makeCtx();
141+
const plugin = new PlatformObjectsPlugin();
142+
await plugin.init(ctx);
143+
await plugin.start(ctx); // no i18n service yet: nothing to load into
144+
145+
// A later plugin's start() (an AppPlugin on a lean kernel) registers one and loads its bundle.
146+
const i18n = createMemoryI18n();
147+
ctx.registerService('i18n', i18n);
148+
i18n.loadTranslations('zh-CN', HOST_ZH);
149+
150+
await ctx._flush('kernel:ready');
151+
152+
expect(i18n.t(LABEL, 'zh-CN')).toBe('工作区');
153+
expect(i18n.t(PLURAL, 'zh-CN')).toBe(platform('zh-CN').pluralLabel);
154+
});
155+
156+
it('on that late path too, the authored overlay of a plugin ordered after this one is never written in', async () => {
157+
const ctx = makeCtx();
158+
const plugin = new PlatformObjectsPlugin();
159+
await plugin.init(ctx);
160+
// The next plugin's init() wires the authored-translation sync (as AppPlugin does).
161+
let i18n: ReturnType<typeof createMemoryI18n> | undefined;
162+
ctx.hook('kernel:ready', () => {
163+
i18n?.replaceAuthoredTranslations({
164+
'zh-CN': { objects: { sys_organization: { pluralLabel: '已编写' } } },
165+
});
166+
});
167+
await plugin.start(ctx); // no i18n service yet
168+
i18n = createMemoryI18n();
169+
ctx.registerService('i18n', i18n);
170+
i18n.loadTranslations('zh-CN', HOST_ZH);
171+
172+
await ctx._flush('kernel:ready');
173+
expect(i18n.t(PLURAL, 'zh-CN')).toBe('已编写');
174+
expect(i18n.t(LABEL, 'zh-CN')).toBe('工作区');
175+
176+
i18n.replaceAuthoredTranslations({});
177+
expect(i18n.t(PLURAL, 'zh-CN')).toBe(platform('zh-CN').pluralLabel);
178+
});
179+
180+
it('loads each bundle once per service: the kernel:ready backstop adds nothing after start()', async () => {
181+
const ctx = makeCtx();
182+
const real = createMemoryI18n();
183+
const loads: string[] = [];
184+
ctx.registerService('i18n', {
185+
...real,
186+
loadTranslations: (locale: string, data: Record<string, unknown>) => {
187+
loads.push(locale);
188+
real.loadTranslations(locale, data);
189+
},
190+
});
191+
192+
await boot(ctx);
193+
194+
// Two bundles (Setup, MetadataForms) times the four locales each ships.
195+
expect(loads.sort()).toEqual(['en', 'en', 'es-ES', 'es-ES', 'ja-JP', 'ja-JP', 'zh-CN', 'zh-CN']);
196+
});
197+
198+
it('fail-safe: a provider with no getTranslations still gets the platform bundles, and says so', async () => {
199+
const ctx = makeCtx();
200+
const withoutRead: any = { ...createMemoryI18n() };
201+
delete withoutRead.getTranslations;
202+
ctx.registerService('i18n', withoutRead);
203+
204+
await boot(ctx);
205+
206+
expect(withoutRead.t(LABEL, 'zh-CN')).toBe(platform('zh-CN').label);
207+
expect(ctx._warns.filter((w: string) => w.includes('getTranslations'))).toHaveLength(1);
208+
});
209+
});

0 commit comments

Comments
 (0)