@cosmicdrift/kumiko-framework 0.171.1 → 0.172.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.172.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.172.0",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.27",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.172.0",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// pin the public guarantees:
|
|
6
6
|
//
|
|
7
7
|
// 1. API entrypoint has no eventDispatcher/jobRunner handles.
|
|
8
|
-
// 2. Worker entrypoint has no HTTP app
|
|
8
|
+
// 2. Worker entrypoint has no HTTP app, but does hand out the
|
|
9
|
+
// command-dispatcher — app-wired background components need it.
|
|
9
10
|
// 3. All-in-one has both.
|
|
10
11
|
// 4. Worker throws when there's literally nothing to consume (defensive
|
|
11
12
|
// guard — buildServer always wires an SSE consumer so this only
|
|
@@ -14,10 +15,11 @@
|
|
|
14
15
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
15
16
|
import { z } from "zod";
|
|
16
17
|
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
18
|
+
import { asRawClient } from "../../db/query";
|
|
17
19
|
import { createRegistry, defineFeature } from "../../engine";
|
|
18
20
|
import { createArchivedStreamsTable, createEventsTable } from "../../event-store";
|
|
19
21
|
import { createEventConsumerStateTable } from "../../pipeline";
|
|
20
|
-
import { createTestRedis, type TestRedis } from "../../stack";
|
|
22
|
+
import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
|
|
21
23
|
import { createAllInOneEntrypoint, createApiEntrypoint, createWorkerEntrypoint } from "../index";
|
|
22
24
|
|
|
23
25
|
const splitFeature = defineFeature("split", (r) => {
|
|
@@ -30,6 +32,24 @@ const splitFeature = defineFeature("split", (r) => {
|
|
|
30
32
|
});
|
|
31
33
|
});
|
|
32
34
|
|
|
35
|
+
const workerWriteFeature = defineFeature("workerWrite", (r) => {
|
|
36
|
+
const noted = r.defineEvent("noted", z.object({ note: z.string() }), { version: 1 });
|
|
37
|
+
r.writeHandler(
|
|
38
|
+
"note",
|
|
39
|
+
z.object({ note: z.string() }),
|
|
40
|
+
async (event, ctx) => {
|
|
41
|
+
await ctx.unsafeAppendEvent({
|
|
42
|
+
aggregateId: crypto.randomUUID(),
|
|
43
|
+
aggregateType: "worker-note",
|
|
44
|
+
type: noted.name,
|
|
45
|
+
payload: { note: event.payload.note },
|
|
46
|
+
});
|
|
47
|
+
return { isSuccess: true as const, data: { note: event.payload.note } };
|
|
48
|
+
},
|
|
49
|
+
{ access: { openToAll: true } },
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
33
53
|
const JWT = "split-deploy-test-secret-must-be-32-chars!!";
|
|
34
54
|
|
|
35
55
|
// Per-test queue-name with a random suffix. Date.now() alone collided
|
|
@@ -86,6 +106,7 @@ describe("entrypoint factories", () => {
|
|
|
86
106
|
expect(worker.mode).toBe("worker");
|
|
87
107
|
expect(worker.eventDispatcher).toBeDefined();
|
|
88
108
|
expect(worker.jobRunner).toBeDefined();
|
|
109
|
+
expect(worker.dispatcher).toBeDefined();
|
|
89
110
|
expect("app" in worker).toBe(false);
|
|
90
111
|
expect("jwt" in worker).toBe(false);
|
|
91
112
|
|
|
@@ -95,6 +116,42 @@ describe("entrypoint factories", () => {
|
|
|
95
116
|
await worker.stop();
|
|
96
117
|
});
|
|
97
118
|
|
|
119
|
+
// An app-wired component running in the worker (analysis service, IMAP
|
|
120
|
+
// supervisor) has to persist its result, and persisting goes through the
|
|
121
|
+
// write-path — JobContext has no write/query. The dispatcher is the only
|
|
122
|
+
// way in, so a worker that doesn't hand it out forces such a component
|
|
123
|
+
// into the API process, which defeats the split.
|
|
124
|
+
test("Worker dispatcher runs a write end-to-end — handler executes, event lands in the store", async () => {
|
|
125
|
+
const registry = createRegistry([workerWriteFeature]);
|
|
126
|
+
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
127
|
+
const worker = createWorkerEntrypoint({
|
|
128
|
+
registry,
|
|
129
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
130
|
+
jwtSecret: JWT,
|
|
131
|
+
redisUrl,
|
|
132
|
+
queueNamePrefix: uniquePrefix("split-dispatch"),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const result = await worker.dispatcher.write(
|
|
137
|
+
"worker-write:write:note",
|
|
138
|
+
{ note: "written from the worker" },
|
|
139
|
+
TestUsers.admin,
|
|
140
|
+
);
|
|
141
|
+
expect(result.isSuccess).toBe(true);
|
|
142
|
+
|
|
143
|
+
const rows = await asRawClient(testDb.db).unsafe(
|
|
144
|
+
`SELECT payload FROM kumiko_events WHERE type = 'worker-write:event:noted'`,
|
|
145
|
+
);
|
|
146
|
+
expect(rows).toHaveLength(1);
|
|
147
|
+
expect((rows[0] as { payload: { note: string } }).payload.note).toBe(
|
|
148
|
+
"written from the worker",
|
|
149
|
+
);
|
|
150
|
+
} finally {
|
|
151
|
+
await worker.stop();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
98
155
|
test("All-in-one entrypoint has both HTTP surface and background workers", async () => {
|
|
99
156
|
const registry = createRegistry([splitFeature]);
|
|
100
157
|
const redisUrl = process.env["REDIS_URL"] ?? "redis://localhost:16379";
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -142,6 +142,12 @@ export type WorkerEntrypoint = {
|
|
|
142
142
|
readonly eventDispatcher: EventDispatcher;
|
|
143
143
|
readonly jobRunner: JobRunner;
|
|
144
144
|
readonly observability: ObservabilityProvider;
|
|
145
|
+
// Same dispatcher the API process exposes — a worker builds the identical
|
|
146
|
+
// server, only without routes. App-wired components that run in the worker
|
|
147
|
+
// and must persist their result need it: JobContext has no write/query
|
|
148
|
+
// (handlers.ts JobContext), so writing goes through dispatchSystemWrite,
|
|
149
|
+
// the pattern inbound-mail-foundation/watch-supervisor.ts established.
|
|
150
|
+
readonly dispatcher: Dispatcher;
|
|
145
151
|
readonly mode: "worker";
|
|
146
152
|
// Starts event-dispatcher poll + BullMQ worker. SIGTERM triggers
|
|
147
153
|
// `lifecycle.drain()`, which stops both via registered hooks.
|
|
@@ -428,6 +434,7 @@ export function createWorkerEntrypoint(options: WorkerEntrypointOptions): Worker
|
|
|
428
434
|
eventDispatcher,
|
|
429
435
|
jobRunner,
|
|
430
436
|
observability: server.observability,
|
|
437
|
+
dispatcher: server.dispatcher,
|
|
431
438
|
mode: "worker",
|
|
432
439
|
async start() {
|
|
433
440
|
await eventDispatcher.start();
|