Skip to content

Commit 09b18f5

Browse files
os-zhuangclaude
andauthored
feat(runtime): single owner for /discovery + one pipeline for /packages (ADR-0076 D11 step 2) (#3142)
OQ#9 flagged discovery and packages as the two genuinely double-mounted domains between the REST server and the dispatcher bridge, with first-registration-wins deciding which implementation a client saw. - kernel: new public hasPlugin(name) — plugins can make composition- dependent decisions deterministically during their own start(). - dispatcher bridge: cede ${prefix}/discovery to com.objectstack.rest.api when that plugin is registered; keep it only as the fallback owner in REST-less compositions. /.well-known/objectstack stays dispatcher-owned (no other registrant). - dispatcher bridge: the /packages route family now flows through dispatch() (like analytics/i18n/automation/AI) instead of calling handlePackages() directly, so the explicit mounts and the cloud hosts' @objectstack/hono catch-all share one per-request pipeline (kernel resolution, identity, auth gate). The direct path also dropped req.query on several routes — the documented ?overwrite=true install flag never reached the handler over the mounted routes. - ADR-0076 OQ#9: record step 2 as shipped and re-scope step 1 with the cross-repo finding — the 'dead' dispatch() branches are the cloud hosts' fallback fabric via the createHonoApp catch-all (/share-links is the designed primary surface for per-env kernels; /notifications has no other HTTP owner), so killing them waits for per-domain handlers (step 3). Refs #2462 Claude-Session: https://claude.ai/code/session_01A1BtxNeUaGmvUYr8FwtUNu Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6cc73e3 commit 09b18f5

4 files changed

Lines changed: 112 additions & 179 deletions

File tree

docs/adr/0076-objectql-core-tiering.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ import { SecurityPlugin } from '@objectstack/plugin-security';
204204
6. **Data-facade home** — does the `DataProtocol` impl live in the engine-adjacent transport layer / `rest`, or a small `@objectstack/protocol-data`? (It is thin and transport-shaped.)
205205
7. **Metadata package name (post-segmentation)****Resolved: keep `@objectstack/metadata-protocol`** (already published; renaming has ~0 benefit and real churn). The `protocol` suffix is a low-cost naming exception — the contract is in `@objectstack/spec/api`; a README note in the package should clarify impl-vs-contract.
206206
8. **Per-domain versioning** — once segmented, do capability protocols get independent version markers / a `getCapabilities()` discovery method?
207-
9. **dispatcher vs rest-server overlap****Resolved (audit, #2462): delineate, do not merge.** They partition the API surface by domain rather than duplicating it: in a standard boot REST owns data/meta/batch/import-export/search/email/forms/sharing/reports/approvals/security, the dispatcher bridge owns mcp/ai/graphql/keys/automation/actions/storage/i18n/analytics/health/ready. Only **discovery** and **packages** are genuinely double-mounted (first-registration-wins — the D11 smell to fix), and the dispatcher's `/data`/`/meta`/`/ui`/`/notifications`/`/share-links`/`/security` `dispatch()` branches are **not mounted** in a standard boot (they are live only when `HttpDispatcher` is used directly as a callable by out-of-tree adapters). D11 worklist: (1) kill or delegate the dead callable branches; (2) give packages + discovery a single owner; (3) extract per-domain handlers; (4) unify env-resolution on the injected `KernelResolver` seam.
207+
9. **dispatcher vs rest-server overlap****Resolved (audit, #2462): delineate, do not merge.** They partition the API surface by domain rather than duplicating it: in a standard boot REST owns data/meta/batch/import-export/search/email/forms/sharing/reports/approvals/security, the dispatcher bridge owns mcp/ai/graphql/keys/automation/actions/storage/i18n/analytics/health/ready. Only **discovery** and **packages** were genuinely double-mounted (first-registration-wins — the D11 smell), and the dispatcher's `/data`/`/meta`/`/ui`/`/notifications`/`/share-links`/`/security` `dispatch()` branches are **not mounted** in a standard boot. D11 worklist: (1) kill or delegate the dead callable branches; (2) give packages + discovery a single owner; (3) extract per-domain handlers; (4) unify env-resolution on the injected `KernelResolver` seam.
208+
- **(2) shipped**: `${prefix}/discovery` has a single deterministic owner — the dispatcher bridge cedes it to `com.objectstack.rest.api` when that plugin is registered (`kernel.hasPlugin`, new public API), keeping it only as the fallback in REST-less compositions; `/.well-known/objectstack` stays dispatcher-owned. The bridge's `/packages` family now flows through `dispatch()` (like analytics/i18n/automation/AI) instead of calling `handlePackages()` directly, so both HTTP entries into the domain (explicit mounts + the cloud hosts' catch-all) share one pipeline — the direct path used to skip kernel/identity resolution and drop `req.query` (the documented `?overwrite=true` install flag never worked over the mounted routes).
209+
- **(1) re-scoped (cross-repo finding)**: the "dead" branches are NOT safely killable — the `@objectstack/hono` `createHonoApp()` catch-all delegates every unmatched `${prefix}/*` to `dispatch()`, and both cloud hosts mount it under their plugin routes. In that composition `/share-links` is the **designed primary surface** (per-env kernels register the `shareLinks` service with `registerShareLinkRoutes:false`; the host dispatcher serves the route after kernel swap — documented in cloud's `artifact-kernel-factory` and `default-environment-plugins`), and `/notifications` has **no other HTTP owner anywhere**. `/data`/`/meta`/`/ui`/`/security` are REST-shadowed but still catch REST misses. Killing therefore waits until the catch-all is retired in favor of per-domain handlers (step 3); until then the branches are the cloud fallback fabric, not dead code.
208210
10. **Validate multi-adapter****Resolved (validated, #2462): the port is free of hard Hono-isms.** A zero-dependency `node:http` reference adapter (`@objectstack/http-conformance`, private QA gate under `packages/qa/`) runs the dispatcher bridge + REST generator unchanged; a cross-adapter conformance suite (40 assertions incl. `/data` CRUD roundtrip, `:param` routing, 404/405 semantics, SSE streaming, discovery) passes identically on both adapters. Findings: the Hono adapter's Host-header backfill is adapter-local (a Fetch-API artifact, not a port leak); all remaining Hono coupling is confined to the `getRawApp()` escape hatch (metadata HMR, cloud-connection/marketplace routes, static/SPA + CORS + Server-Timing), whose consumers feature-detect and degrade. Follow-up for D11: codify the two soft extensions consumers already rely on (`res.write`/`res.end` for SSE, `getPort()`) and 404/405 semantics into the `IHttpServer` contract.
209211
11. **D12 stub marker****Resolved (#2462): standard `__serviceInfo` self-descriptor** (`ServiceSelfInfoSchema` in `spec/api/discovery.zod.ts`, read via `readServiceSelfInfo()`), with plugin-dev's legacy `_dev: true` normalized to `{ status: 'stub', handlerReady: false }`. Both discovery builders honor it; the analytics fallback self-identifies as `degraded`.

packages/core/src/kernel.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,20 @@ export class ObjectKernel {
461461
return new Map(this.pluginStartTimes);
462462
}
463463

464+
/**
465+
* Whether a plugin with the given name has been registered on this kernel.
466+
*
467+
* Registration happens synchronously in `use()` before any plugin's
468+
* `start()` runs, so a plugin may use this during its own start() to make
469+
* composition-dependent decisions deterministically — e.g. the dispatcher
470+
* bridge cedes `${prefix}/discovery` to `com.objectstack.rest.api` when
471+
* both are mounted (ADR-0076 D11: single owner per route, not
472+
* first-registration-wins).
473+
*/
474+
hasPlugin(name: string): boolean {
475+
return this.plugins.has(name);
476+
}
477+
464478
/**
465479
* Get a service (sync helper)
466480
*/

packages/runtime/src/dispatcher-plugin.routes.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,22 @@ describe('createDispatcherPlugin — HTTP route registration', () => {
126126
expect(headers['Cache-Control'], `${route} Cache-Control`).toBe('no-store');
127127
}
128128
});
129+
130+
// ADR-0076 D11 / OQ#9 — single owner for ${prefix}/discovery. When the REST
131+
// plugin is registered on the same kernel it serves /api/v1/discovery itself;
132+
// which payload a client saw used to depend on plugin start order
133+
// (first-registration-wins). The bridge must cede the route deterministically
134+
// and keep /.well-known/objectstack (dispatcher-owned, no other registrant).
135+
it('cedes /api/v1/discovery to the REST plugin when it is registered', async () => {
136+
const { server, routes } = makeFakeServer();
137+
const ctx = makeCtx(server);
138+
ctx.getKernel().hasPlugin = (name: string) => name === 'com.objectstack.rest.api';
139+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
140+
await plugin.start?.(ctx);
141+
142+
expect(routes).not.toContain('GET /api/v1/discovery');
143+
expect(routes).toContain('GET /.well-known/objectstack');
144+
// Non-discovery routes are unaffected by the cession.
145+
expect(routes).toContain('GET /api/v1/packages');
146+
});
129147
});

0 commit comments

Comments
 (0)