Skip to content

Commit 8891f93

Browse files
os-zhuangclaude
andauthored
feat(runtime): extract /meta and /data — ADR-0076 D11 step ③ PR-10, terminal cut (#2462) (#3573)
The last two if-chain domains leave the dispatcher: - domains/meta.ts: the metadata surface (ADR-0033 draft-aware protocol paths, org-scoped reads, requireAuth gate via deps.isAuthRequired) with its exclusive slimDocList (ADR-0046) riding along. - domains/data.ts: CRUD/query over actionExec.callData; the multi-tenant unresolved-env 428 keys off a new semantic deps.isMultiTenantHost() instead of poking this.kernelResolver. dispatch() now contains ZERO domain branches — 18 domains resolve through the DomainHandlerRegistry; the gate stages + registry lookup is all that remains. createHonoApp's catch-all is ready for retirement (step ① of #2462). Verified: runtime 649, http-conformance 41, dogfood 351, full build green. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7fb436c commit 8891f93

5 files changed

Lines changed: 556 additions & 484 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
feat(runtime): extract the /meta and /data dispatcher domain bodies — ADR-0076 D11 step ③, PR-10, the terminal cut (#2462)
6+
7+
The last two domains leave the dispatcher: `domains/meta.ts` (metadata
8+
read/write incl. ADR-0033 draft-aware protocol paths, ADR-0046 doc slimming
9+
riding along with its exclusive `slimDocList` helper) and `domains/data.ts`
10+
(CRUD/query over the action-execution `callData` bridge; the multi-tenant
11+
unresolved-environment 428 now keys off a semantic `isMultiTenantHost()`
12+
deps member instead of poking `kernelResolver`). **The dispatch() if-chain
13+
is now EMPTY of domains** — 18 domains resolve through the registry, and
14+
`createHonoApp`'s catch-all is ready for retirement (step ① of #2462).
15+
Zero behavior change — runtime 649, http-conformance 41, dogfood 351 green.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ export interface DomainHandlerDeps {
131131
* dispatch()-routed requests (they already swapped).
132132
*/
133133
resolveProjectKernelObjectQL(context: HttpProtocolContext): Promise<any | null>;
134+
/** True when a host KernelResolver is registered (multi-tenant deployment). */
135+
isMultiTenantHost(): boolean;
134136
/** The deployment's `requireAuth` posture (lazily read — construction-order safe). */
135137
isAuthRequired(): boolean;
136138
/**
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `/data` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-10,
5+
* the terminal cut). CRUD + query over `callData` (protocol-first with
6+
* ObjectQL fallback, ADR-0049 exposure gate inside). On a multi-tenant
7+
* host (a KernelResolver is registered) an unresolved environment answers
8+
* 428 instead of silently serving the control plane.
9+
*/
10+
11+
import * as actionExec from '../action-execution.js';
12+
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
13+
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
14+
15+
export function createDataDomain(deps: DomainHandlerDeps): DomainRoute {
16+
return {
17+
prefix: '/data',
18+
handler: (req, context) =>
19+
handleDataRequest(deps, req.path.substring(5), req.method, req.body, req.query, context),
20+
};
21+
}
22+
23+
/**
24+
* Handles Data requests
25+
* path: sub-path after /data/ (e.g. "contacts", "contacts/123", "contacts/query")
26+
*/
27+
export async function handleDataRequest(deps: DomainHandlerDeps, path: string, method: string, body: any, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
28+
const parts = path.replace(/^\/+/, '').split('/');
29+
const objectName = parts[0];
30+
31+
if (!objectName) {
32+
return { handled: true, response: deps.error('Object name required', 400) };
33+
}
34+
35+
// Check if environment is resolved for data-plane requests. A
36+
// registered KernelResolver marks this host as multi-tenant (ADR-0006
37+
// Phase 5 — previously signalled by the env-registry service): a
38+
// data-plane request that the resolver did not attach to an
39+
// environment must not silently fall through to the host kernel.
40+
if (!_context.dataDriver && deps.isMultiTenantHost()) {
41+
return {
42+
handled: true,
43+
response: deps.error('Project not resolved. Please specify X-Environment-Id header or ensure hostname maps to a project.', 428)
44+
};
45+
}
46+
47+
const m = method.toUpperCase();
48+
49+
// 1. Custom Actions (query, batch)
50+
if (parts.length > 1) {
51+
const action = parts[1];
52+
53+
// POST /data/:object/query
54+
if (action === 'query' && m === 'POST') {
55+
// Spec: returns FindDataResponse = { object, records, total?, hasMore? }
56+
const result = await actionExec.callData(deps, 'query', { object: objectName, ...body }, _context.dataDriver, _context.environmentId, _context.executionContext);
57+
return { handled: true, response: deps.success(result) };
58+
}
59+
60+
// GET /data/:object/:id
61+
if (parts.length === 2 && m === 'GET') {
62+
const id = parts[1];
63+
// Spec: Only select/expand are allowlisted query params for GET by ID.
64+
// All other query parameters are discarded to prevent parameter pollution.
65+
const { select, expand } = query || {};
66+
const allowedParams: Record<string, unknown> = {};
67+
if (select != null) allowedParams.select = select;
68+
if (expand != null) allowedParams.expand = expand;
69+
// Spec: returns GetDataResponse = { object, id, record }
70+
const result = await actionExec.callData(deps, 'get', { object: objectName, id, ...allowedParams }, _context.dataDriver, _context.environmentId, _context.executionContext);
71+
return { handled: true, response: deps.success(result) };
72+
}
73+
74+
// PATCH /data/:object/:id
75+
if (parts.length === 2 && m === 'PATCH') {
76+
const id = parts[1];
77+
// Spec: returns UpdateDataResponse = { object, id, record }
78+
const result = await actionExec.callData(deps, 'update', { object: objectName, id, data: body }, _context.dataDriver, _context.environmentId, _context.executionContext);
79+
return { handled: true, response: deps.success(result) };
80+
}
81+
82+
// DELETE /data/:object/:id
83+
if (parts.length === 2 && m === 'DELETE') {
84+
const id = parts[1];
85+
// Spec: returns DeleteDataResponse = { object, id, deleted }
86+
const result = await actionExec.callData(deps, 'delete', { object: objectName, id }, _context.dataDriver, _context.environmentId, _context.executionContext);
87+
return { handled: true, response: deps.success(result) };
88+
}
89+
} else {
90+
// GET /data/:object (List)
91+
if (m === 'GET') {
92+
// ── Normalize HTTP transport params → Spec canonical (QueryAST) ──
93+
// HTTP GET query params use transport-level names (filter, sort, top,
94+
// skip, select, expand) which are normalized here to canonical
95+
// QueryAST field names (where, orderBy, limit, offset, fields,
96+
// expand) before forwarding to the data service layer.
97+
// The protocol.ts findData() method performs a deeper normalization
98+
// pass, but pre-normalizing here ensures the data service always receives
99+
// Spec-canonical keys.
100+
const normalized: Record<string, unknown> = { ...query };
101+
102+
// filter/filters → where
103+
// Note: `filter` is the canonical HTTP *transport* parameter name
104+
// (see HttpFindQueryParamsSchema). It is normalized here to the
105+
// canonical *QueryAST* field name `where` before data dispatch.
106+
// `filters` (plural) is a deprecated alias for `filter`.
107+
if (normalized.filter != null || normalized.filters != null) {
108+
normalized.where = normalized.where ?? normalized.filter ?? normalized.filters;
109+
delete normalized.filter;
110+
delete normalized.filters;
111+
}
112+
// select → fields
113+
if (normalized.select != null && normalized.fields == null) {
114+
normalized.fields = normalized.select;
115+
delete normalized.select;
116+
}
117+
// sort → orderBy
118+
if (normalized.sort != null && normalized.orderBy == null) {
119+
normalized.orderBy = normalized.sort;
120+
delete normalized.sort;
121+
}
122+
// top → limit
123+
if (normalized.top != null && normalized.limit == null) {
124+
normalized.limit = normalized.top;
125+
delete normalized.top;
126+
}
127+
// skip → offset
128+
if (normalized.skip != null && normalized.offset == null) {
129+
normalized.offset = normalized.skip;
130+
delete normalized.skip;
131+
}
132+
133+
// Spec: returns FindDataResponse = { object, records, total?, hasMore? }
134+
const result = await actionExec.callData(deps, 'query', { object: objectName, query: normalized }, _context.dataDriver, _context.environmentId, _context.executionContext);
135+
return { handled: true, response: deps.success(result) };
136+
}
137+
138+
// POST /data/:object (Create)
139+
if (m === 'POST') {
140+
// Spec: returns CreateDataResponse = { object, id, record }
141+
const result = await actionExec.callData(deps, 'create', { object: objectName, data: body }, _context.dataDriver, _context.environmentId, _context.executionContext);
142+
const res = deps.success(result);
143+
res.status = 201;
144+
return { handled: true, response: res };
145+
}
146+
}
147+
148+
return { handled: false };
149+
}

0 commit comments

Comments
 (0)