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
4 changes: 3 additions & 1 deletion docs/adr/0076-objectql-core-tiering.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ import { SecurityPlugin } from '@objectstack/plugin-security';
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.)
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.
8. **Per-domain versioning** — once segmented, do capability protocols get independent version markers / a `getCapabilities()` discovery method?
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.
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.
- **(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).
- **(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.
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.
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`.
14 changes: 14 additions & 0 deletions packages/core/src/kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,20 @@ export class ObjectKernel {
return new Map(this.pluginStartTimes);
}

/**
* Whether a plugin with the given name has been registered on this kernel.
*
* Registration happens synchronously in `use()` before any plugin's
* `start()` runs, so a plugin may use this during its own start() to make
* composition-dependent decisions deterministically — e.g. the dispatcher
* bridge cedes `${prefix}/discovery` to `com.objectstack.rest.api` when
* both are mounted (ADR-0076 D11: single owner per route, not
* first-registration-wins).
*/
hasPlugin(name: string): boolean {
return this.plugins.has(name);
}

/**
* Get a service (sync helper)
*/
Expand Down
18 changes: 18 additions & 0 deletions packages/runtime/src/dispatcher-plugin.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,22 @@ describe('createDispatcherPlugin — HTTP route registration', () => {
expect(headers['Cache-Control'], `${route} Cache-Control`).toBe('no-store');
}
});

// ADR-0076 D11 / OQ#9 — single owner for ${prefix}/discovery. When the REST
// plugin is registered on the same kernel it serves /api/v1/discovery itself;
// which payload a client saw used to depend on plugin start order
// (first-registration-wins). The bridge must cede the route deterministically
// and keep /.well-known/objectstack (dispatcher-owned, no other registrant).
it('cedes /api/v1/discovery to the REST plugin when it is registered', async () => {
const { server, routes } = makeFakeServer();
const ctx = makeCtx(server);
ctx.getKernel().hasPlugin = (name: string) => name === 'com.objectstack.rest.api';
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
await plugin.start?.(ctx);

expect(routes).not.toContain('GET /api/v1/discovery');
expect(routes).toContain('GET /.well-known/objectstack');
// Non-discovery routes are unaffected by the cession.
expect(routes).toContain('GET /api/v1/packages');
});
});
Loading