Repository navigation
Commit 3071d3e
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
- .changeset
- packages/platform-objects/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
0 commit comments