Skip to content

Commit d729a31

Browse files
os-zhuangclaude
andauthored
feat(runtime): extract /packages dispatcher domain body — ADR-0076 D11 step ③ PR-5 (#2462) (#3537)
The largest extraction so far: handlePackages (~470 lines) plus its two EXCLUSIVE helpers assemblePackageManifest and applyPublishedSeeds (~190 more) move to domains/packages.ts. resolveActiveOrganizationId stays on the dispatcher (metadata-domain call sites share it) and is exposed through deps instead. DomainHandlerDeps grows: errorFromThrown (spec-validation issues carry through as field-anchored 422s), resolveActiveOrganizationId, announceKernelEvent (the metadata:reloaded publish announcement — the domain keeps the changed-list check, the deps side owns the trigger-existence check), and optional logger (seed-loader falls back to console as before). The dynamic './seed-loader.js' import moved to '../seed-loader.js' (the PR#2415 lesson: dynamic imports are the extraction landmine — this time the import moved WITH the caller). Step-② (#3142) single-pipeline behavior preserved; the '/packages' bare-startsWith match is reproduced as-is. Verified: seam suite 34 tests (4 new incl. the 409 duplicate-install guard + ?overwrite=true), runtime 639 green, http-conformance 41 green, 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 3d3fddf commit d729a31

5 files changed

Lines changed: 774 additions & 654 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
feat(runtime): extract the /packages dispatcher domain body — ADR-0076 D11 step ③, PR-5 (#2462)
6+
7+
The largest domain so far (~680 lines: the handler plus its two exclusive
8+
helpers `assemblePackageManifest` and `applyPublishedSeeds`) moves to
9+
`domains/packages.ts` — list/install/enable/disable, ADR-0033 draft
10+
publish/discard, ADR-0067 commit history & rollback, ADR-0070 export /
11+
orphan adoption / duplicate, delete. `DomainHandlerDeps` grows the shared
12+
facilities the body needs: `errorFromThrown` (field-anchored 422s),
13+
`resolveActiveOrganizationId` (session org), `announceKernelEvent`
14+
(`metadata:reloaded` after publish), and an optional `logger`. The step-②
15+
(#3142) single-pipeline behavior is preserved. Zero behavior change —
16+
http-conformance (41) plus 4 new seam tests (incl. the 409
17+
duplicate-install guard).

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,3 +358,52 @@ describe('HttpDispatcher extracted domains (PR-4: share-links)', () => {
358358
expect(result.response?.body?.error?.type).toBe('ROUTE_NOT_FOUND');
359359
});
360360
});
361+
362+
// ---------------------------------------------------------------------------
363+
// PR-5 — packages extraction
364+
// ---------------------------------------------------------------------------
365+
366+
describe('HttpDispatcher extracted domains (PR-5: packages)', () => {
367+
function qlWithRegistry(extra: Partial<Record<string, any>> = {}) {
368+
return {
369+
find: vi.fn().mockResolvedValue([]),
370+
getObjects: vi.fn().mockReturnValue({}),
371+
registry: {
372+
getObject: vi.fn().mockReturnValue(null),
373+
getRegisteredTypes: vi.fn().mockReturnValue([]),
374+
getAllPackages: vi.fn().mockReturnValue([{ id: 'pkg-a', status: 'active' }]),
375+
getPackage: vi.fn().mockReturnValue(undefined),
376+
installPackage: vi.fn().mockImplementation((m: any) => ({ id: m.id, manifest: m })),
377+
...extra,
378+
},
379+
};
380+
}
381+
382+
it('GET /packages lists packages from the ObjectQL registry', async () => {
383+
const objectql = qlWithRegistry();
384+
const result = await makeDispatcher({ objectql }).dispatch('GET', '/packages', undefined, {}, {} as any);
385+
expect(result.response?.status).toBe(200);
386+
expect(result.response?.body?.data?.total).toBe(1);
387+
});
388+
389+
it('responds 503 when no ObjectQL registry is available', async () => {
390+
const objectql = { find: vi.fn(), getObjects: vi.fn() }; // no .registry → getObjectQL returns null
391+
const result = await makeDispatcher({ objectql }).dispatch('GET', '/packages', undefined, {}, {} as any);
392+
expect(result.response?.status).toBe(503);
393+
});
394+
395+
it('POST /packages rejects a duplicate id with 409 unless ?overwrite=true (data-loss footgun guard)', async () => {
396+
const objectql = qlWithRegistry({ getPackage: vi.fn().mockReturnValue({ id: 'pkg-a' }) });
397+
const dispatcher = makeDispatcher({ objectql });
398+
const dup = await dispatcher.dispatch('POST', '/packages', { id: 'pkg-a', name: 'A' }, {}, {} as any);
399+
expect(dup.response?.status).toBe(409);
400+
const forced = await dispatcher.dispatch('POST', '/packages', { id: 'pkg-a', name: 'A' }, { overwrite: 'true' }, {} as any);
401+
expect(forced.response?.status).toBe(201);
402+
});
403+
404+
it('POST /packages without an id is rejected with 400', async () => {
405+
const objectql = qlWithRegistry();
406+
const result = await makeDispatcher({ objectql }).dispatch('POST', '/packages', { name: 'no-id' }, {}, {} as any);
407+
expect(result.response?.status).toBe(400);
408+
});
409+
});

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,23 @@ export interface DomainHandlerDeps {
102102
error(message: string, code?: number, details?: any): { status: number; body: any };
103103
/** Standard ROUTE_NOT_FOUND envelope (404 + discovery hint). */
104104
routeNotFound(route: string): { status: number; body: any };
105+
/**
106+
* Error envelope derived from a thrown value: honours `.status` /
107+
* `.statusCode`, carries spec-validation `issues` and `.code` through as
108+
* details (the ADR-0033 publish surface relies on field-anchored 422s).
109+
*/
110+
errorFromThrown(e: any, fallbackStatus?: number): { status: number; body: any };
111+
/** Active organization id from the request session (undefined if anonymous / no auth). */
112+
resolveActiveOrganizationId(context: HttpProtocolContext): Promise<string | undefined>;
113+
/**
114+
* Fire a kernel-context event on the request's resolved kernel (no-op
115+
* when the kernel exposes no trigger). Used by the packages domain to
116+
* announce `metadata:reloaded` after a publish so boot-cached consumers
117+
* (the automation engine above all) re-sync without a restart.
118+
*/
119+
announceKernelEvent(event: string, payload: unknown): Promise<void>;
120+
/** Host logger when one is attached to the dispatcher; domains fall back to console. */
121+
logger?: any;
105122
}
106123

107124
/**

0 commit comments

Comments
 (0)