Skip to content

Commit 8f124a7

Browse files
os-zhuangclaude
andauthored
feat(runtime): extract first four dispatcher domain bodies into domains/ modules — ADR-0076 D11 step ③ PR-2 (#2462) (#3507)
Moves the /analytics, /i18n, /notifications and /security handler bodies out of the HttpDispatcher god class into per-domain modules under src/domains/, each running against the explicit DomainHandlerDeps contract (resolveService/getService/success/error — the whole dispatcher surface a domain may touch, made visible). Thin handleXxx delegates stay for direct callers; /notifications + /security leave the legacy if-chain for the registry, with new match:'segment' preserving their `=== p || startsWith(p+'/')` branch shape exactly. Key call: registration stays dispatcher-owned. The original plan said "move handlers to their owning service package", but most slots are multi-provider — i18n is served by I18nServicePlugin OR AppPlugin's in-memory fallback (app-plugin.ts auto-registers it for stacks declaring translation bundles); analytics by service-analytics OR the ObjectQLPlugin fallback. A route is the bridge to a SLOT, not the property of one provider — registration moving into one provider would 404 the others. Packages that DO own a slot exclusively can still self-register via registerDomainHandler(). Verified: seam suite 18 tests; runtime 617 green; http-conformance 41 cross-adapter assertions green; 25-package dependent closure builds with DTS (--force). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ad4af62 commit 8f124a7

9 files changed

Lines changed: 528 additions & 235 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
feat(runtime): extract the first four dispatcher domain bodies into `domains/` modules — ADR-0076 D11 step ③, PR-2 (#2462)
6+
7+
The `/analytics`, `/i18n`, `/notifications` and `/security` handler bodies
8+
move out of the `HttpDispatcher` god class into per-domain modules under
9+
`packages/runtime/src/domains/`, running against an explicit
10+
`DomainHandlerDeps` contract (resolveService / getService / success / error —
11+
the WHOLE dispatcher surface a domain may touch). The dispatcher keeps thin
12+
`handleXxx` delegates for direct callers, and `/notifications` + `/security`
13+
leave the legacy if-chain for the domain registry (new `match: 'segment'`
14+
preserves their `=== p || startsWith(p + '/')` branch shape exactly).
15+
16+
Route registration stays dispatcher-owned on purpose: most service slots are
17+
multi-provider (i18n = I18nServicePlugin OR the AppPlugin in-memory fallback;
18+
analytics = service-analytics OR the ObjectQLPlugin fallback), so a route is
19+
the bridge to a SLOT, not the property of any one providing package. Zero
20+
behavior change — http-conformance (41 cross-adapter assertions) and the
21+
seam suite (18 tests) lock it.

packages/runtime/src/domain-handler-registry.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,79 @@ describe('HttpDispatcher domain registry (D11 step ③)', () => {
152152
expect(result.response?.body?.echo).toBe('/custom-domain/thing');
153153
});
154154
});
155+
156+
// ---------------------------------------------------------------------------
157+
// PR-2 — segment matching + extracted notification/security domains
158+
// ---------------------------------------------------------------------------
159+
160+
describe('DomainHandlerRegistry segment matching (PR-2)', () => {
161+
it("match: 'segment' claims the prefix and slash-separated sub-paths, but not lexical extensions", () => {
162+
const registry = new DomainHandlerRegistry();
163+
registry.register({ prefix: '/security', match: 'segment', handler: okHandler('s') });
164+
expect(registry.resolve('/security', 'GET')).toBeDefined();
165+
expect(registry.resolve('/security/suggested-bindings', 'GET')).toBeDefined();
166+
// The legacy `=== p || startsWith(p + '/')` shape: '/securityfoo' is NOT claimed.
167+
expect(registry.resolve('/securityfoo', 'GET')).toBeUndefined();
168+
});
169+
});
170+
171+
describe('HttpDispatcher extracted domains (PR-2)', () => {
172+
it('/notifications requires an authenticated user (401) when the service is wired', async () => {
173+
const notification = { listInbox: vi.fn().mockResolvedValue([]), markRead: vi.fn(), markAllRead: vi.fn() };
174+
const result = await makeDispatcher({ notification }).dispatch('GET', '/notifications', undefined, {}, {} as any);
175+
expect(result.handled).toBe(true);
176+
expect(result.response?.status).toBe(401);
177+
});
178+
179+
it('/notifications lists the inbox for an authenticated user (thin delegate carries the extracted body)', async () => {
180+
const notification = { listInbox: vi.fn().mockResolvedValue([{ id: 'n1' }]), markRead: vi.fn(), markAllRead: vi.fn() };
181+
// Call the public delegate directly: dispatch() re-resolves identity
182+
// from the (mock, auth-less) kernel and would overwrite the seeded
183+
// executionContext with an anonymous one.
184+
const context: any = { executionContext: { userId: 'u1' } };
185+
const result = await makeDispatcher({ notification }).handleNotification('', 'GET', undefined, { limit: '5' }, context);
186+
expect(result.response?.status).toBe(200);
187+
expect(notification.listInbox).toHaveBeenCalledWith('u1', expect.objectContaining({ limit: 5 }));
188+
});
189+
190+
it('/security responds 503 when no security service is wired (legacy in-handler semantics)', async () => {
191+
const result = await makeDispatcher().dispatch('GET', '/security/suggested-bindings', undefined, {}, {} as any);
192+
expect(result.handled).toBe(true);
193+
expect(result.response?.status).toBe(503);
194+
});
195+
196+
it('/security denies anonymous callers unconditionally when the service is wired', async () => {
197+
const security = {
198+
listAudienceBindingSuggestions: vi.fn().mockResolvedValue([]),
199+
confirmAudienceBindingSuggestion: vi.fn(),
200+
dismissAudienceBindingSuggestion: vi.fn(),
201+
};
202+
const result = await makeDispatcher({ security }).dispatch('GET', '/security/suggested-bindings', undefined, {}, {} as any);
203+
expect(result.handled).toBe(true);
204+
expect(result.response?.status).toBe(401);
205+
expect(security.listAudienceBindingSuggestions).not.toHaveBeenCalled();
206+
});
207+
208+
it('/security lists suggestions for an authenticated caller (thin delegate carries the extracted body)', async () => {
209+
const security = {
210+
listAudienceBindingSuggestions: vi.fn().mockResolvedValue([{ id: 's1' }]),
211+
confirmAudienceBindingSuggestion: vi.fn(),
212+
dismissAudienceBindingSuggestion: vi.fn(),
213+
};
214+
// Direct delegate call for the same reason as the notifications case.
215+
const context: any = { executionContext: { userId: 'admin-1' } };
216+
const result = await makeDispatcher({ security }).handleSecurity('/suggested-bindings', 'GET', undefined, { status: 'open' }, context);
217+
expect(result.response?.status).toBe(200);
218+
expect(security.listAudienceBindingSuggestions).toHaveBeenCalledWith(
219+
expect.objectContaining({ userId: 'admin-1' }),
220+
expect.objectContaining({ status: 'open' }),
221+
);
222+
});
223+
224+
it('/securityfoo is NOT claimed by the security domain (segment semantics preserved from the if-chain)', async () => {
225+
const security = { listAudienceBindingSuggestions: vi.fn() };
226+
const result = await makeDispatcher({ security }).dispatch('GET', '/securityfoo', undefined, {}, {} as any);
227+
expect(security.listAudienceBindingSuggestions).not.toHaveBeenCalled();
228+
expect(result.response?.status ?? 404).not.toBe(200);
229+
});
230+
});

packages/runtime/src/domain-handler-registry.ts

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,21 @@
1111
* is the decomposition seam: `dispatch()` consults it FIRST, and domains are
1212
* migrated out of the if-chain one PR at a time.
1313
*
14-
* Migration discipline (registry first, code moves later, ownership last):
15-
* 1. This PR: the dispatcher wraps its existing `handleXxx` methods into
14+
* Migration discipline (registry first, then extract bodies):
15+
* 1. PR-1: the dispatcher wraps its existing `handleXxx` methods into
1616
* registry entries at construction time — same matching semantics, same
1717
* handler bodies, zero behavior change (locked by the http-conformance
1818
* cross-adapter suite).
19-
* 2. Follow-up PRs: a domain's handler body moves into its owning service
20-
* package, which registers the entry itself (via
21-
* {@link HttpDispatcher.registerDomainHandler}). Service-absence
22-
* semantics (today's in-handler 501 vs route-not-mounted 404) are decided
23-
* per-domain at THAT point, together with the D12 honest-capabilities
24-
* discovery contract.
19+
* 2. PR-2..N: each domain's handler BODY moves to a module under
20+
* `./domains/`, depending only on the explicit {@link DomainHandlerDeps}
21+
* contract. Registration stays dispatcher-owned on purpose: most service
22+
* slots are multi-provider (e.g. `i18n` is served by I18nServicePlugin
23+
* OR the AppPlugin in-memory fallback; `analytics` by service-analytics
24+
* OR the ObjectQLPlugin fallback), so a route is the bridge to a SLOT,
25+
* not the property of any one providing package — moving registration
26+
* into one provider would 404 the others. External packages that DO own
27+
* a slot exclusively can still self-register via
28+
* {@link HttpDispatcher.registerDomainHandler}.
2529
*
2630
* Matching semantics are deliberately faithful to the legacy if-chain,
2731
* INCLUDING its rough edges (`match: 'prefix'` on `/i18n` also matches
@@ -56,13 +60,32 @@ export interface DomainRoute {
5660
/**
5761
* `'prefix'` — legacy `startsWith(prefix)` semantics (default).
5862
* `'exact'` — the path must equal the prefix exactly.
63+
* `'segment'` — exact, or followed by `'/'` (the legacy
64+
* `=== p || startsWith(p + '/')` branch shape; does NOT claim `/i18nxx`).
5965
*/
60-
match?: 'prefix' | 'exact';
66+
match?: 'prefix' | 'exact' | 'segment';
6167
/** Restrict to these UPPERCASE HTTP methods. Omit = all methods. */
6268
methods?: string[];
6369
handler: DomainHandler;
6470
}
6571

72+
/**
73+
* The dispatcher facilities an extracted domain body is allowed to use — the
74+
* WHOLE dependency contract, made explicit. Growing this interface is a
75+
* design decision, not a convenience: every addition couples all domains to
76+
* more dispatcher surface.
77+
*/
78+
export interface DomainHandlerDeps {
79+
/** Environment-scoped service resolution (per-request kernel aware). */
80+
resolveService(name: string, environmentId?: string): any;
81+
/** Unscoped service lookup on the current kernel (may return a Promise). */
82+
getService(name: string): any;
83+
/** Standard success envelope. */
84+
success(data: any, meta?: any): { status: number; body: any };
85+
/** Standard error envelope. */
86+
error(message: string, code?: number, details?: any): { status: number; body: any };
87+
}
88+
6689
/**
6790
* First-match-wins routing table, in registration order. Kept deliberately
6891
* minimal — no wildcards, no params, no middleware: those belong to the real
@@ -83,13 +106,22 @@ export class DomainHandlerRegistry {
83106
const m = method.toUpperCase();
84107
for (const route of this.routes) {
85108
if (route.methods && !route.methods.includes(m)) continue;
86-
if (route.match === 'exact' ? path === route.prefix : path.startsWith(route.prefix)) {
87-
return route;
88-
}
109+
if (DomainHandlerRegistry.matches(route, path)) return route;
89110
}
90111
return undefined;
91112
}
92113

114+
private static matches(route: DomainRoute, path: string): boolean {
115+
switch (route.match) {
116+
case 'exact':
117+
return path === route.prefix;
118+
case 'segment':
119+
return path === route.prefix || path.startsWith(route.prefix + '/');
120+
default:
121+
return path.startsWith(route.prefix);
122+
}
123+
}
124+
93125
/** Registered routes, in match order (introspection / tests). */
94126
list(): readonly DomainRoute[] {
95127
return this.routes;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `/analytics` domain — extracted dispatcher body (ADR-0076 D11 step ③,
5+
* PR-2). Bridges to whatever provides the `analytics` service slot: the
6+
* service-analytics engine when installed, or the ObjectQLPlugin degraded
7+
* fallback otherwise (deliberate fallback + `replaceService`, see ADR-0076
8+
* D10/D12) — which is exactly why route registration stays dispatcher-owned.
9+
*/
10+
11+
import { CoreServiceName } from '@objectstack/spec/system';
12+
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
13+
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
14+
15+
export function createAnalyticsDomain(deps: DomainHandlerDeps): DomainRoute {
16+
return {
17+
prefix: '/analytics',
18+
handler: (req, context) =>
19+
handleAnalyticsRequest(deps, req.path.substring(10), req.method, req.body, context),
20+
};
21+
}
22+
23+
/** Body kept signature-compatible with the legacy `HttpDispatcher.handleAnalytics`. */
24+
export async function handleAnalyticsRequest(
25+
deps: DomainHandlerDeps,
26+
path: string,
27+
method: string,
28+
body: any,
29+
context: HttpProtocolContext,
30+
): Promise<HttpDispatcherResult> {
31+
const analyticsService = await deps.getService(CoreServiceName.enum.analytics);
32+
if (!analyticsService) return { handled: false }; // 404 handled by caller if unhandled
33+
34+
const m = method.toUpperCase();
35+
const subPath = path.replace(/^\/+/, '');
36+
37+
// POST /analytics/query
38+
if (subPath === 'query' && m === 'POST') {
39+
// [#2852] Pass the request's execution context so the analytics
40+
// service scopes each object by its per-object read filter (tenant +
41+
// RLS). Without it, `getReadScope(object, undefined)` returned no
42+
// filter and the query ran UNSCOPED — an authenticated caller saw
43+
// rows RLS would otherwise hide.
44+
const result = await analyticsService.query(body, context?.executionContext);
45+
return { handled: true, response: deps.success(result) };
46+
}
47+
48+
// GET /analytics/meta
49+
if (subPath === 'meta' && m === 'GET') {
50+
const result = await analyticsService.getMeta();
51+
return { handled: true, response: deps.success(result) };
52+
}
53+
54+
// POST /analytics/sql (Dry-run or debug)
55+
if (subPath === 'sql' && m === 'POST') {
56+
// [#2852] Scope the generated SQL to the caller too, so a preview
57+
// reflects the same per-object read filter the real query applies.
58+
const result = await analyticsService.generateSql(body, context?.executionContext);
59+
return { handled: true, response: deps.success(result) };
60+
}
61+
62+
return { handled: false };
63+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `/i18n` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-2).
5+
* Serves translations / locales / labels from whatever provides the `i18n`
6+
* service slot: I18nServicePlugin (service-i18n) when installed, or the
7+
* AppPlugin in-memory fallback auto-registered for stacks that declare
8+
* translation bundles — multi-provider slot, so route registration stays
9+
* dispatcher-owned (moving it into one provider would 404 the other).
10+
*
11+
* Routes (path is the sub-path after `/i18n`):
12+
* GET /locales → getLocales
13+
* GET /translations/:locale → getTranslations (locale from path)
14+
* GET /translations?locale=xx → getTranslations (locale from query)
15+
* GET /labels/:object/:locale → getFieldLabels (both from path)
16+
* GET /labels/:object?locale=xx → getFieldLabels (locale from query)
17+
*/
18+
19+
import { resolveLocale } from '@objectstack/core';
20+
import { CoreServiceName } from '@objectstack/spec/system';
21+
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
22+
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
23+
24+
export function createI18nDomain(deps: DomainHandlerDeps): DomainRoute {
25+
return {
26+
prefix: '/i18n',
27+
handler: (req, context) =>
28+
handleI18nRequest(deps, req.path.substring(5), req.method, req.query, context),
29+
};
30+
}
31+
32+
/** Body kept signature-compatible with the legacy `HttpDispatcher.handleI18n`. */
33+
export async function handleI18nRequest(
34+
deps: DomainHandlerDeps,
35+
path: string,
36+
method: string,
37+
query: any,
38+
_context: HttpProtocolContext,
39+
): Promise<HttpDispatcherResult> {
40+
const i18nService = await deps.getService(CoreServiceName.enum.i18n);
41+
if (!i18nService) return { handled: true, response: deps.error('i18n service not available', 501) };
42+
43+
const m = method.toUpperCase();
44+
const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
45+
46+
if (m !== 'GET') return { handled: false };
47+
48+
// GET /i18n/locales
49+
if (parts[0] === 'locales' && parts.length === 1) {
50+
const locales = i18nService.getLocales();
51+
return { handled: true, response: deps.success({ locales }) };
52+
}
53+
54+
// GET /i18n/translations/:locale OR /i18n/translations?locale=xx
55+
if (parts[0] === 'translations') {
56+
const locale = parts[1] ? decodeURIComponent(parts[1]) : query?.locale;
57+
if (!locale) return { handled: true, response: deps.error('Missing locale parameter', 400) };
58+
59+
let translations = i18nService.getTranslations(locale);
60+
61+
// Locale fallback: try resolving to an available locale when
62+
// the exact code yields empty translations (e.g. zh → zh-CN).
63+
if (Object.keys(translations).length === 0) {
64+
const availableLocales = typeof i18nService.getLocales === 'function'
65+
? i18nService.getLocales() : [];
66+
const resolved = resolveLocale(locale, availableLocales);
67+
if (resolved && resolved !== locale) {
68+
translations = i18nService.getTranslations(resolved);
69+
return { handled: true, response: deps.success({ locale: resolved, requestedLocale: locale, translations }) };
70+
}
71+
}
72+
73+
return { handled: true, response: deps.success({ locale, translations }) };
74+
}
75+
76+
// GET /i18n/labels/:object/:locale OR /i18n/labels/:object?locale=xx
77+
if (parts[0] === 'labels' && parts.length >= 2) {
78+
const objectName = decodeURIComponent(parts[1]);
79+
let locale = parts[2] ? decodeURIComponent(parts[2]) : query?.locale;
80+
if (!locale) return { handled: true, response: deps.error('Missing locale parameter', 400) };
81+
82+
// Locale fallback for labels endpoint
83+
const availableLocales = typeof i18nService.getLocales === 'function'
84+
? i18nService.getLocales() : [];
85+
const resolved = resolveLocale(locale, availableLocales);
86+
if (resolved) locale = resolved;
87+
88+
if (typeof i18nService.getFieldLabels === 'function') {
89+
const labels = i18nService.getFieldLabels(objectName, locale);
90+
return { handled: true, response: deps.success({ object: objectName, locale, labels }) };
91+
}
92+
// Fallback: derive field labels from full translation bundle
93+
const translations = i18nService.getTranslations(locale);
94+
const prefix = `o.${objectName}.fields.`;
95+
const labels: Record<string, string> = {};
96+
for (const [key, value] of Object.entries(translations)) {
97+
if (key.startsWith(prefix)) {
98+
labels[key.substring(prefix.length)] = value as string;
99+
}
100+
}
101+
return { handled: true, response: deps.success({ object: objectName, locale, labels }) };
102+
}
103+
104+
return { handled: false };
105+
}

0 commit comments

Comments
 (0)