@cosmicdrift/kumiko-framework 0.171.2 → 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.171.2",
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.171.2",
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.171.2",
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";
@@ -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();