Skip to content

Commit 4bf6e8f

Browse files
fix(metadata-protocol): the packaged app base carries contributed navigation, so Setup entries translate (#30553)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg8oKsEJp8L8ojaxxRT7yAhQ)_ domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF Fixes #30504 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:服务端给翻译器的「打包基准」应用,现在带上了各插件往这个应用里贡献的菜单项(用的是读取时本来就在用的同一个合并函数)。中文控制台的设置侧栏 45 个子菜单从全英文变成按翻译目录显示中文;account 应用里贡献进去的「连接智能体」同样修好。 - 为什么改:这些子菜单都是插件贡献的,基准里没有它们,翻译器就把它们当成定制包自己写的菜单,跳过翻译目录,于是全显示英文,而目录里其实有译文(如 nav_users = 用户)。 - 风险与回滚:只改一个服务端读取函数,接口返回的结构不变,只是标签值变成译文;定制包(.custom)自己新写的菜单仍然只读定制包自己的译文(ADR-0146 D2 不变)。回滚就是 revert。 - 先看:`packages/metadata-protocol/src/protocol-discovery-and-bases.ts` 的 `getPackagedAppBase`;`packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts` 末尾新增的 `[#30504]` 用例。 - 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run src/protocol-custom-override-bare-slot-envelope.test.ts -t 30504`,应看到 4 个用例通过;或用中文打开控制台的设置,侧栏子菜单显示「用户」「权限集分配」等中文。 - 需要你做的:无,席位落地。 ## What changes `getPackagedAppBase(name)` (the body in `protocol-discovery-and-bases.ts`; `protocol.ts` and `meta-item-read-gate.ts` are untouched) now answers the app's packaged declaration with the code layer's navigation contributions folded in, through the one fold every served app read already applies, `SchemaRegistry.applyNavContributions` (ADR-0029 D7). No second copy of the fold. When the registry has no such method (a double) the declaration alone is answered, and a throw from the fold answers the declaration alone, which is the base `main` answered. Why this is the fix: the served app is `declaration + contributions` (`protocol-meta-item-reads.ts` folds them on read), while the base handed to `translateApp` was the declaration alone. For a node id the known base lacks, `packagedNavNodes` answers `{}`, `valueOverridesPackagedBase` reads the label as changed, and a changed label reads only the pair's translations (ADR-0146 D2). Outside a pair that bundle is empty, so every contributed entry kept its source text. With the contributions in the base, a contributed node is judged against its own packaged label and the catalog applies. ADR-0146 D2 is preserved: a node a `.custom` override authors is in no contribution, so it is still absent from the base and still reads only the pair's translations, else its own text. Shape B (`packagedNavNodes` answering "no base knowledge" for an unknown id) was not taken, as the dispatch ruled: it would let the base catalog relabel an override-authored node. ## Tests - New top-level describe `[#30504]` in `packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts` (the #29613/#30062 real-boot harness that already pins the packaged app base), with and without a metadata service: - the packaged base carries the contributed entry, and the registry's own item is not mutated by the fold; - the base's own app, translated against that base, reads the contributed entry as `用户` in `zh-CN` and `Users` in `en`; - on the pair's declared override, the override-authored `nav_custom` keeps `Custom entry` with no pair translation (the catalog's `基包译文` is not taken) and reads the pair's `自定义入口` when the pair ships one, while the contributed `nav_users` reads `用户` in both. - Red on `main`: the test committed first (`97886306`), `@objectstack/metadata-protocol` built without the fix: 4 failed / 87 skipped (`expected {} to deeply equal { nav_users: 'Users' }`). With the fix and a rebuild: the file 91/91 passed. - After merging `origin/main` and rebuilding the objectql closure: this file plus `registry-nav-contribution-group-semantics.test.ts` and `registry-nav-contribution-disabled-package.test.ts`, 112/112 passed. - `pnpm --filter @objectstack/metadata-protocol exec vitest run`: 316 files passed / 4 skipped, 22425 tests passed / 26 skipped (before the main merge; the merge brought only two metadata-protocol test files). - `pnpm --filter @objectstack/metadata-protocol --filter @objectstack/objectql typecheck`: lock VERDICT command-exit 0. ## Acceptance notes - Measured whether other apps are hit the same way: every code-level contribution in the tree targets `setup`, plus one into `account` (`packages/mcp/src/connect-ui.ts`, `nav_connect_agent`), the same code path, fixed here (live: served `连接智能体`). Studio carries no code-level contributions, so it is not on this path. - `protocol.ts`'s public docblock for `getPackagedAppBase` still reads "answered the same way" as the page base; it is left untouched to stay off seat 5's facade series, and the new behaviour is documented on the body in `protocol-discovery-and-bases.ts`. A one-line docblock follow-up there can ride a later `protocol.ts` PR. - `nav_webhooks` reads `Webhooks` in zh-CN: that is the catalog's own zh-CN value, not a miss. ## Verification <details><summary>Live: showcase stack, GET /api/v1/meta/app/setup, signed in as the seeded admin</summary> `examples/app-showcase`, `objectstack dev --seed-admin --fresh` on this branch's build (fix included): - `Accept-Language: zh-CN`: 9/9 group headings and 45/45 leaf entries in the catalog's zh-CN text (44 contain CJK; the 45th is `nav_webhooks` = `Webhooks`, the catalog's value). First leaves: `nav_system_overview=系统概览 | nav_packaged_actions=打包动作 | nav_marketplace_browse=浏览应用市场 | nav_marketplace_installed=已安装应用 | nav_users=用户 | nav_organization=当前组织 | nav_business_units=业务单元 | nav_teams=团队`. On `main` the ui seat's dev measured 0/45 (os-dev-report 6660644606). - `Accept-Language: en`: 0/9 and 0/45 CJK, labels unchanged (`Users`, `Organization`, …). - `GET /api/v1/meta/app/account` in zh-CN: `nav_connect_agent=连接智能体`. </details> <details><summary>Gates (local subset, derived by dispatch-gates --commands on this diff)</summary> 58 derived families, all `:: exit 0`; `--ran` reconciles 58 run, 0 NOT-MEASURED. Two first ran as exit 3, PREREQUISITE NOT MET, and were re-run once their prerequisite was met: `check-plugin-teardown-shape.mjs --self-test` (shallow clone, after fetching its pinned fixture commit `621a4876`) and `check:dual-build-cjs-loads` (seven packages had no `dist/`; 116 entry points across 71 packages load). Record: project files `server-seat4/30504/`. `pnpm lint`: no findings. Taken on head `5eef4217`. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CayrDmiTYnJJzkKhec)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f5aaa0b commit 4bf6e8f

3 files changed

Lines changed: 124 additions & 4 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): contributed navigation entries are translated again, the whole Setup sidebar among them
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`getPackagedAppBase(name)` now answers the app's packaged declaration with the code layer's navigation contributions folded in, by the one fold every served app read applies (`SchemaRegistry.applyNavContributions`, ADR-0029 D7). Before, it answered the declaration alone, so `translateApp` read every contributed entry's id as one a `.custom` override authored (ADR-0146 D2), skipped the catalog and served the source text: on a `zh-CN` deployment every Setup leaf entry (Users, Permission Set Assignment, Sharing Rules, API Keys and the rest) read English beside Chinese group headings, although the catalog carries their keys. Contributed entries now read the catalog's translation; the served app's shape is unchanged, only label values move. A navigation node a `.custom` override authors is in no contribution, so it still reads only the pair's translations, else its own text.

‎packages/metadata-protocol/src/protocol-discovery-and-bases.ts‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
* dataset, app and book packaged bases (`getPackagedPageBase`,
1515
* `getPackagedDatasetBase`, `getPackagedAppBase`, `getPackagedBookBase`), the
1616
* served-override reading of a served document (`servedOverrideBaseOf`) and the
17-
* pair's own translations (`getPairTranslationBundle`).
17+
* pair's own translations (`getPairTranslationBundle`). [#30504] The app base
18+
* carries the code layer's navigation contributions, folded once.
1819
*
1920
* A MOVE, not a redesign. `ProtocolDiscoveryAndBasesFacet` is a stateless facet over the host
2021
* (ruling 6624345766): each static method below is the body of the class
@@ -273,9 +274,33 @@ export class ProtocolDiscoveryAndBasesFacet {
273274
return host.packagedArtifactBase('dataset', name);
274275
}
275276

276-
/** The body of `ObjectStackProtocolImplementation#getPackagedAppBase`; the method's docblock there is its contract. */
277-
static getPackagedAppBase(host: Pick<ProtocolHost, 'packagedArtifactBase'>, name: string): unknown {
278-
return host.packagedArtifactBase('app', name);
277+
/**
278+
* The body of `ObjectStackProtocolImplementation#getPackagedAppBase`; the method's docblock there is its contract.
279+
*
280+
* [#30504, ADR-0029 D7] The app's packaged declaration WITH the navigation
281+
* the code layer contributes to it, folded by the registry's one fold
282+
* (`SchemaRegistry.applyNavContributions`, the fold every served app read
283+
* applies). A contributed entry is packaged content, so `translateApp`
284+
* must find it in the base: without it the translator reads the entry's
285+
* id as one an override authored (ADR-0146 D2), the catalog is skipped,
286+
* and every contributed entry (the whole Setup sidebar) is served in the
287+
* source language. A node a `.custom` override authors is in no
288+
* contribution, so it is still absent from the base and still reads only
289+
* the pair's translations.
290+
*/
291+
static getPackagedAppBase(host: Pick<ProtocolHost, 'packagedArtifactBase' | 'engine'>, name: string): unknown {
292+
const declared = host.packagedArtifactBase('app', name);
293+
if (declared === undefined || declared === null) return declared;
294+
const registry = (host.engine as { registry?: { applyNavContributions?: (app: unknown) => unknown } } | undefined)?.registry;
295+
if (typeof registry?.applyNavContributions !== 'function') return declared;
296+
try {
297+
return registry.applyNavContributions(declared);
298+
} catch {
299+
// A fold over in-memory contributors; a failure here must never turn
300+
// a served document into a 5xx. The declaration alone is the base
301+
// this accessor answered before the fold was applied to it.
302+
return declared;
303+
}
279304
}
280305

281306
/** The body of `ObjectStackProtocolImplementation#getPackagedBookBase`; the method's docblock there is its contract. */

‎packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,3 +693,88 @@ describe('[#30062] a declared override\'s label order: the packaged base and the
693693
}
694694
}
695695
});
696+
697+
/**
698+
* [#30504, ADR-0029 D7 · ADR-0146 D2] The packaged base of an APP carries the
699+
* navigation the code layer contributes to it, so a contributed entry is
700+
* judged against its own packaged label and the catalog translates it.
701+
*
702+
* Measured live before the fix: `GET /api/v1/meta/app/setup` under `zh-CN`
703+
* served every Setup group heading in Chinese and every leaf entry in English,
704+
* while the catalog carried the leaf keys (`nav_users` = 用户). The leaves are
705+
* code-level `NavigationContribution`s, folded into the served app on read by
706+
* `SchemaRegistry.applyNavContributions`; the base `getPackagedAppBase`
707+
* answered was the app's declaration alone, which carries none of them. The
708+
* translator reads an id the known base lacks as a node an override authored
709+
* (`packagedNavNodes` answers `{}`), and such a node reads only the pair's
710+
* translations, so every contributed leaf kept its English.
711+
*
712+
* Pinned on the #29613 boot, with and without a metadata service:
713+
*
714+
* - the packaged base carries the contributed entry, and the registry's own
715+
* item is not mutated by the fold;
716+
* - the base's own app, translated against that base, reads the contributed
717+
* entry in `zh-CN` and keeps it in `en`;
718+
* - on the pair's declared override, a node the OVERRIDE authored still reads
719+
* only the pair's translations, else its own text (ADR-0146 D2), while the
720+
* contributed entry the override is served with reads the catalog.
721+
*/
722+
const CONTRIBUTOR = 'com.example.security';
723+
const CONTRIBUTED_LABEL = 'Users';
724+
const AUTHORED_LABEL = 'Custom entry';
725+
const navOf = (doc: any): Record<string, unknown> =>
726+
Object.fromEntries(((doc?.navigation ?? []) as any[]).map((n) => [n.id, n.label]));
727+
const NAV_CATALOG = {
728+
'zh-CN': { apps: { showcase: { navigation: { nav_users: { label: '用户' }, nav_custom: { label: '基包译文' } } } } },
729+
};
730+
731+
async function bootContributed(stores: Stores, withService: boolean) {
732+
const booted = await bootSealed(stores, withService);
733+
booted.engine.registerApp({
734+
id: CONTRIBUTOR, name: 'security', version: '1.0.0', namespace: 'security',
735+
navigationContributions: [{
736+
app: 'showcase',
737+
items: [{ id: 'nav_users', type: 'object', objectName: 'showcase_task', label: CONTRIBUTED_LABEL }],
738+
}],
739+
});
740+
return booted;
741+
}
742+
743+
describe('[#30504] an app\'s packaged base carries its contributed navigation, so the catalog translates a contributed entry', () => {
744+
for (const withService of [false, true]) {
745+
const shape = withService ? 'with a metadata service (a real boot)' : 'with no metadata service';
746+
747+
it(`${shape}: the base carries the contributed entry, and the base's own app reads it translated in zh-CN and unchanged in en`, async () => {
748+
const { engine, protocol } = await bootContributed(new Map(), withService);
749+
const base = protocol.getPackagedAppBase('showcase');
750+
expect(navOf(base), 'the packaged base').toEqual({ nav_users: CONTRIBUTED_LABEL });
751+
expect((engine.registry.getItem('app', 'showcase') as any)?.navigation, 'the registry item, unmutated').toBeUndefined();
752+
753+
const served = (await protocol.getMetaItem({ type: 'app', name: 'showcase' }))?.item;
754+
expect(navOf(served), 'served').toEqual({ nav_users: CONTRIBUTED_LABEL });
755+
const zh = translateMetadataDocument('app', served, NAV_CATALOG as any, { locale: 'zh-CN', packagedBase: base });
756+
expect(navOf(zh), 'zh-CN').toEqual({ nav_users: '用户' });
757+
const en = translateMetadataDocument('app', served, NAV_CATALOG as any, { locale: 'en', packagedBase: base });
758+
expect(navOf(en), 'en').toEqual({ nav_users: CONTRIBUTED_LABEL });
759+
});
760+
761+
it(`${shape}: on the pair's override, a node the override authored reads only the pair's translations (ADR-0146 D2) while the contributed entry reads the catalog`, async () => {
762+
const { protocol } = await bootContributed(new Map(), withService);
763+
await publishOverride(protocol, 'app', 'showcase', {
764+
...appBody(EDITED),
765+
navigation: [{ id: 'nav_custom', type: 'object', objectName: 'showcase_task', label: AUTHORED_LABEL }],
766+
});
767+
const served = (await protocol.getMetaItem({ type: 'app', name: 'showcase' }))?.item;
768+
expect(protocol.servedOverrideBaseOf('app', served), 'the served override').toBe(BASE);
769+
expect(navOf(served), 'served').toEqual({ nav_custom: AUTHORED_LABEL, nav_users: CONTRIBUTED_LABEL });
770+
771+
const base = protocol.getPackagedAppBase('showcase');
772+
const translate = (pairBundle?: unknown) => navOf(translateMetadataDocument('app', served, NAV_CATALOG as any, {
773+
locale: 'zh-CN', packagedBase: base, ...(pairBundle ? { pairBundle: pairBundle as any } : {}),
774+
}));
775+
expect(translate(), 'no pair translation').toEqual({ nav_custom: AUTHORED_LABEL, nav_users: '用户' });
776+
const pair = { 'zh-CN': { apps: { showcase: { navigation: { nav_custom: { label: '自定义入口' } } } } } };
777+
expect(translate(pair), 'the pair\'s translation').toEqual({ nav_custom: '自定义入口', nav_users: '用户' });
778+
});
779+
}
780+
});

0 commit comments

Comments
 (0)