Skip to content

Commit 59ad75e

Browse files
os-zhuangclaude
andcommitted
feat(adr-0006): Phase 2 — generic resolveKernel seam on HttpDispatcher
ADR-0006 Phase 2 (引入通用接缝). Adds an optional, host-injected `KernelResolver` to the framework dispatcher so per-request kernel selection can move out of the framework and into the cloud distribution (Phase 5). Additive + behavior-equivalent: - `HttpDispatcherOptions.kernelResolver?: KernelResolver` (also picked up from the `kernel-resolver` service, mirroring `kernel-manager`). - In `dispatch()` it TAKES PRECEDENCE over `kernelManager` for primary routing, but the two COEXIST — `kernelManager` still serves the action-route service path until Phase 5 retires in-dispatcher resolution. - Returning `undefined` routes to the single `defaultKernel` (single-environment / control-plane / unscoped requests). - `KernelResolver` exported as a RETAINED generic contract (ADR D3) — the framework ships no multi-tenant implementation. Back-compat both ways: a host that injects neither is unchanged; a host on an older framework that registers a `kernel-resolver` service is ignored. New focused test (http-dispatcher.kernel-resolver.test.ts): precedence over kernelManager, undefined→defaultKernel fallback, legacy back-compat. Runtime build (incl. DTS) + tests 384 (was 381) green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3291633 commit 59ad75e

3 files changed

Lines changed: 139 additions & 1 deletion

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0006 Phase 2 — the generic `kernelResolver` seam.
5+
*
6+
* The dispatcher gains an optional host-injected resolver that owns
7+
* per-request kernel selection. It coexists with the legacy `kernelManager`
8+
* path and TAKES PRECEDENCE over it for primary routing. The framework ships
9+
* no multi-tenant resolver — these tests pin the seam contract (precedence +
10+
* undefined → defaultKernel fallback) that cloud's resolver plugs into.
11+
*/
12+
13+
import { describe, it, expect, vi } from 'vitest';
14+
import { HttpDispatcher } from './http-dispatcher.js';
15+
import type { KernelResolver } from './http-dispatcher.js';
16+
17+
/** Minimal kernel whose objectql records which instance served the request. */
18+
function makeKernel(tag: string) {
19+
const objectql = {
20+
find: vi.fn().mockResolvedValue([]),
21+
getObjects: vi.fn().mockReturnValue({}),
22+
registry: { getObject: vi.fn().mockReturnValue(null), getRegisteredTypes: vi.fn().mockReturnValue([]) },
23+
__tag: tag,
24+
};
25+
const kernel: any = {
26+
__tag: tag,
27+
getService: (name: string) => (name === 'objectql' ? objectql : null),
28+
getServiceAsync: async (name: string) => (name === 'objectql' ? objectql : null),
29+
context: { getService: (name: string) => (name === 'objectql' ? objectql : null) },
30+
};
31+
return kernel;
32+
}
33+
34+
describe('HttpDispatcher — ADR-0006 kernelResolver seam', () => {
35+
it('prefers the injected kernelResolver over kernelManager for primary routing', async () => {
36+
const defaultKernel = makeKernel('default');
37+
const envKernel = makeKernel('env');
38+
39+
const resolveKernel = vi.fn(async () => envKernel);
40+
const kernelResolver: KernelResolver = { resolveKernel };
41+
const getOrCreate = vi.fn(async () => envKernel);
42+
43+
const dispatcher = new HttpDispatcher(defaultKernel, undefined, {
44+
kernelResolver,
45+
kernelManager: { getOrCreate } as any,
46+
enforceProjectMembership: false,
47+
});
48+
49+
await dispatcher.dispatch('GET', '/data/widget', undefined, {}, { request: {} } as any);
50+
51+
// The seam was consulted exactly once, with (context, defaultKernel)…
52+
expect(resolveKernel).toHaveBeenCalledTimes(1);
53+
expect(resolveKernel.mock.calls[0][1]).toBe(defaultKernel);
54+
// …and it WON: the legacy kernelManager path was not used for routing.
55+
expect(getOrCreate).not.toHaveBeenCalled();
56+
});
57+
58+
it('falls back to defaultKernel when the resolver returns undefined', async () => {
59+
const defaultKernel = makeKernel('default');
60+
const resolveKernel = vi.fn(async () => undefined);
61+
62+
const dispatcher = new HttpDispatcher(defaultKernel, undefined, {
63+
kernelResolver: { resolveKernel },
64+
enforceProjectMembership: false,
65+
});
66+
67+
// Should not throw — undefined routes to the single defaultKernel.
68+
const result = await dispatcher.dispatch('GET', '/data/widget', undefined, {}, { request: {} } as any);
69+
expect(resolveKernel).toHaveBeenCalledTimes(1);
70+
expect(result).toBeDefined();
71+
});
72+
73+
it('uses the legacy kernelManager path when no resolver is injected (back-compat)', async () => {
74+
const defaultKernel = makeKernel('default');
75+
const envKernel = makeKernel('env');
76+
const getOrCreate = vi.fn(async () => envKernel);
77+
78+
const dispatcher = new HttpDispatcher(defaultKernel, undefined, {
79+
kernelManager: { getOrCreate } as any,
80+
enforceProjectMembership: false,
81+
});
82+
83+
// Force a resolved environmentId so the kernelManager branch is taken.
84+
await dispatcher.dispatch('GET', '/data/widget', undefined, {}, {
85+
request: {},
86+
environmentId: 'env_123',
87+
} as any);
88+
89+
expect(getOrCreate).toHaveBeenCalledWith('env_123');
90+
});
91+
});

packages/runtime/src/http-dispatcher.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,45 @@ export interface HttpDispatcherResult {
5252
result?: any; // For flexible return types or direct response objects (Response/NextResponse)
5353
}
5454

55+
/**
56+
* ADR-0006 generic kernel-resolution seam.
57+
*
58+
* A host (e.g. ObjectStack Cloud) injects a resolver to own per-request
59+
* kernel selection. The framework ships NO multi-tenant implementation — all
60+
* hostname→env strategy, the per-env kernel cache, and the control plane live
61+
* in the host distribution (`@objectstack/objectos-runtime`). When no resolver
62+
* is injected the dispatcher serves every request from its single
63+
* `defaultKernel` (single-environment mode).
64+
*
65+
* Returning `undefined` routes the request to `defaultKernel` — resolvers use
66+
* this for control-plane / unscoped / single-environment requests. This is the
67+
* generic contract that, in ADR-0006 Phase 5, fully replaces the dispatcher's
68+
* built-in `kernelManager` + hostname resolution; in Phase 2 it coexists with
69+
* them (it merely takes precedence for primary request routing).
70+
*/
71+
export interface KernelResolver {
72+
resolveKernel(
73+
context: HttpProtocolContext,
74+
defaultKernel: ObjectKernel,
75+
): Promise<ObjectKernel | undefined> | ObjectKernel | undefined;
76+
}
77+
5578
/**
5679
* Optional configuration passed to the dispatcher constructor. Supports the
5780
* legacy `enforceProjectMembership` toggle plus the new multi-kernel
5881
* scheduling hook required by ADR-0003's cloud runtime mode.
5982
*/
6083
export interface HttpDispatcherOptions {
6184
enforceProjectMembership?: boolean;
85+
/**
86+
* Optional generic kernel-resolution seam (ADR-0006). When present it
87+
* TAKES PRECEDENCE over `kernelManager` for primary request routing: after
88+
* the dispatcher resolves `context.environmentId`, it delegates per-request
89+
* kernel selection to the resolver. Coexists with `kernelManager` (still
90+
* used by action-route service resolution) until ADR-0006 Phase 5 retires
91+
* the in-dispatcher resolution. Falls back to `resolveService('kernel-resolver')`.
92+
*/
93+
kernelResolver?: KernelResolver;
6294
/**
6395
* Optional {@link KernelManager}. When present, the dispatcher resolves
6496
* `context.environmentId` first and then routes the request against the
@@ -88,6 +120,7 @@ export class HttpDispatcher {
88120
private envRegistry?: any; // EnvironmentDriverRegistry
89121
private defaultProject?: { environmentId: string; orgId?: string };
90122
private kernelManager?: KernelManager;
123+
private kernelResolver?: KernelResolver;
91124
private scopeManager?: EnvironmentScopeManager;
92125
/**
93126
* When `true`, scoped data-plane routes enforce a
@@ -119,6 +152,10 @@ export class HttpDispatcher {
119152
this.envRegistry = envRegistry ?? resolveService('env-registry');
120153
this.enforceMembership = options?.enforceProjectMembership ?? true;
121154
this.kernelManager = options?.kernelManager ?? resolveService('kernel-manager');
155+
// ADR-0006 Phase 2 seam — host-injected kernel resolver (preferred over
156+
// kernelManager for primary routing when present). Optional service so
157+
// single-environment / legacy hosts that register neither are unchanged.
158+
this.kernelResolver = options?.kernelResolver ?? resolveService('kernel-resolver');
122159
this.scopeManager = options?.scopeManager ?? resolveService('scope-manager');
123160
// Single-project default is resolved lazily on first request — the
124161
// plugin that registers it (`createSingleEnvironmentPlugin`) may run
@@ -2806,7 +2843,14 @@ export class HttpDispatcher {
28062843
// Reserved virtual id 'platform' addresses the control plane through
28072844
// the regular project URL family — never spin up a per-project kernel
28082845
// for it (there is no projects row to look up).
2809-
if (this.kernelManager && context.environmentId && context.environmentId !== 'platform') {
2846+
if (this.kernelResolver) {
2847+
// ADR-0006 Phase 2 seam: the host owns kernel selection. The
2848+
// resolver returns `defaultKernel` (or undefined) for platform /
2849+
// unscoped / single-env requests, so this branch is
2850+
// behavior-equivalent to the kernelManager path below for the
2851+
// cloud resolver (which delegates to the same KernelManager).
2852+
this.kernel = (await this.kernelResolver.resolveKernel(context, this.defaultKernel)) ?? this.defaultKernel;
2853+
} else if (this.kernelManager && context.environmentId && context.environmentId !== 'platform') {
28102854
this.kernel = await this.kernelManager.getOrCreate(context.environmentId);
28112855
} else {
28122856
this.kernel = this.defaultKernel;

packages/runtime/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ export type { SystemEnvironmentPluginConfig } from './system-environment-plugin.
3434
export { HttpServer } from './http-server.js';
3535
export { HttpDispatcher } from './http-dispatcher.js';
3636
export type { HttpProtocolContext, HttpDispatcherResult } from './http-dispatcher.js';
37+
// ADR-0006 generic kernel-resolution seam (retained framework contract; the
38+
// multi-tenant implementation lives in cloud `@objectstack/objectos-runtime`).
39+
export type { KernelResolver } from './http-dispatcher.js';
3740
export { MiddlewareManager } from './middleware.js';
3841

3942
// ── Security primitives ───────────────────────────────────────────────

0 commit comments

Comments
 (0)