Skip to content
53 changes: 53 additions & 0 deletions packages/objectql/src/protocol-meta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ═══════════════════════════════════════════════════════════════
Expand Down
18 changes: 18 additions & 0 deletions packages/objectql/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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/<name>) 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`
Expand Down
7 changes: 6 additions & 1 deletion packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down