Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/adr-0029-d7-setup-nav-contributions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@objectstack/spec": minor
"@objectstack/objectql": minor
"@objectstack/platform-objects": minor
"@objectstack/plugin-auth": minor
"@objectstack/plugin-webhooks": minor
---

ADR-0029 D7 — Setup app navigation contributions.

Adds the UI-layer analog of object `own`/`extend`: a package can contribute
navigation items into an app it does not own, so a shared admin app can be a
thin shell while each capability plugin ships the menu for the objects it owns.

- **`@objectstack/spec`** — new `NavigationContributionSchema` (`{ app, group?,
priority, items }`) and an optional `navigationContributions` field on the
manifest.
- **`@objectstack/objectql`** — `SchemaRegistry.registerAppNavContribution()`
plus lazy merge in `getApp` / `getAllApps` (by target group id + priority,
cloning so the stored app is never mutated); the engine wires
`manifest.navigationContributions` during app registration.
- **`@objectstack/platform-objects`** — the Setup app becomes a **shell** of
empty group anchors; its entries for platform-objects-owned objects move to
`SETUP_NAV_CONTRIBUTIONS`.
- **`@objectstack/plugin-auth`** — registers `SETUP_NAV_CONTRIBUTIONS` alongside
the Setup app it already registers.
- **`@objectstack/plugin-webhooks`** — contributes its `Webhooks` /
`Webhook Deliveries` entries into the Setup `group_integrations` slot (it owns
`sys_webhook` / `sys_webhook_delivery` per K2.a), demonstrating end-to-end
cross-plugin contribution.

The rendered Setup nav is identical to the former static artifact — just
assembled from its owners. A disabled/absent capability contributes nothing and
its slot stays empty (in addition to the existing `requiresObject` gating).
This unblocks moving each remaining K2 domain's menu out of the monolith with
its objects.
14 changes: 14 additions & 0 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,20 @@ export class ObjectQL implements IDataEngine {
this.logger.debug('Registered manifest-as-app', { app: manifest.name, from: id });
}

// 4b. Register navigation contributions (ADR-0029 D7) — nav items this
// package injects into apps owned by other packages (e.g. a
// capability plugin adding its menu into the `setup` app). Merged
// into the target app's navigation on read by group id + priority.
if (Array.isArray((manifest as any).navigationContributions) && (manifest as any).navigationContributions.length > 0) {
for (const contribution of (manifest as any).navigationContributions) {
this._registry.registerAppNavContribution(contribution, id);
}
this.logger.debug('Registered navigation contributions', {
from: id,
count: (manifest as any).navigationContributions.length,
});
}

// 5. Register all other metadata types generically
const metadataArrayKeys = [
// UI Protocol
Expand Down
106 changes: 106 additions & 0 deletions packages/objectql/src/registry-nav-contributions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach } from 'vitest';
import { SchemaRegistry } from './registry';

/**
* ADR-0029 D7 — app navigation contributions.
*
* A "shell" app exposes empty group anchors; packages contribute their nav
* entries into those groups, merged on read by group id + priority. This is
* the UI-layer analog of object `own`/`extend`.
*/
describe('SchemaRegistry navigation contributions (ADR-0029 D7)', () => {
let registry: SchemaRegistry;

const shellApp = () => ({
name: 'setup',
label: 'Setup',
navigation: [
{ id: 'group_people_org', type: 'group', label: 'People & Organization', children: [] },
{ id: 'group_integrations', type: 'group', label: 'Integrations', children: [] },
],
});

beforeEach(() => {
registry = new SchemaRegistry({ multiTenant: false });
registry.registerApp(shellApp(), 'com.objectstack.platform-objects');
});

it('merges a contribution into the targeted group', () => {
registry.registerAppNavContribution(
{
app: 'setup',
group: 'group_integrations',
priority: 100,
items: [
{ id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook', requiresObject: 'sys_webhook' },
],
},
'com.objectstack.plugin-webhook-outbox.schema',
);

const app = registry.getApp('setup');
const group = app.navigation.find((n: any) => n.id === 'group_integrations');
expect(group.children).toHaveLength(1);
expect(group.children[0].objectName).toBe('sys_webhook');
// Untargeted group stays empty.
const other = app.navigation.find((n: any) => n.id === 'group_people_org');
expect(other.children).toHaveLength(0);
});

it('orders contributions to the same group by ascending priority', () => {
registry.registerAppNavContribution(
{ app: 'setup', group: 'group_people_org', priority: 200, items: [{ id: 'nav_b', type: 'object', label: 'B', objectName: 'sys_b' }] },
'pkg.late',
);
registry.registerAppNavContribution(
{ app: 'setup', group: 'group_people_org', priority: 100, items: [{ id: 'nav_a', type: 'object', label: 'A', objectName: 'sys_a' }] },
'pkg.early',
);

const app = registry.getApp('setup');
const group = app.navigation.find((n: any) => n.id === 'group_people_org');
expect(group.children.map((c: any) => c.id)).toEqual(['nav_a', 'nav_b']);
});

it('appends at the top level when the target group is missing', () => {
registry.registerAppNavContribution(
{ app: 'setup', group: 'group_does_not_exist', priority: 100, items: [{ id: 'nav_orphan', type: 'url', label: 'Orphan', url: '/x' }] },
'pkg.orphan',
);
const app = registry.getApp('setup');
expect(app.navigation.some((n: any) => n.id === 'nav_orphan')).toBe(true);
});

it('does not mutate the stored app — reads are idempotent', () => {
registry.registerAppNavContribution(
{ app: 'setup', group: 'group_integrations', priority: 100, items: [{ id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook' }] },
'pkg.webhooks',
);
const first = registry.getApp('setup');
const second = registry.getApp('setup');
const firstCount = first.navigation.find((n: any) => n.id === 'group_integrations').children.length;
const secondCount = second.navigation.find((n: any) => n.id === 'group_integrations').children.length;
expect(firstCount).toBe(1);
expect(secondCount).toBe(1); // not 2 — contributions are not appended cumulatively
});

it('returns the un-merged app when there are no contributions', () => {
const app = registry.getApp('setup');
for (const group of app.navigation) {
expect(group.children).toHaveLength(0);
}
});

it('applies contributions through getAllApps too', () => {
registry.registerAppNavContribution(
{ app: 'setup', group: 'group_integrations', priority: 100, items: [{ id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook' }] },
'pkg.webhooks',
);
const apps = registry.getAllApps();
const setup = apps.find((a: any) => a.name === 'setup');
const group = setup.navigation.find((n: any) => n.id === 'group_integrations');
expect(group.children).toHaveLength(1);
});
});
101 changes: 99 additions & 2 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,15 @@ export class SchemaRegistry {
/** Type → Name/ID → MetadataItem */
private metadata = new Map<string, Map<string, any>>();

/**
* App name → navigation contributions (ADR-0029 D7).
*
* Lets packages inject nav items into apps they do not own (the UI analog
* of object extenders). Merged into the owning app's `navigation` tree on
* read in {@link getApp} / {@link getAllApps} by group id + priority.
*/
private appNavContributions = new Map<string, Array<{ packageId?: string; group?: string; priority: number; items: any[] }>>();

/**
* Package ids that must be installed in a DISABLED state. Seeded once at
* boot (from persisted state) BEFORE any package registration so that every
Expand Down Expand Up @@ -952,11 +961,98 @@ export class SchemaRegistry {
}

getApp(name: string): any {
return this.getItem('app', name);
const app = this.getItem('app', name);
if (!app) return app;
return this.applyNavContributions(app);
}

getAllApps(): any[] {
return this.listItems('app');
return this.listItems('app').map((app: any) => this.applyNavContributions(app));
}

// ==========================================
// App navigation contributions (ADR-0029 D7)
// ==========================================

/**
* Register a navigation contribution — a package injecting nav items into
* an app it does not own (the UI-layer analog of object `extend`).
*
* Contributions are merged into the target app's `navigation` tree lazily
* on read ({@link getApp} / {@link getAllApps}) by group id + priority, so
* registration order does not matter and the owning app can be registered
* before or after its contributors.
*/
registerAppNavContribution(
contribution: { app: string; group?: string; priority?: number; items?: any[] },
packageId?: string,
): void {
if (!contribution || !contribution.app) return;
const list = this.appNavContributions.get(contribution.app) ?? [];
list.push({
packageId,
group: contribution.group,
priority: contribution.priority ?? 200,
items: Array.isArray(contribution.items) ? contribution.items : [],
});
this.appNavContributions.set(contribution.app, list);
this.log(
`[Registry] Navigation contribution: ${packageId ?? '(unknown)'} -> ${contribution.app}` +
(contribution.group ? `/${contribution.group}` : '') +
` (${list[list.length - 1].items.length} items)`,
);
}

/** Contributions registered for an app (empty array when none). */
getAppNavContributions(appName: string): Array<{ packageId?: string; group?: string; priority: number; items: any[] }> {
return this.appNavContributions.get(appName) ?? [];
}

/**
* 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.
*/
private applyNavContributions(app: any): any {
const contributions = this.appNavContributions.get(app?.name);
if (!contributions || contributions.length === 0) return app;

const cloned = structuredClone(app);
const nav: any[] = Array.isArray(cloned.navigation) ? cloned.navigation : (cloned.navigation = []);

// Lower priority applied first — mirrors object extender ordering.
const sorted = [...contributions].sort((a, b) => a.priority - b.priority);
for (const c of sorted) {
if (!c.items.length) continue;
if (c.group) {
const group = this.findNavGroup(nav, c.group);
if (group) {
if (!Array.isArray(group.children)) group.children = [];
group.children.push(...c.items);
} else {
this.log(
`[Registry] Navigation contribution from "${c.packageId ?? '(unknown)'}" targets ` +
`missing group "${c.group}" in app "${app.name}" — appending at top level.`,
);
nav.push(...c.items);
}
} else {
nav.push(...c.items);
}
}
return cloned;
}

/** Depth-first search for a `type: 'group'` nav item by id. */
private findNavGroup(items: any[], groupId: string): any | undefined {
for (const item of items) {
if (item && item.id === groupId && item.type === 'group') return item;
if (item && Array.isArray(item.children)) {
const found = this.findNavGroup(item.children, groupId);
if (found) return found;
}
}
return undefined;
}

// ==========================================
Expand Down Expand Up @@ -1026,6 +1122,7 @@ export class SchemaRegistry {
this.mergedObjectCache.clear();
this.namespaceRegistry.clear();
this.metadata.clear();
this.appNavContributions.clear();
this.log('[Registry] Reset complete');
}
}
1 change: 1 addition & 0 deletions packages/platform-objects/src/apps/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

export { SETUP_APP } from './setup.app.js';
export { SETUP_NAV_CONTRIBUTIONS } from './setup-nav.contributions.js';
export { STUDIO_APP } from './studio.app.js';
export { ACCOUNT_APP } from './account.app.js';
export * from './dashboards/index.js';
Expand Down
Loading