@cosmicdrift/kumiko-framework 0.163.3 → 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 (32) 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/pg-error.ts +1 -1
  12. package/src/db/queries/event-store.ts +14 -16
  13. package/src/engine/__tests__/boot-validator.test.ts +14 -0
  14. package/src/engine/__tests__/registry.test.ts +36 -0
  15. package/src/engine/boot-validator/entity-handler.ts +14 -25
  16. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +4 -0
  17. package/src/engine/feature-ast/extractors/handlers.ts +13 -18
  18. package/src/engine/registry-validate.ts +7 -2
  19. package/src/engine/types/index.ts +0 -2
  20. package/src/event-store/__tests__/event-store.integration.test.ts +138 -0
  21. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +5 -8
  22. package/src/event-store/event-store.ts +12 -23
  23. package/src/event-store/events-schema.ts +10 -3
  24. package/src/event-store/index.ts +1 -2
  25. package/src/pipeline/__tests__/dispatcher.test.ts +52 -0
  26. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
  27. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +31 -18
  28. package/src/pipeline/dispatch-stream.ts +2 -7
  29. package/src/pipeline/event-dispatcher-delivery.ts +2 -1
  30. package/src/pipeline/system-hooks.ts +50 -1
  31. package/src/db/__tests__/encryption.test.ts +0 -39
  32. 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.163.3",
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.163.3",
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.163.3",
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,
@@ -3,7 +3,7 @@
3
3
  // unwrap both layers so callers don't have to know which layer produced the
4
4
  // error. Used by the event-store to distinguish a unique-violation on the
5
5
  // aggregate-version index (optimistic-concurrency conflict) from the one on
6
- // the request-id idempotency index (replay signal).
6
+ // the idempotency-key index (caller-side replay signal).
7
7
 
8
8
  export type PgErrorInfo = {
9
9
  readonly code: string | undefined;
@@ -6,6 +6,20 @@ export async function notifyPgChannel(db: AnyDb, channel: string): Promise<void>
6
6
  await asRawClient(db).unsafe(`SELECT pg_notify($1, '')`, [channel]);
7
7
  }
8
8
 
9
+ // Tenant-scoped partial unique index over metadata.idempotencyKey.
10
+ // Expression index straight on the jsonb column — no dedicated key column,
11
+ // so it needs no INSERT-path change and covers admin-api's raw appends too
12
+ // (same metadata jsonb). CREATE ... IF NOT EXISTS makes this safe to call
13
+ // on every boot, same "ensure" pattern as ensureSnapshotVersionColumn: heals
14
+ // installs that predate the index without a table rebuild.
15
+ export async function ensureIdempotencyKeyIndex(db: AnyDb): Promise<void> {
16
+ await asRawClient(db).unsafe(
17
+ `CREATE UNIQUE INDEX IF NOT EXISTS "events_idempotency_uq" ON "kumiko_events" ` +
18
+ `("tenant_id", (("metadata"->>'idempotencyKey'))) ` +
19
+ `WHERE "metadata"->>'idempotencyKey' IS NOT NULL`,
20
+ );
21
+ }
22
+
9
23
  export type SubsequentEventInsertParams = {
10
24
  readonly aggregateId: string;
11
25
  readonly aggregateType: string;
@@ -85,22 +99,6 @@ export async function selectAggregateMaxVersion(db: AnyDb, aggregateId: string):
85
99
  return rows[0]?.v ?? 0;
86
100
  }
87
101
 
88
- /** tenant_id the aggregate's events were written under — no membership/tenant
89
- * filter. A r.systemScope() aggregate (e.g. user) lives in whichever tenant
90
- * its creating executor used, which need not be a tenant the subject holds a
91
- * membership in. Returns null for unknown streams. */
92
- export async function selectAggregateStreamTenant(
93
- db: AnyDb,
94
- aggregateId: string,
95
- aggregateType: string,
96
- ): Promise<string | null> {
97
- const rows = (await asRawClient(db).unsafe(
98
- `SELECT "tenant_id" AS t FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "aggregate_type" = $2 ORDER BY "version" LIMIT 1`,
99
- [aggregateId, aggregateType],
100
- )) as ReadonlyArray<{ t: string | null }>;
101
- return rows[0]?.t ?? null;
102
- }
103
-
104
102
  export async function selectEventsHighWaterMark(db: AnyDb): Promise<bigint> {
105
103
  const rows = (await asRawClient(db).unsafe(
106
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