@cosmicdrift/kumiko-framework 0.164.0 → 0.165.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.
Files changed (30) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/consumer-cli.integration.test.ts +30 -0
  3. package/src/api/__tests__/api.test.ts +60 -0
  4. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +18 -2
  5. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +27 -18
  6. package/src/api/routes.ts +41 -22
  7. package/src/api/server.ts +14 -6
  8. package/src/bun-db/connection.ts +3 -3
  9. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +5 -12
  10. package/src/db/index.ts +0 -2
  11. package/src/db/queries/event-store.ts +0 -16
  12. package/src/engine/__tests__/boot-validator.test.ts +14 -0
  13. package/src/engine/__tests__/registry.test.ts +36 -0
  14. package/src/engine/boot-validator/entity-handler.ts +14 -25
  15. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +4 -0
  16. package/src/engine/feature-ast/extractors/handlers.ts +13 -18
  17. package/src/engine/registry-validate.ts +7 -2
  18. package/src/engine/types/index.ts +0 -2
  19. package/src/event-store/__tests__/event-store.integration.test.ts +28 -14
  20. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +5 -8
  21. package/src/event-store/event-store.ts +1 -17
  22. package/src/event-store/index.ts +0 -1
  23. package/src/pipeline/__tests__/dispatcher.test.ts +52 -0
  24. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
  25. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +31 -18
  26. package/src/pipeline/dispatch-stream.ts +2 -7
  27. package/src/pipeline/event-dispatcher-delivery.ts +2 -1
  28. package/src/pipeline/system-hooks.ts +50 -1
  29. package/src/db/__tests__/encryption.test.ts +0 -39
  30. package/src/db/encryption.ts +0 -45
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.164.0",
3
+ "version": "0.165.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.164.0",
185
+ "@cosmicdrift/kumiko-types": "0.165.0",
186
186
  "bullmq": "^5.76.7",
187
187
  "bun-types": "^1.3.13",
188
188
  "hono": "^4.12.18",
@@ -198,7 +198,7 @@
198
198
  "zod": "^4.4.3"
199
199
  },
200
200
  "devDependencies": {
201
- "@cosmicdrift/kumiko-dispatcher-live": "0.164.0",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.165.0",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -84,6 +84,36 @@ describe("runConsumerCli status", () => {
84
84
  expect(code).toBe(1);
85
85
  expect(lines.join("\n")).toContain("DATABASE_URL not set");
86
86
  });
87
+
88
+ test("--instance-id flag → status looks up the per-instance consumer, not shared", async () => {
89
+ prevDbUrl = process.env["DATABASE_URL"];
90
+ process.env["DATABASE_URL"] = testUrl;
91
+ await insertConsumerIfAbsent(testDb.db, "test:consumer:inst", "inst-1");
92
+ const { out, lines } = captureOut();
93
+ const code = await runConsumerCli(
94
+ ["status", "test:consumer:inst", "--instance-id", "inst-1"],
95
+ out,
96
+ );
97
+ expect(code).toBe(0);
98
+ const joined = lines.join("\n");
99
+ expect(joined).toContain('instance_id="inst-1"');
100
+ });
101
+ });
102
+
103
+ describe("runConsumerCli — unknown/empty subcommand exit codes", () => {
104
+ test("no subcommand at all → exit 0 (usage, not an error)", async () => {
105
+ const { out, lines } = captureOut();
106
+ const code = await runConsumerCli([], out);
107
+ expect(code).toBe(0);
108
+ expect(lines.join("\n")).toContain("Subcommands:");
109
+ });
110
+
111
+ test("an unrecognized subcommand → exit 1", async () => {
112
+ const { out, lines } = captureOut();
113
+ const code = await runConsumerCli(["bogus"], out);
114
+ expect(code).toBe(1);
115
+ expect(lines.join("\n")).toContain("Subcommands:");
116
+ });
87
117
  });
88
118
 
89
119
  describe("runConsumerCli restart", () => {
@@ -8,6 +8,7 @@ import {
8
8
  type TenantId,
9
9
  } from "../../engine";
10
10
  import { createTestUser, TestUsers } from "../../stack";
11
+ import { pumpStream } from "../routes";
11
12
  import { buildServer } from "../server";
12
13
 
13
14
  const JWT_SECRET = "test-secret-at-least-32-chars-long!!";
@@ -210,6 +211,65 @@ describe("POST /api/command", () => {
210
211
  });
211
212
  });
212
213
 
214
+ // --- pumpStream (SSE pull loop) ---
215
+
216
+ function fakeSseWriter() {
217
+ const frames: Array<{ event: string; data: string }> = [];
218
+ return {
219
+ frames,
220
+ async writeSSE(message: { event: string; data: string }) {
221
+ frames.push(message);
222
+ },
223
+ };
224
+ }
225
+
226
+ async function* delayedGenerator(values: readonly unknown[], delayMsByIndex: readonly number[]) {
227
+ for (let i = 0; i < values.length; i++) {
228
+ const delay = delayMsByIndex[i] ?? 0;
229
+ if (delay > 0) await Bun.sleep(delay);
230
+ yield values[i];
231
+ }
232
+ }
233
+
234
+ describe("pumpStream", () => {
235
+ test("emits a ping when the handler is slow, then still delivers the pending chunk (no loss)", async () => {
236
+ const writer = fakeSseWriter();
237
+ // heartbeatMs (10) fires before the 40ms-delayed second chunk resolves.
238
+ const gen = delayedGenerator([{ i: 0 }, { i: 1 }], [0, 40]);
239
+
240
+ await pumpStream(writer, gen, 10);
241
+
242
+ const events = writer.frames.map((f) => f.event);
243
+ expect(events[0]).toBe("chunk");
244
+ expect(events).toContain("ping");
245
+ expect(events.at(-1)).toBe("done");
246
+ // Both chunks arrive despite the ping in between — no chunk dropped.
247
+ const chunkData = writer.frames.filter((f) => f.event === "chunk").map((f) => f.data);
248
+ expect(chunkData).toEqual([JSON.stringify({ i: 0 }), JSON.stringify({ i: 1 })]);
249
+ });
250
+
251
+ test("no heartbeat fires when the handler is faster than heartbeatMs — chunks then done", async () => {
252
+ const writer = fakeSseWriter();
253
+ const gen = delayedGenerator([{ i: 0 }, { i: 1 }, { i: 2 }], [0, 0, 0]);
254
+
255
+ await pumpStream(writer, gen, 1000);
256
+
257
+ expect(writer.frames.map((f) => f.event)).toEqual(["chunk", "chunk", "chunk", "done"]);
258
+ });
259
+
260
+ test("a handler generator that throws propagates the error instead of swallowing it", async () => {
261
+ const writer = fakeSseWriter();
262
+ async function* throwing() {
263
+ yield { i: 0 };
264
+ throw new Error("handler-boom");
265
+ }
266
+
267
+ await expect(pumpStream(writer, throwing(), 1000)).rejects.toThrow("handler-boom");
268
+ // The chunk before the throw still made it out.
269
+ expect(writer.frames.map((f) => f.event)).toEqual(["chunk"]);
270
+ });
271
+ });
272
+
213
273
  // --- SSE ---
214
274
 
215
275
  describe("GET /api/sse", () => {
@@ -80,7 +80,11 @@ function preauthConfirmRequest(body: unknown): Request {
80
80
 
81
81
  describe("POST /auth/mfa/preauth-confirm", () => {
82
82
  test("is public — reachable without a JWT", async () => {
83
- expect(PUBLIC_API_PATHS.has("/api/auth/mfa/preauth-confirm")).toBe(true);
83
+ const { app } = await buildApp();
84
+ const res = await app.request(preauthConfirmRequest({ setupToken: "t", code: "123456" }));
85
+ // No Authorization header sent at all — if the route required a JWT,
86
+ // authMiddleware would reject with 401 before the handler ever runs.
87
+ expect(res.status).not.toBe(401);
84
88
  });
85
89
 
86
90
  test("not mounted when mfaPreauthConfirmHandler is unset", async () => {
@@ -100,12 +104,24 @@ describe("POST /auth/mfa/preauth-confirm", () => {
100
104
  };
101
105
  },
102
106
  });
103
- const { app } = await buildApp({}, dispatcher);
107
+ // An active limiter, not null "before rate-limit" is only checkable
108
+ // when there's a rate-limit to (not) consume.
109
+ const { app } = await buildApp(
110
+ { mfaPreauthConfirmRateLimit: createInMemoryLoginRateLimiter(2, 60_000) },
111
+ dispatcher,
112
+ );
104
113
  const res = await app.request(preauthConfirmRequest({ setupToken: "t" }));
105
114
  expect(res.status).toBe(400);
106
115
  const body = (await res.json()) as { isSuccess: boolean; error: string };
107
116
  expect(body.error).toBe("invalid_body");
108
117
  expect(dispatched).toBe(false);
118
+
119
+ // The limiter's cap (2) must still be fully available — a malformed
120
+ // body must not have consumed an attempt.
121
+ for (let i = 0; i < 5; i++) {
122
+ const retry = await app.request(preauthConfirmRequest({ setupToken: "t" }));
123
+ expect(retry.status).toBe(400);
124
+ }
109
125
  });
110
126
 
111
127
  test("on success: dispatches to mfaPreauthConfirmHandler, mints a JWT + cookies", async () => {
@@ -2,25 +2,33 @@ import { describe, expect, test } from "bun:test";
2
2
  import { createInMemoryLoginRateLimiter } from "../auth-routes";
3
3
 
4
4
  describe("createInMemoryLoginRateLimiter — sweep + cap", () => {
5
- test("sweepExpired drops windows that already reset before accepting a new key", async () => {
6
- // Tiny thresholds so the Map hits the sweep path without flooding.
7
- const limiter = createInMemoryLoginRateLimiter(10, 50, {
8
- maxEntries: 100,
5
+ test("sweepExpired removes expired entries before enforceCap can evict a fresh key", async () => {
6
+ // maxAttempts=1 makes every entry a single-shot probe: a second `check`
7
+ // on the same key only returns true if the entry was actually removed
8
+ // from the map (either by expiry+sweep or by enforceCap).
9
+ const limiter = createInMemoryLoginRateLimiter(1, 50, {
10
+ maxEntries: 2,
9
11
  sweepThreshold: 2,
10
12
  });
11
13
 
12
- expect(await limiter.check("a")).toBe(true);
13
- expect(await limiter.check("b")).toBe(true);
14
- // Wait for both windows to expire, then a third check must sweep a+b
15
- // (hits.size >= sweepThreshold) before inserting "c".
14
+ expect(await limiter.check("old1")).toBe(true);
15
+ expect(await limiter.check("old2")).toBe(true);
16
+ // Let both windows expire.
16
17
  await Bun.sleep(60);
17
- expect(await limiter.check("c")).toBe(true);
18
- // Fresh window for "a" after sweep not rate-limited.
19
- expect(await limiter.check("a")).toBe(true);
18
+
19
+ // hits.size (2) >= sweepThreshold (2) sweepExpired runs first and
20
+ // clears old1/old2 before "new1" is inserted, so enforceCap never fires.
21
+ expect(await limiter.check("new1")).toBe(true);
22
+ expect(await limiter.check("new2")).toBe(true);
23
+
24
+ // If enforceCap had run instead of (or before) the sweep, it would have
25
+ // evicted "new1" as the oldest live entry, and this check would return
26
+ // true (fresh window) rather than false (still rate-limited).
27
+ expect(await limiter.check("new1")).toBe(false);
20
28
  });
21
29
 
22
- test("enforceCap drops oldest entries when the map exceeds maxEntries", async () => {
23
- const limiter = createInMemoryLoginRateLimiter(100, 60_000, {
30
+ test("enforceCap drops the oldest entry when the map exceeds maxEntries", async () => {
31
+ const limiter = createInMemoryLoginRateLimiter(1, 60_000, {
24
32
  maxEntries: 3,
25
33
  sweepThreshold: 10_000, // never sweep — only the hard cap matters
26
34
  });
@@ -28,14 +36,15 @@ describe("createInMemoryLoginRateLimiter — sweep + cap", () => {
28
36
  expect(await limiter.check("k1")).toBe(true);
29
37
  expect(await limiter.check("k2")).toBe(true);
30
38
  expect(await limiter.check("k3")).toBe(true);
31
- // 4th insert trips enforceCap → drops oldest (k1).
39
+ // 4th insert trips enforceCap → drops the oldest entry (k1).
32
40
  expect(await limiter.check("k4")).toBe(true);
33
41
 
34
42
  // k1 was dropped — a fresh check starts a new window (allowed).
35
43
  expect(await limiter.check("k1")).toBe(true);
36
- // k2/k3/k4 still live in the map (cap=3 after k1 drop + k1 reinsert may
37
- // drop another). Reset proves the API still works for survivors.
38
- await limiter.reset("k4");
39
- expect(await limiter.check("k4")).toBe(true);
44
+
45
+ // k4 must still be alive in the map: a check within its still-open
46
+ // window is rate-limited (count >= maxAttempts), proving enforceCap
47
+ // dropped k1 and not k4.
48
+ expect(await limiter.check("k4")).toBe(false);
40
49
  });
41
50
  });
package/src/api/routes.ts CHANGED
@@ -147,28 +147,7 @@ export function createApiRoutes(dispatcher: Dispatcher) {
147
147
  });
148
148
 
149
149
  try {
150
- let pending = generator.next();
151
- while (true) {
152
- let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
153
- const heartbeat = new Promise<"heartbeat">((resolve) => {
154
- heartbeatTimer = setTimeout(() => resolve("heartbeat"), SSE_HEARTBEAT_INTERVAL_MS);
155
- });
156
- let outcome: Awaited<typeof pending> | "heartbeat";
157
- try {
158
- outcome = await Promise.race([pending, heartbeat]);
159
- } finally {
160
- clearTimeout(heartbeatTimer);
161
- }
162
-
163
- if (outcome === "heartbeat") {
164
- await stream.writeSSE({ event: "ping", data: "" });
165
- continue;
166
- }
167
- if (outcome.done) break;
168
- await stream.writeSSE({ event: "chunk", data: stringifyJson(outcome.value) });
169
- pending = generator.next();
170
- }
171
- await stream.writeSSE({ event: "done", data: "" });
150
+ await pumpStream(stream, generator, SSE_HEARTBEAT_INTERVAL_MS);
172
151
  } catch (e) {
173
152
  const err = toKumiko(e);
174
153
  logServerFault(err, requestId, body.type);
@@ -181,6 +160,46 @@ export function createApiRoutes(dispatcher: Dispatcher) {
181
160
  return api;
182
161
  }
183
162
 
163
+ export type SseWriter = {
164
+ readonly writeSSE: (message: { readonly event: string; readonly data: string }) => Promise<void>;
165
+ };
166
+
167
+ // Pull loop for /api/stream: races each generator.next() against a
168
+ // heartbeat timer so a slow/idle handler still keeps the connection alive
169
+ // ("ping" frames), forwards yielded chunks as "chunk" frames, and emits
170
+ // "done" once the generator completes. Factored out of the route handler
171
+ // so the heartbeat/ping and abort/cleanup paths are unit-testable with a
172
+ // fast heartbeatMs instead of only reachable through SSE_HEARTBEAT_INTERVAL_MS
173
+ // (15s) in a full HTTP round-trip.
174
+ export async function pumpStream(
175
+ stream: SseWriter,
176
+ generator: AsyncGenerator<unknown>,
177
+ heartbeatMs: number,
178
+ ): Promise<void> {
179
+ let pending = generator.next();
180
+ while (true) {
181
+ let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
182
+ const heartbeat = new Promise<"heartbeat">((resolve) => {
183
+ heartbeatTimer = setTimeout(() => resolve("heartbeat"), heartbeatMs);
184
+ });
185
+ let outcome: Awaited<typeof pending> | "heartbeat";
186
+ try {
187
+ outcome = await Promise.race([pending, heartbeat]);
188
+ } finally {
189
+ clearTimeout(heartbeatTimer);
190
+ }
191
+
192
+ if (outcome === "heartbeat") {
193
+ await stream.writeSSE({ event: "ping", data: "" });
194
+ continue;
195
+ }
196
+ if (outcome.done) break;
197
+ await stream.writeSSE({ event: "chunk", data: stringifyJson(outcome.value) });
198
+ pending = generator.next();
199
+ }
200
+ await stream.writeSSE({ event: "done", data: "" });
201
+ }
202
+
184
203
  function jsonResponse(c: Context, body: unknown, status: ContentfulStatusCode = 200) {
185
204
  return c.body(stringifyJson(body), status, { "Content-Type": "application/json" });
186
205
  }
package/src/api/server.ts CHANGED
@@ -34,6 +34,7 @@ import { createEventDispatcher } from "../pipeline/event-dispatcher";
34
34
  import { createLifecycleHooks, type SystemHooks } from "../pipeline/lifecycle-pipeline";
35
35
  import { createMultiStreamApplyContext } from "../pipeline/multi-stream-apply-context";
36
36
  import {
37
+ createJobTriggerEventConsumer,
37
38
  createSearchEventConsumer,
38
39
  createSseBroadcastEventConsumer,
39
40
  } from "../pipeline/system-hooks";
@@ -105,12 +106,14 @@ export type ServerOptions = {
105
106
  // runs a dedicated dispatcher process, or a test needs to control the
106
107
  // consumer lifecycle manually.
107
108
  disabled?: boolean;
108
- // Opt out of the auto-built system consumers (SSE, Search) while still
109
- // running feature r.multiStreamProjection consumers. Useful for tests
110
- // that assert only on subscriber behaviour, or for a deployment that
111
- // routes SSE via a different transport. Default: both enabled when the
112
- // respective dependency (sseBroker / context.searchAdapter) is available.
113
- systemConsumers?: { sse?: boolean; search?: boolean };
109
+ // Opt out of the auto-built system consumers (SSE, Search, Job-Trigger)
110
+ // while still running feature r.multiStreamProjection consumers. Useful
111
+ // for tests that assert only on subscriber behaviour, or for a
112
+ // deployment that routes SSE via a different transport. Default: sse/
113
+ // search enabled when the respective dependency (sseBroker /
114
+ // context.searchAdapter) is available; jobTrigger enabled when a
115
+ // jobRunner is wired via dispatcherOptions.
116
+ systemConsumers?: { sse?: boolean; search?: boolean; jobTrigger?: boolean };
114
117
  // Raw postgres.js client for LISTEN/NOTIFY wake-up (Sprint E.4). When
115
118
  // present, `.start()` subscribes to EVENTS_PUBSUB_CHANNEL — delivery
116
119
  // latency drops from pollIntervalMs to TCP-round-trip. The poll timer
@@ -398,6 +401,8 @@ export function buildServer(options: ServerOptions): KumikoServer {
398
401
 
399
402
  const sseConsumerEnabled = options.eventDispatcher?.systemConsumers?.sse ?? true;
400
403
  const searchConsumerEnabled = options.eventDispatcher?.systemConsumers?.search ?? true;
404
+ const jobTriggerConsumerEnabled = options.eventDispatcher?.systemConsumers?.jobTrigger ?? true;
405
+ const jobRunnerForTriggers = options.dispatcherOptions?.jobRunner;
401
406
 
402
407
  const systemConsumers: EventConsumer[] = [];
403
408
  if (sseConsumerEnabled) {
@@ -406,6 +411,9 @@ export function buildServer(options: ServerOptions): KumikoServer {
406
411
  if (searchConsumerEnabled && searchAdapter) {
407
412
  systemConsumers.push(createSearchEventConsumer(searchAdapter, options.registry));
408
413
  }
414
+ if (jobTriggerConsumerEnabled && jobRunnerForTriggers) {
415
+ systemConsumers.push(createJobTriggerEventConsumer(jobRunnerForTriggers, options.registry));
416
+ }
409
417
 
410
418
  // MultiStreamProjections: one EventConsumer per MSP. Handler routes by
411
419
  // event.type into the MSP's apply map. MSPs aggregate cross-aggregate but
@@ -6,9 +6,12 @@
6
6
  // event-dispatcher.ts. Bun.sql 1.2.20 hat kein listen() (PR oven-sh/bun#25511
7
7
  // pending). Nach Landung des Bun-LISTEN-Supports: peer raus.
8
8
 
9
+ import type { PgListenClient } from "@cosmicdrift/kumiko-types/db-connection";
9
10
  import postgres from "postgres";
10
11
  import { readPositiveIntEnv } from "../utils/env-parse";
11
12
 
13
+ export type { PgListenClient };
14
+
12
15
  // Bun.SQL ist callable als tagged template `sql\`...\`` PLUS hat methods
13
16
  // (.begin / .unsafe / .end / .file / .reserve etc.). DbConnection-Type
14
17
  // reflektiert die Instance-Shape.
@@ -21,9 +24,6 @@ export type BunDbTx = BunDbConnection;
21
24
  // Beide austauschbar im normalen call-path.
22
25
  export type BunDbRunner = BunDbConnection | BunDbTx;
23
26
 
24
- // Postgres-js peer NUR für event-dispatcher LISTEN.
25
- export type PgListenClient = ReturnType<typeof postgres>;
26
-
27
27
  export type BunDbConnectionOptions = {
28
28
  readonly maxConnections?: number;
29
29
  readonly idleTimeoutSeconds?: number;
@@ -50,7 +50,7 @@ describe("event-store-executor write-verbs — entity-level ownership_denied", (
50
50
  });
51
51
 
52
52
  beforeAll(async () => {
53
- await unsafeCreateEntityTableFor(restrictedEntity, "esWriteRestricted");
53
+ await unsafeCreateEntityTable(testDb.db, restrictedEntity, "esWriteRestricted");
54
54
  });
55
55
 
56
56
  beforeEach(async () => {
@@ -158,7 +158,7 @@ describe("event-store-executor write-verbs — restore without softDelete", () =
158
158
  });
159
159
 
160
160
  beforeAll(async () => {
161
- await unsafeCreateEntityTableFor(hardDeleteEntity, "esWriteHard");
161
+ await unsafeCreateEntityTable(testDb.db, hardDeleteEntity, "esWriteHard");
162
162
  });
163
163
 
164
164
  beforeEach(async () => {
@@ -199,7 +199,7 @@ describe("event-store-executor write-verbs — field-level ownership_denied", ()
199
199
  });
200
200
 
201
201
  beforeAll(async () => {
202
- await unsafeCreateEntityTableFor(ownedFieldEntity, "esWriteOwnedField");
202
+ await unsafeCreateEntityTable(testDb.db, ownedFieldEntity, "esWriteOwnedField");
203
203
  });
204
204
 
205
205
  beforeEach(async () => {
@@ -264,7 +264,7 @@ describe("event-store-executor write-verbs — version_conflict edge cases", ()
264
264
  });
265
265
 
266
266
  beforeAll(async () => {
267
- await unsafeCreateEntityTableFor(versionEntity, "esWriteVersion");
267
+ await unsafeCreateEntityTable(testDb.db, versionEntity, "esWriteVersion");
268
268
  });
269
269
 
270
270
  beforeEach(async () => {
@@ -298,13 +298,6 @@ describe("event-store-executor write-verbs — version_conflict edge cases", ()
298
298
  });
299
299
  });
300
300
 
301
- async function unsafeCreateEntityTableFor(
302
- entity: Parameters<typeof buildEntityTable>[1],
303
- name: string,
304
- ): Promise<void> {
305
- await unsafeCreateEntityTable(testDb.db, entity, name);
306
- }
307
-
308
301
  // =============================================================================
309
302
  // Concurrent update race → EventStoreVersionConflict catch + entityCache.del
310
303
  // on forget/restore (create/update/delete already exercise cache in the
@@ -341,7 +334,7 @@ describe("event-store-executor write-verbs — concurrent version race + cache",
341
334
  });
342
335
 
343
336
  beforeAll(async () => {
344
- await unsafeCreateEntityTableFor(raceEntity, "esWriteRace");
337
+ await unsafeCreateEntityTable(testDb.db, raceEntity, "esWriteRace");
345
338
  });
346
339
 
347
340
  beforeEach(async () => {
package/src/db/index.ts CHANGED
@@ -35,8 +35,6 @@ export {
35
35
  enrichRowWithReferences,
36
36
  enrichWithReferences,
37
37
  } from "./eagerload";
38
- export type { EncryptionProvider } from "./encryption";
39
- export { createEncryptionProvider } from "./encryption";
40
38
  export {
41
39
  collectEncryptedFieldNames,
42
40
  configuredEntityFieldEncryption,
@@ -99,22 +99,6 @@ export async function selectAggregateMaxVersion(db: AnyDb, aggregateId: string):
99
99
  return rows[0]?.v ?? 0;
100
100
  }
101
101
 
102
- /** tenant_id the aggregate's events were written under — no membership/tenant
103
- * filter. A r.systemScope() aggregate (e.g. user) lives in whichever tenant
104
- * its creating executor used, which need not be a tenant the subject holds a
105
- * membership in. Returns null for unknown streams. */
106
- export async function selectAggregateStreamTenant(
107
- db: AnyDb,
108
- aggregateId: string,
109
- aggregateType: string,
110
- ): Promise<string | null> {
111
- const rows = (await asRawClient(db).unsafe(
112
- `SELECT "tenant_id" AS t FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "aggregate_type" = $2 ORDER BY "version" LIMIT 1`,
113
- [aggregateId, aggregateType],
114
- )) as ReadonlyArray<{ t: string | null }>;
115
- return rows[0]?.t ?? null;
116
- }
117
-
118
102
  export async function selectEventsHighWaterMark(db: AnyDb): Promise<bigint> {
119
103
  const rows = (await asRawClient(db).unsafe(
120
104
  `SELECT COALESCE(MAX("id"), 0)::bigint AS max FROM "kumiko_events"`,
@@ -599,6 +599,20 @@ describe("boot-validator", () => {
599
599
  expect(() => validateBoot(features)).toThrow(/a:stream:chat:complete.*missing an access rule/i);
600
600
  });
601
601
 
602
+ test("object-form streamHandler with access rule passes boot", () => {
603
+ const features = [
604
+ defineFeature("a", (r) => {
605
+ r.streamHandler({
606
+ name: "chat:complete",
607
+ schema: z.object({}),
608
+ handler: async function* () {},
609
+ access: { roles: ["User"] },
610
+ });
611
+ }),
612
+ ];
613
+ expect(() => validateBoot(features)).not.toThrow();
614
+ });
615
+
602
616
  test("accepts openToAll access rule on a stream handler", () => {
603
617
  const features = [
604
618
  defineFeature("a", (r) => {
@@ -139,6 +139,42 @@ describe("getAllStreamHandlers", () => {
139
139
  });
140
140
  expect(() => createRegistry([aiFeature, otherFeature])).not.toThrow();
141
141
  });
142
+
143
+ test("two distinct feature names that kebab-collide throw on the qualified stream-handler clash", () => {
144
+ // "registry-test-kebab-dup" and "registryTestKebabDup" are different raw
145
+ // feature.name values (so the earlier Duplicate-feature guard doesn't
146
+ // fire) but toKebab() collapses both to the same qualified name.
147
+ const featureA = defineFeature("registry-test-kebab-dup", (r) => {
148
+ r.streamHandler("chat:complete", z.object({}), async function* () {});
149
+ });
150
+ const featureB = defineFeature("registryTestKebabDup", (r) => {
151
+ r.streamHandler("chat:complete", z.object({}), async function* () {});
152
+ });
153
+ expect(() => createRegistry([featureA, featureB])).toThrow(/Duplicate stream handler/);
154
+ });
155
+
156
+ test("object-form streamHandler registration preserves schema/access/rateLimit", () => {
157
+ const schema = z.object({ prompt: z.string() });
158
+ const handlerFn = async function* () {};
159
+ const feature = defineFeature("registry-test-object-form", (r) => {
160
+ r.streamHandler({
161
+ name: "chat:complete",
162
+ schema,
163
+ handler: handlerFn,
164
+ access: { openToAll: true },
165
+ rateLimit: { per: "ip+handler", limit: 5, windowSeconds: 60 },
166
+ });
167
+ });
168
+
169
+ const registry = createRegistry([feature]);
170
+ const registered = registry.getStreamHandler("registry-test-object-form:stream:chat:complete");
171
+
172
+ expect(registered).toBeDefined();
173
+ expect(registered?.schema).toBe(schema);
174
+ expect(registered?.handler).toBe(handlerFn);
175
+ expect(registered?.access).toEqual({ openToAll: true });
176
+ expect(registered?.rateLimit).toEqual({ per: "ip+handler", limit: 5, windowSeconds: 60 });
177
+ });
142
178
  });
143
179
 
144
180
  describe("extensionSelector boot-validation", () => {
@@ -101,32 +101,21 @@ const USER_BUCKETED_RATE_LIMIT_PER: ReadonlySet<string> = new Set(["user", "user
101
101
  // at runtime, but we fail at boot to turn an easy-to-miss security regression
102
102
  // into a loud configuration error.
103
103
  export function validateHandlerAccess(feature: FeatureDefinition): void {
104
- for (const [name, handler] of Object.entries(feature.writeHandlers)) {
105
- if (!handler.access) {
106
- throw new Error(
107
- `Write handler "${feature.name}:write:${name}" is missing an access rule. ` +
108
- `Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
109
- );
110
- }
111
- validateAnonymousRateLimit(feature.name, "write", name, handler.access, handler.rateLimit);
112
- }
113
- for (const [name, handler] of Object.entries(feature.queryHandlers)) {
114
- if (!handler.access) {
115
- throw new Error(
116
- `Query handler "${feature.name}:query:${name}" is missing an access rule. ` +
117
- `Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
118
- );
119
- }
120
- validateAnonymousRateLimit(feature.name, "query", name, handler.access, handler.rateLimit);
121
- }
122
- for (const [name, handler] of Object.entries(feature.streamHandlers)) {
123
- if (!handler.access) {
124
- throw new Error(
125
- `Stream handler "${feature.name}:stream:${name}" is missing an access rule. ` +
126
- `Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
127
- );
104
+ const kinds = [
105
+ { kind: "write" as const, label: "Write", handlers: feature.writeHandlers },
106
+ { kind: "query" as const, label: "Query", handlers: feature.queryHandlers },
107
+ { kind: "stream" as const, label: "Stream", handlers: feature.streamHandlers },
108
+ ];
109
+ for (const { kind, label, handlers } of kinds) {
110
+ for (const [name, handler] of Object.entries(handlers)) {
111
+ if (!handler.access) {
112
+ throw new Error(
113
+ `${label} handler "${feature.name}:${kind}:${name}" is missing an access rule. ` +
114
+ `Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
115
+ );
116
+ }
117
+ validateAnonymousRateLimit(feature.name, kind, name, handler.access, handler.rateLimit);
128
118
  }
129
- validateAnonymousRateLimit(feature.name, "stream", name, handler.access, handler.rateLimit);
130
119
  }
131
120
  }
132
121
 
@@ -258,6 +258,7 @@ describe("render → parse roundtrip — mixed patterns (header data + opaque bo
258
258
  // from the parsed FeaturePattern shape alone.
259
259
  const RAW_REF_FEATURE = `
260
260
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
261
+ import { chatCompleteHandler } from "./handlers";
261
262
 
262
263
  const eventEntity = {
263
264
  fields: { name: { type: "text", required: true } },
@@ -284,6 +285,7 @@ defineFeature("refs", (r) => {
284
285
  r.entity("event", eventEntity);
285
286
  r.entity("task", { fields: buildFields() });
286
287
  r.writeHandler(makeHandler());
288
+ r.streamHandler(chatCompleteHandler);
287
289
  r.screen(eventListScreen);
288
290
  });
289
291
  `;
@@ -297,6 +299,7 @@ describe("render → parse roundtrip — unresolved references (raw-ref sentinel
297
299
  { kind: "entity", entityName: "event", definition: { __raw: "eventEntity" } },
298
300
  { kind: "entity", entityName: "task", definition: { fields: { __raw: "buildFields()" } } },
299
301
  { kind: "writeHandler", handlerName: undefined },
302
+ { kind: "streamHandler", handlerName: undefined },
300
303
  { kind: "screen", definition: { __raw: "eventListScreen" } },
301
304
  ]);
302
305
  });
@@ -310,6 +313,7 @@ describe("render → parse roundtrip — unresolved references (raw-ref sentinel
310
313
  expect(rendered).toContain("eventEntity");
311
314
  expect(rendered).toContain("buildFields()");
312
315
  expect(rendered).toContain("r.writeHandler(makeHandler())");
316
+ expect(rendered).toContain("r.streamHandler(chatCompleteHandler)");
313
317
  expect(rendered).toContain("r.screen(eventListScreen);");
314
318
  // Would only appear if buildFields()'s return value got inlined.
315
319
  expect(rendered).not.toContain("title:");
@@ -204,21 +204,24 @@ export function extractWriteHandler(
204
204
  });
205
205
  }
206
206
 
207
- export function extractQueryHandler(
208
- call: CallExpression,
209
- sourceFile: SourceFile,
210
- ): ExtractOutput<QueryHandlerPattern> {
211
- const parsed = parseHandlerCall(call, sourceFile, "queryHandler");
212
- if (parsed.kind === "error") return parsed;
213
- return ok({
214
- kind: "queryHandler",
207
+ function readHandlerFields(parsed: Extract<ExtractOutput<ParsedHandlerCall>, { kind: "pattern" }>) {
208
+ return {
215
209
  source: parsed.pattern.source,
216
210
  handlerName: parsed.pattern.handlerName,
217
211
  schemaSource: parsed.pattern.schemaSource,
218
212
  handlerBody: parsed.pattern.handlerBody,
219
213
  ...(parsed.pattern.access !== undefined && { access: parsed.pattern.access }),
220
214
  ...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
221
- });
215
+ };
216
+ }
217
+
218
+ export function extractQueryHandler(
219
+ call: CallExpression,
220
+ sourceFile: SourceFile,
221
+ ): ExtractOutput<QueryHandlerPattern> {
222
+ const parsed = parseHandlerCall(call, sourceFile, "queryHandler");
223
+ if (parsed.kind === "error") return parsed;
224
+ return ok({ kind: "queryHandler", ...readHandlerFields(parsed) });
222
225
  }
223
226
 
224
227
  export function extractStreamHandler(
@@ -227,13 +230,5 @@ export function extractStreamHandler(
227
230
  ): ExtractOutput<StreamHandlerPattern> {
228
231
  const parsed = parseHandlerCall(call, sourceFile, "streamHandler");
229
232
  if (parsed.kind === "error") return parsed;
230
- return ok({
231
- kind: "streamHandler",
232
- source: parsed.pattern.source,
233
- handlerName: parsed.pattern.handlerName,
234
- schemaSource: parsed.pattern.schemaSource,
235
- handlerBody: parsed.pattern.handlerBody,
236
- ...(parsed.pattern.access !== undefined && { access: parsed.pattern.access }),
237
- ...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
238
- });
233
+ return ok({ kind: "streamHandler", ...readHandlerFields(parsed) });
239
234
  }
@@ -564,7 +564,11 @@ export function validateEntityHookTargets(
564
564
  }
565
565
 
566
566
  export function validateJobTriggers(state: RegistryState): void {
567
- // Validate: job event triggers must reference existing handlers.
567
+ // Validate: job event triggers must reference an existing write/query
568
+ // handler OR an existing r.defineEvent registration. The latter is
569
+ // delivered async via the job-trigger event-consumer (server.ts), not
570
+ // the synchronous write-handler dispatch path — see
571
+ // createJobTriggerEventConsumer in pipeline/system-hooks.ts.
568
572
  // Multi-Trigger-Form: jeden Eintrag im Array gegen allHandlers prüfen,
569
573
  // auch wenn nur einer fehlt fail-fast.
570
574
  const allHandlers = allHandlerQns(state);
@@ -575,8 +579,9 @@ export function validateJobTriggers(state: RegistryState): void {
575
579
  for (const t of triggers) {
576
580
  const rawName = resolveName(t);
577
581
  if (allHandlers.has(rawName)) continue;
582
+ if (state.eventMap.has(rawName)) continue;
578
583
  throw new Error(
579
- `Job "${jobName}" triggers on "${rawName}" but no handler with that name exists`,
584
+ `Job "${jobName}" triggers on "${rawName}" but no handler or event with that name exists`,
580
585
  );
581
586
  }
582
587
  }
@@ -1,7 +1,5 @@
1
1
  // Barrel: re-exports all types from @cosmicdrift/kumiko-types, plus the
2
2
  // runtime helpers below that stay framework-side.
3
- // Duplicate types (OnDeleteStrategy, ConfigScope, ConcurrencyMode, LifecycleHookType)
4
- // are defined ONLY in constants.ts — re-exported here for backwards compatibility.
5
3
 
6
4
  export type {
7
5
  ConfigAccessor,
@@ -200,20 +200,34 @@ describe("event-store: idempotency-key conflict", () => {
200
200
  });
201
201
 
202
202
  test("omitting idempotencyKey allows unlimited appends, unchanged from before", async () => {
203
- const events = await Promise.all(
204
- Array.from({ length: 3 }, () =>
205
- append(testDb.db, {
206
- aggregateId: uuid(),
207
- aggregateType: "task",
208
- tenantId: tenantA,
209
- expectedVersion: 0,
210
- type: "task.created",
211
- payload: {},
212
- metadata: { userId: userA },
213
- }),
214
- ),
215
- );
216
- expect(events).toHaveLength(3);
203
+ // Same tenant for both, unlike the different-aggregateId version this
204
+ // replaces Postgres treats every NULL as distinct even in a plain
205
+ // unique index, so this does NOT exercise the partial index's `WHERE
206
+ // ... IS NOT NULL` clause specifically (that's provable only by a
207
+ // duplicate NON-null key, covered above). What this does pin: a fully-
208
+ // omitted key and an explicit `idempotencyKey: undefined` both serialize
209
+ // to a JSON-absent key (JSON.stringify drops undefined) and must behave
210
+ // identically, rather than one silently colliding on `"idempotencyKey":null`.
211
+ const omitted = await append(testDb.db, {
212
+ aggregateId: uuid(),
213
+ aggregateType: "task",
214
+ tenantId: tenantA,
215
+ expectedVersion: 0,
216
+ type: "task.created",
217
+ payload: {},
218
+ metadata: { userId: userA },
219
+ });
220
+ const explicitUndefined = await append(testDb.db, {
221
+ aggregateId: uuid(),
222
+ aggregateType: "task",
223
+ tenantId: tenantA,
224
+ expectedVersion: 0,
225
+ type: "task.created",
226
+ payload: {},
227
+ metadata: { userId: userA, idempotencyKey: undefined },
228
+ });
229
+ expect(omitted.version).toBe(1);
230
+ expect(explicitUndefined.version).toBe(1);
217
231
  });
218
232
  });
219
233
 
@@ -1,14 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { Glob } from "bun";
3
3
 
4
- // getUnscopedAggregateStreamMaxVersion / getUnscopedAggregateStreamTenant have
5
- // no tenant filter — a caller can use them to probe whether a foreign tenant's
6
- // aggregate exists (see event-store.ts SECURITY doc). Restricted to known
7
- // seed/system-internal callers; extend only for genuine new ones.
8
- const RESTRICTED_SYMBOLS = [
9
- "getUnscopedAggregateStreamMaxVersion",
10
- "getUnscopedAggregateStreamTenant",
11
- ];
4
+ // getUnscopedAggregateStreamMaxVersion has no tenant filter — a caller can use
5
+ // it to probe whether a foreign tenant's aggregate exists (see event-store.ts
6
+ // SECURITY doc). Restricted to known seed/system-internal callers; extend
7
+ // only for genuine new ones.
8
+ const RESTRICTED_SYMBOLS = ["getUnscopedAggregateStreamMaxVersion"];
12
9
 
13
10
  const ALLOWED_FILES = new Set([
14
11
  "packages/framework/src/event-store/event-store.ts",
@@ -6,7 +6,6 @@ import {
6
6
  insertSubsequentEventRow,
7
7
  notifyPgChannel,
8
8
  selectAggregateMaxVersion,
9
- selectAggregateStreamTenant,
10
9
  selectEventsHighWaterMark,
11
10
  selectStreamMaxVersion,
12
11
  } from "../db/queries/event-store";
@@ -260,21 +259,6 @@ export async function getUnscopedAggregateStreamMaxVersion(
260
259
  return selectAggregateMaxVersion(db, aggregateId);
261
260
  }
262
261
 
263
- /** Stream tenant of an aggregate (the tenant_id its events live under), with no
264
- * membership/tenant filter. SECURITY: existence-oracle, same caveat as
265
- * getUnscopedAggregateStreamMaxVersion — seed/system-internal use only. Recovers
266
- * the write target for a systemScope aggregate whose stream tenant isn't one of
267
- * the subject's memberships. Returns null for unknown streams. */
268
- export async function getUnscopedAggregateStreamTenant(
269
- db: DbRunner,
270
- aggregateId: string,
271
- aggregateType: string,
272
- ): Promise<TenantId | null> {
273
- const tenantId = await selectAggregateStreamTenant(db, aggregateId, aggregateType);
274
- // DB-boundary: kumiko_events.tenant_id is a TenantId-shaped uuid column.
275
- return tenantId as TenantId | null;
276
- }
277
-
278
262
  // Global high-water-mark = MAX(events.id). Marten/Wolverine standard for
279
263
  // projection/consumer lag math: lag = HWM - cursor. Single-row aggregate over
280
264
  // the bigserial PK index — sub-millisecond cost. Returns 0n on an empty log
@@ -312,7 +296,7 @@ export async function loadEventsAfterVersion(
312
296
  // prevent.
313
297
  export const LOAD_ALL_EVENTS_ROW_LIMIT = 100_000;
314
298
 
315
- /** @deprecated buffers ALL matching events in memory — a memory cliff for large stores. Use `streamAllEventsByType` (yields batchwise) instead. */
299
+ /** Buffers ALL matching events in memory — a memory cliff for large stores. Use `streamAllEventsByType` (yields batchwise) for production reads; this is test-only in practice. */
316
300
  export async function loadAllEventsByType(
317
301
  db: DbRunner,
318
302
  aggregateType: string,
@@ -21,7 +21,6 @@ export {
21
21
  getEventsHighWaterMark,
22
22
  getStreamVersion,
23
23
  getUnscopedAggregateStreamMaxVersion,
24
- getUnscopedAggregateStreamTenant,
25
24
  LOAD_ALL_EVENTS_ROW_LIMIT,
26
25
  loadAggregate,
27
26
  loadAggregateAsOf,
@@ -2,9 +2,12 @@ import { describe, expect, test } from "bun:test";
2
2
  import { z } from "zod";
3
3
  import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
4
4
  import type { TenantId } from "../../engine/types/identifiers";
5
+ import { createSecret } from "../../secrets/types";
5
6
  import { createTestUser } from "../../stack";
6
7
  import { createDispatcher } from "../dispatcher";
7
8
 
9
+ const streamCleanupState = { closed: false };
10
+
8
11
  const echoFeature = defineFeature("echo", (r) => {
9
12
  r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
10
13
 
@@ -36,6 +39,29 @@ const echoFeature = defineFeature("echo", (r) => {
36
39
  { access: { roles: ["Admin"] } },
37
40
  );
38
41
 
42
+ r.streamHandler(
43
+ "item:tail-leak",
44
+ z.object({}),
45
+ async function* () {
46
+ yield { apiKey: createSecret("leak-me") };
47
+ },
48
+ { access: { roles: ["Admin"] } },
49
+ );
50
+
51
+ r.streamHandler(
52
+ "item:tail-cleanup",
53
+ z.object({}),
54
+ async function* () {
55
+ try {
56
+ yield { i: 0 };
57
+ yield { i: 1 };
58
+ } finally {
59
+ streamCleanupState.closed = true;
60
+ }
61
+ },
62
+ { access: { roles: ["Admin"] } },
63
+ );
64
+
39
65
  r.hook("validation", "item:create", (data) => {
40
66
  if (data["name"] === "forbidden") return [{ field: "name", error: "forbidden_name" }];
41
67
  return null;
@@ -272,6 +298,32 @@ describe("dispatcher.stream", () => {
272
298
  collect(dispatcher.stream("nonexistent", {}, createTestUser({ roles: ["Admin"] }))),
273
299
  ).rejects.toMatchObject({ code: "not_found", httpStatus: 404 });
274
300
  });
301
+
302
+ test("a chunk containing a Secret<> value aborts the stream instead of leaking it", async () => {
303
+ const dispatcher = createTestDispatcher();
304
+
305
+ await expect(
306
+ collect(
307
+ dispatcher.stream("echo:stream:item:tail-leak", {}, createTestUser({ roles: ["Admin"] })),
308
+ ),
309
+ ).rejects.toMatchObject({ message: expect.stringContaining("Secret<> leaked") });
310
+ });
311
+
312
+ test("consumer breaking out of for-await runs the handler generator's finally block", async () => {
313
+ streamCleanupState.closed = false;
314
+ const dispatcher = createTestDispatcher();
315
+ const gen = dispatcher.stream(
316
+ "echo:stream:item:tail-cleanup",
317
+ {},
318
+ createTestUser({ roles: ["Admin"] }),
319
+ );
320
+
321
+ for await (const _chunk of gen) {
322
+ break;
323
+ }
324
+
325
+ expect(streamCleanupState.closed).toBe(true);
326
+ });
275
327
  });
276
328
 
277
329
  // --- postQuery hooks on standalone (entity-less) queries ---
@@ -0,0 +1,106 @@
1
+ // createJobTriggerEventConsumer — proves r.job's trigger.on can fire on an
2
+ // r.defineEvent QN appended by a multiStreamProjection's unsafeAppendEvent
3
+ // (kumiko-framework#1505). Mirrors document-ingest-foundation's actual
4
+ // request-ingest MSP (upload → fileRef.created → an owned defineEvent),
5
+ // the motivating case for this fix — fileRef.created itself never reaches
6
+ // jobRunner.handleEvent because the upload route appends it via the raw
7
+ // event-store executor, not a write-handler dispatch (see #1505).
8
+ //
9
+ // Not covered here: a job triggered on a write/query-handler QN still
10
+ // firing exactly once (unaffected by the new consumer). The full suite
11
+ // stays green (e.g. the lane-routing sample), but that's not a positive
12
+ // test of the partition guard — no stored event's `type` is ever a
13
+ // handler QN in practice (entity events are "entity.verb"; custom
14
+ // write-handlers like lane-routing's don't append to the store at all),
15
+ // so `getWriteHandler`/`getQueryHandler` in the new consumer's handler is
16
+ // defense-in-depth for an input shape the framework doesn't currently
17
+ // produce, not something exercised end-to-end by any test today.
18
+
19
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
20
+ import { z } from "zod";
21
+ import { entityEventName } from "../../db";
22
+ import { defineFeature } from "../../engine";
23
+ import { createInMemoryFileProvider, type InMemoryFileProvider } from "../../files";
24
+ import { setupTestStack, type TestStack, TestUsers } from "../../stack";
25
+ import { waitFor } from "../../testing";
26
+
27
+ const ITEM_REQUESTED_EVENT_QN = "job-trigger-fixture:event:item-requested";
28
+ const FILE_REF_CREATED = entityEventName("fileRef", "created");
29
+
30
+ const processedItems: Array<{ readonly fileRefId: string }> = [];
31
+
32
+ const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
33
+ r.defineEvent("item-requested", z.object({ fileRefId: z.string().min(1) }));
34
+
35
+ // Mirrors document-ingest-foundation's request-ingest MSP exactly: reacts
36
+ // to fileRef.created, appends a NEW event via unsafeAppendEvent — no
37
+ // write-handler behind the appended event itself.
38
+ r.multiStreamProjection({
39
+ name: "request-item",
40
+ apply: {
41
+ [FILE_REF_CREATED]: async (event, _tx, ctx) => {
42
+ await ctx.unsafeAppendEvent({
43
+ aggregateId: event.aggregateId,
44
+ aggregateType: "job-trigger-fixture-request",
45
+ type: ITEM_REQUESTED_EVENT_QN,
46
+ payload: { fileRefId: event.aggregateId },
47
+ });
48
+ },
49
+ },
50
+ });
51
+
52
+ // Under test: only reachable via createJobTriggerEventConsumer, since
53
+ // ITEM_REQUESTED_EVENT_QN is an r.defineEvent QN, not a handler QN.
54
+ r.job(
55
+ "process-item",
56
+ { trigger: { on: ITEM_REQUESTED_EVENT_QN }, runIn: "worker" },
57
+ async (payload) => {
58
+ processedItems.push({ fileRefId: payload["fileRefId"] as string });
59
+ },
60
+ );
61
+ });
62
+
63
+ let stack: TestStack;
64
+ let provider: InMemoryFileProvider;
65
+
66
+ beforeAll(async () => {
67
+ provider = createInMemoryFileProvider();
68
+ stack = await setupTestStack({
69
+ features: [jobTriggerFixtureFeature],
70
+ files: { storageProvider: provider },
71
+ jobs: { consumerLane: "worker" },
72
+ });
73
+ });
74
+
75
+ afterAll(async () => {
76
+ await stack.cleanup();
77
+ });
78
+
79
+ beforeEach(() => {
80
+ processedItems.length = 0;
81
+ provider.clear();
82
+ });
83
+
84
+ describe("job-trigger event consumer", () => {
85
+ test("a job triggers on an r.defineEvent QN appended by an MSP's unsafeAppendEvent", async () => {
86
+ const token = await stack.jwt.sign(TestUsers.admin);
87
+ const formData = new FormData();
88
+ formData.append("file", new File([Buffer.from("hello")], "note.txt", { type: "text/plain" }));
89
+ const res = await stack.app.request("/api/files", {
90
+ method: "POST",
91
+ headers: { Authorization: `Bearer ${token}` },
92
+ body: formData,
93
+ });
94
+ expect(res.status).toBe(201);
95
+
96
+ await waitFor(async () => {
97
+ // Drives both the MSP (appends item-requested off fileRef.created)
98
+ // and the new job-trigger consumer (reacts to it) — may need more
99
+ // than one pass since the MSP's append happens mid-drain.
100
+ await stack.eventDispatcher?.runOnce();
101
+ expect(processedItems).toHaveLength(1);
102
+ });
103
+
104
+ expect(processedItems[0]?.fileRefId).toBeTruthy();
105
+ });
106
+ });
@@ -707,30 +707,43 @@ describe("runPostSaveBatch / runPostDeleteBatch", () => {
707
707
  expect(seen).toEqual([[deletectx]]);
708
708
  });
709
709
 
710
- test("one batch hook throwing doesn't stop the others (Promise.allSettled) — logged, never thrown", async () => {
710
+ test.each([
711
+ [
712
+ "postSaveBatch",
713
+ (hooks: { name: string; priority: number; fn: () => Promise<void> }[]) =>
714
+ ({ postSaveBatch: hooks }) satisfies SystemHooks,
715
+ (pipeline: ReturnType<typeof createLifecycleHooks>) =>
716
+ pipeline.runPostSaveBatch([savectx], {}),
717
+ ],
718
+ [
719
+ "postDeleteBatch",
720
+ (hooks: { name: string; priority: number; fn: () => Promise<void> }[]) =>
721
+ ({ postDeleteBatch: hooks }) satisfies SystemHooks,
722
+ (pipeline: ReturnType<typeof createLifecycleHooks>) =>
723
+ pipeline.runPostDeleteBatch([deletectx], {}),
724
+ ],
725
+ ])("one %s hook throwing doesn't stop the others (Promise.allSettled) — logged, never thrown", async (_name, buildHooks, run) => {
711
726
  const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
712
727
  const calls: string[] = [];
713
- const systemHooks: SystemHooks = {
714
- postSaveBatch: [
715
- {
716
- name: "failing",
717
- priority: 1000,
718
- fn: async () => {
719
- throw new Error("batch-hook-boom");
720
- },
728
+ const systemHooks = buildHooks([
729
+ {
730
+ name: "failing",
731
+ priority: 1000,
732
+ fn: async () => {
733
+ throw new Error("batch-hook-boom");
721
734
  },
722
- {
723
- name: "ok",
724
- priority: 1001,
725
- fn: async () => {
726
- calls.push("ok-ran");
727
- },
735
+ },
736
+ {
737
+ name: "ok",
738
+ priority: 1001,
739
+ fn: async () => {
740
+ calls.push("ok-ran");
728
741
  },
729
- ],
730
- };
742
+ },
743
+ ]);
731
744
  const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
732
745
  // Must not throw.
733
- await pipeline.runPostSaveBatch([savectx], {});
746
+ await run(pipeline);
734
747
  expect(calls).toEqual(["ok-ran"]);
735
748
  expect(consoleSpy).toHaveBeenCalled();
736
749
  consoleSpy.mockRestore();
@@ -1,4 +1,3 @@
1
- import type { DbTx } from "../db/connection";
2
1
  import { hasAccess } from "../engine/access";
3
2
  import type { SessionUser } from "../engine/types";
4
3
  import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors";
@@ -22,11 +21,8 @@ export async function* executeStream(
22
21
  type: string,
23
22
  payload: unknown,
24
23
  user: SessionUser,
25
- tx?: DbTx,
26
24
  ): AsyncGenerator<unknown> {
27
- yield* runStreamInstrumented(ctx, type, user, () =>
28
- executeStreamInner(ctx, type, payload, user, tx),
29
- );
25
+ yield* runStreamInstrumented(ctx, type, user, () => executeStreamInner(ctx, type, payload, user));
30
26
  }
31
27
 
32
28
  async function* executeStreamInner(
@@ -34,7 +30,6 @@ async function* executeStreamInner(
34
30
  type: string,
35
31
  payload: unknown,
36
32
  user: SessionUser,
37
- tx?: DbTx,
38
33
  ): AsyncGenerator<unknown> {
39
34
  const { registry } = ctx;
40
35
  const handler = registry.getStreamHandler(type);
@@ -58,7 +53,7 @@ async function* executeStreamInner(
58
53
  throw validationErrorFromZod(parsed.error);
59
54
  }
60
55
 
61
- const handlerContext = buildHandlerContext(ctx, type, user, tx);
56
+ const handlerContext = buildHandlerContext(ctx, type, user);
62
57
  const chunks = handler.handler({ type, payload: parsed.data, user }, handlerContext);
63
58
 
64
59
  // Consumer-driven pull (for await) is the backpressure mechanism — the
@@ -194,7 +194,8 @@ export type DeliveryOutcome = {
194
194
 
195
195
  // Deliver events to the consumer's handler in events.id order. Halt-on-
196
196
  // poison: a throw breaks the loop, the cursor stays at the last successful
197
- // event, and attempts climb. At maxAttempts the caller persists status=
197
+ // event, and attempts climb. At the consumer's effectiveMaxAttempts
198
+ // (errorPolicy.maxAttempts ?? maxAttempts) the caller persists status=
198
199
  // "dead" and the consumer is parked until ops intervenes (see
199
200
  // restartConsumer / skipPoisonEvent).
200
201
  export async function deliverEvents(
@@ -1,7 +1,7 @@
1
1
  import type { SseBroker } from "../api/sse-broker";
2
2
  import type { DbRow } from "../db/connection";
3
3
  import { tenantChannel } from "../engine/constants";
4
- import type { EntityId, Registry } from "../engine/types";
4
+ import type { EntityId, JobRunnerRef, Registry, SessionUser } from "../engine/types";
5
5
  import type { SearchAdapter, SearchDocument } from "../search/types";
6
6
  import type { EventConsumer } from "./event-dispatcher";
7
7
 
@@ -249,3 +249,52 @@ export function createSseBroadcastEventConsumer(sseBroker: SseBroker): EventCons
249
249
  },
250
250
  };
251
251
  }
252
+
253
+ // --- Job-Trigger Consumer (async, via event-dispatcher) ---
254
+ //
255
+ // r.job's `trigger.on` historically only fired via the synchronous
256
+ // write-handler dispatch path (dispatch-write.ts's afterCommitHooks calling
257
+ // jobRunner.handleEvent). Events appended any other way — an
258
+ // r.multiStreamProjection's ctx.unsafeAppendEvent, or a raw
259
+ // event-store-executor write (e.g. `files`' fileRef.created) — never
260
+ // reached it, so a job could never trigger on an r.defineEvent-registered
261
+ // event (kumiko-framework#1505).
262
+ //
263
+ // This consumer closes that gap the same way search/SSE do: read every
264
+ // committed event off the shared cursor and re-check job triggers. It is
265
+ // scoped EXACTLY to the gap — the write/query-handler-QN skip below is
266
+ // defense-in-depth: no stored event's `type` is a handler QN today (entity
267
+ // events are "entity.verb"; ctx.appendEvent enforces defineEvent-only
268
+ // ownership), but if that ever changes, this consumer must not re-fire a
269
+ // trigger the synchronous dispatch-write.ts path already handled.
270
+ //
271
+ // Delivery is at-least-once (cursor semantics) where the synchronous path
272
+ // is effectively once — job handlers reached via an r.defineEvent trigger
273
+ // must be idempotent (same expectation r.multiStreamProjection applies
274
+ // already carry).
275
+ export const JOB_TRIGGER_CONSUMER_NAME = "system:consumer:job-trigger";
276
+
277
+ export function createJobTriggerEventConsumer(
278
+ jobRunner: JobRunnerRef,
279
+ registry: Registry,
280
+ ): EventConsumer {
281
+ return {
282
+ name: JOB_TRIGGER_CONSUMER_NAME,
283
+ handler: async (event) => {
284
+ // skip: write/query-handler QN — already dispatched synchronously by
285
+ // dispatch-write.ts's afterCommitHooks. Re-firing here would
286
+ // double-enqueue every existing handler-triggered job.
287
+ if (registry.getWriteHandler(event.type) || registry.getQueryHandler(event.type)) return;
288
+ // skip: no r.defineEvent registered under this type — nothing this
289
+ // consumer is responsible for.
290
+ if (!registry.getEvent(event.type)) return;
291
+
292
+ const user: SessionUser = {
293
+ id: event.metadata.userId,
294
+ tenantId: event.tenantId,
295
+ roles: [],
296
+ };
297
+ await jobRunner.handleEvent(event.type, event.payload, user);
298
+ },
299
+ };
300
+ }
@@ -1,39 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { createEncryptionProvider } from "../encryption";
3
-
4
- // 32 bytes base64-encoded for AES-256
5
- const TEST_KEY = Buffer.from("a]bJm#kP9xQ2@wN!vL$hR5yT8eU0iO3f").toString("base64");
6
-
7
- describe("EncryptionProvider", () => {
8
- test("encrypt + decrypt roundtrip returns original", () => {
9
- const provider = createEncryptionProvider(TEST_KEY);
10
- const ciphertext = provider.encrypt("hello world");
11
- expect(provider.decrypt(ciphertext)).toBe("hello world");
12
- });
13
-
14
- test("same plaintext produces different ciphertexts (random IV)", () => {
15
- const provider = createEncryptionProvider(TEST_KEY);
16
- const a = provider.encrypt("same");
17
- const b = provider.encrypt("same");
18
- expect(a).not.toBe(b);
19
- });
20
-
21
- test("decrypt with different key throws", () => {
22
- const key2 = Buffer.from("x]bJm#kP9xQ2@wN!vL$hR5yT8eU0iO3f").toString("base64");
23
- const p1 = createEncryptionProvider(TEST_KEY);
24
- const p2 = createEncryptionProvider(key2);
25
- const ciphertext = p1.encrypt("secret");
26
- expect(() => p2.decrypt(ciphertext)).toThrow();
27
- });
28
-
29
- test("handles unicode and emoji", () => {
30
- const provider = createEncryptionProvider(TEST_KEY);
31
- const ciphertext = provider.encrypt("Ünïcödé 🔐");
32
- expect(provider.decrypt(ciphertext)).toBe("Ünïcödé 🔐");
33
- });
34
-
35
- test("throws on invalid key length", () => {
36
- const shortKey = Buffer.from("too-short").toString("base64");
37
- expect(() => createEncryptionProvider(shortKey)).toThrow(/32 bytes/);
38
- });
39
- });
@@ -1,45 +0,0 @@
1
- import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
2
-
3
- const ALGORITHM = "aes-256-gcm";
4
- const IV_LENGTH = 12;
5
- const TAG_LENGTH = 16;
6
-
7
- export type EncryptionProvider = {
8
- encrypt(plaintext: string): string;
9
- decrypt(ciphertext: string): string;
10
- };
11
-
12
- /**
13
- * @deprecated Legacy single-key format: base64(iv+tag+ct) with NO key id —
14
- * a key change makes existing ciphertexts permanently undecryptable. Use
15
- * `createEnvelopeCipher` (@cosmicdrift/kumiko-framework/secrets) for new code;
16
- * this provider has no consumers left in the framework.
17
- */
18
- export function createEncryptionProvider(key: string): EncryptionProvider {
19
- // Key must be 32 bytes for AES-256
20
- const keyBuffer = Buffer.from(key, "base64");
21
- if (keyBuffer.length !== 32) {
22
- throw new Error("ENCRYPTION_KEY must be 32 bytes (base64 encoded)");
23
- }
24
-
25
- return {
26
- encrypt(plaintext: string): string {
27
- const iv = randomBytes(IV_LENGTH);
28
- const cipher = createCipheriv(ALGORITHM, keyBuffer, iv);
29
- const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
30
- const tag = cipher.getAuthTag();
31
- // Format: base64(iv + tag + ciphertext)
32
- return Buffer.concat([iv, tag, encrypted]).toString("base64");
33
- },
34
-
35
- decrypt(ciphertext: string): string {
36
- const data = Buffer.from(ciphertext, "base64");
37
- const iv = data.subarray(0, IV_LENGTH);
38
- const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
39
- const encrypted = data.subarray(IV_LENGTH + TAG_LENGTH);
40
- const decipher = createDecipheriv(ALGORITHM, keyBuffer, iv);
41
- decipher.setAuthTag(tag);
42
- return decipher.update(encrypted) + decipher.final("utf8");
43
- },
44
- };
45
- }