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
17 changes: 17 additions & 0 deletions .changeset/runtime-packages-extraction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@objectstack/runtime": minor
---

feat(runtime): extract the /packages dispatcher domain body — ADR-0076 D11 step ③, PR-5 (#2462)

The largest domain so far (~680 lines: the handler plus its two exclusive
helpers `assemblePackageManifest` and `applyPublishedSeeds`) moves to
`domains/packages.ts` — list/install/enable/disable, ADR-0033 draft
publish/discard, ADR-0067 commit history & rollback, ADR-0070 export /
orphan adoption / duplicate, delete. `DomainHandlerDeps` grows the shared
facilities the body needs: `errorFromThrown` (field-anchored 422s),
`resolveActiveOrganizationId` (session org), `announceKernelEvent`
(`metadata:reloaded` after publish), and an optional `logger`. The step-②
(#3142) single-pipeline behavior is preserved. Zero behavior change —
http-conformance (41) plus 4 new seam tests (incl. the 409
duplicate-install guard).
49 changes: 49 additions & 0 deletions packages/runtime/src/domain-handler-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,3 +358,52 @@ describe('HttpDispatcher extracted domains (PR-4: share-links)', () => {
expect(result.response?.body?.error?.type).toBe('ROUTE_NOT_FOUND');
});
});

// ---------------------------------------------------------------------------
// PR-5 — packages extraction
// ---------------------------------------------------------------------------

describe('HttpDispatcher extracted domains (PR-5: packages)', () => {
function qlWithRegistry(extra: Partial<Record<string, any>> = {}) {
return {
find: vi.fn().mockResolvedValue([]),
getObjects: vi.fn().mockReturnValue({}),
registry: {
getObject: vi.fn().mockReturnValue(null),
getRegisteredTypes: vi.fn().mockReturnValue([]),
getAllPackages: vi.fn().mockReturnValue([{ id: 'pkg-a', status: 'active' }]),
getPackage: vi.fn().mockReturnValue(undefined),
installPackage: vi.fn().mockImplementation((m: any) => ({ id: m.id, manifest: m })),
...extra,
},
};
}

it('GET /packages lists packages from the ObjectQL registry', async () => {
const objectql = qlWithRegistry();
const result = await makeDispatcher({ objectql }).dispatch('GET', '/packages', undefined, {}, {} as any);
expect(result.response?.status).toBe(200);
expect(result.response?.body?.data?.total).toBe(1);
});

it('responds 503 when no ObjectQL registry is available', async () => {
const objectql = { find: vi.fn(), getObjects: vi.fn() }; // no .registry → getObjectQL returns null
const result = await makeDispatcher({ objectql }).dispatch('GET', '/packages', undefined, {}, {} as any);
expect(result.response?.status).toBe(503);
});

it('POST /packages rejects a duplicate id with 409 unless ?overwrite=true (data-loss footgun guard)', async () => {
const objectql = qlWithRegistry({ getPackage: vi.fn().mockReturnValue({ id: 'pkg-a' }) });
const dispatcher = makeDispatcher({ objectql });
const dup = await dispatcher.dispatch('POST', '/packages', { id: 'pkg-a', name: 'A' }, {}, {} as any);
expect(dup.response?.status).toBe(409);
const forced = await dispatcher.dispatch('POST', '/packages', { id: 'pkg-a', name: 'A' }, { overwrite: 'true' }, {} as any);
expect(forced.response?.status).toBe(201);
});

it('POST /packages without an id is rejected with 400', async () => {
const objectql = qlWithRegistry();
const result = await makeDispatcher({ objectql }).dispatch('POST', '/packages', { name: 'no-id' }, {}, {} as any);
expect(result.response?.status).toBe(400);
});
});
17 changes: 17 additions & 0 deletions packages/runtime/src/domain-handler-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,23 @@ export interface DomainHandlerDeps {
error(message: string, code?: number, details?: any): { status: number; body: any };
/** Standard ROUTE_NOT_FOUND envelope (404 + discovery hint). */
routeNotFound(route: string): { status: number; body: any };
/**
* Error envelope derived from a thrown value: honours `.status` /
* `.statusCode`, carries spec-validation `issues` and `.code` through as
* details (the ADR-0033 publish surface relies on field-anchored 422s).
*/
errorFromThrown(e: any, fallbackStatus?: number): { status: number; body: any };
/** Active organization id from the request session (undefined if anonymous / no auth). */
resolveActiveOrganizationId(context: HttpProtocolContext): Promise<string | undefined>;
/**
* Fire a kernel-context event on the request's resolved kernel (no-op
* when the kernel exposes no trigger). Used by the packages domain to
* announce `metadata:reloaded` after a publish so boot-cached consumers
* (the automation engine above all) re-sync without a restart.
*/
announceKernelEvent(event: string, payload: unknown): Promise<void>;
/** Host logger when one is attached to the dispatcher; domains fall back to console. */
logger?: any;
}

/**
Expand Down
Loading