diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index 55abd2862..48f983976 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -791,6 +791,59 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { }); }); + // ═══════════════════════════════════════════════════════════════ + // ADR-0029 D7 — navigation contributions reach the serving path + // + // Regression: the setup app is a shell of empty group anchors; menu + // entries are injected as navigation contributions and merged lazily on + // read. `registry.getApp` / `getAllApps` did the merge, but the REST app + // endpoints read through `protocol.getMetaItems` / `getMetaItem`, which + // returned the raw shell — leaving every Setup menu group empty. + // ═══════════════════════════════════════════════════════════════ + + describe('app navigation contributions (ADR-0029 D7)', () => { + const shellApp = { + name: 'setup', + label: 'Setup', + navigation: [ + { id: 'group_diagnostics', type: 'group', label: 'Diagnostics', children: [] }, + ], + }; + + const contribution = { + app: 'setup', + group: 'group_diagnostics', + priority: 100, + items: [{ id: 'nav_audit_logs', type: 'object', label: 'Audit Logs', objectName: 'sys_audit_log' }], + }; + + it('getMetaItems({type:"app"}) merges contributions into the served app', async () => { + registry.registerItem('app', shellApp, 'name'); + registry.registerAppNavContribution(contribution, 'platform-objects'); + + const result = await protocol.getMetaItems({ type: 'app' }); + + const setup = (result.items as any[]).find((a) => a.name === 'setup'); + expect(setup).toBeDefined(); + const group = setup.navigation.find((g: any) => g.id === 'group_diagnostics'); + expect(group.children).toHaveLength(1); + expect(group.children[0].id).toBe('nav_audit_logs'); + // The stored shell is never mutated — repeated reads stay idempotent. + expect((registry.getItem('app', 'setup') as any).navigation[0].children).toHaveLength(0); + }); + + it('getMetaItem({type:"app"}) merges contributions for a single-app fetch', async () => { + registry.registerItem('app', shellApp, 'name'); + registry.registerAppNavContribution(contribution, 'platform-objects'); + + const result = await protocol.getMetaItem({ type: 'app', name: 'setup' }); + + const group = (result.item as any).navigation.find((g: any) => g.id === 'group_diagnostics'); + expect(group.children).toHaveLength(1); + expect(group.children[0].id).toBe('nav_audit_logs'); + }); + }); + // ═══════════════════════════════════════════════════════════════ // loadMetaFromDb — startup hydration // ═══════════════════════════════════════════════════════════════ diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index a96d82a81..7f85707d1 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -1281,6 +1281,16 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { items = (items as any[]).filter((it) => !isAggregatedViewContainer(it)); } + // Merge registered navigation contributions into each served app + // (ADR-0029 D7). The setup app is a shell of empty group anchors; + // platform-objects and capability plugins inject their menu entries as + // contributions, merged lazily on read. REST app endpoints read through + // this path (not registry.getAllApps), so the merge must happen here too + // or every contributed group renders empty. + if (request.type === 'app' || request.type === 'apps') { + items = (items as any[]).map((app) => this.engine.registry.applyNavContributions(app)); + } + return { type: request.type, items: decorateMetadataItems( @@ -1417,6 +1427,14 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { } } + // Merge registered navigation contributions into a served app + // (ADR-0029 D7) — parity with the getMetaItems list path so a + // single-app fetch (GET /meta/app/) also sees the contributed + // menu entries, not just the empty group-anchor shell. + if ((request.type === 'app' || request.type === 'apps') && item) { + item = this.engine.registry.applyNavContributions(item); + } + // ADR-0010 §3.3 — artifact-level protection (lock/packageId) always // wins over any overlay row. The metadata service may return a // persisted overlay copy that pre-dates the artifact's `_lock` diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 7c0db4cf0..ab85435c7 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1012,8 +1012,13 @@ export class SchemaRegistry { * Return a copy of `app` with all registered navigation contributions * merged into its `navigation` tree. The stored app is never mutated, so * repeated reads stay idempotent. + * + * Public so the protocol serving path (`getMetaItems` / `getMetaItem` for + * `app`) can merge contributions the same way `getApp` / `getAllApps` do — + * the REST app endpoints read through the protocol, not these helpers, so + * the merge must be reachable from there too (ADR-0029 D7). */ - private applyNavContributions(app: any): any { + applyNavContributions(app: any): any { const contributions = this.appNavContributions.get(app?.name); if (!contributions || contributions.length === 0) return app;