@cosmicdrift/kumiko-framework 0.159.1 → 0.160.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 (84) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/api.test.ts +65 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +1 -0
  4. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
  5. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
  6. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  7. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
  8. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  9. package/src/api/api-constants.ts +1 -0
  10. package/src/api/routes.ts +57 -0
  11. package/src/bun-db/query.ts +12 -25
  12. package/src/crypto/kms-adapter.ts +2 -118
  13. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  14. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +91 -0
  15. package/src/db/cursor.ts +1 -18
  16. package/src/db/dialect.ts +8 -19
  17. package/src/db/entity-table-meta-types.ts +2 -92
  18. package/src/db/event-store-executor.ts +4 -96
  19. package/src/db/table-builder.ts +2 -19
  20. package/src/db/tenant-db.ts +6 -55
  21. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  22. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  23. package/src/engine/__tests__/engine.test.ts +28 -0
  24. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  25. package/src/engine/__tests__/registry.test.ts +40 -0
  26. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  27. package/src/engine/boot-validator/entity-handler.ts +10 -1
  28. package/src/engine/define-feature.ts +1 -0
  29. package/src/engine/define-handler.ts +1 -0
  30. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  31. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  32. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  33. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  34. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  35. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  36. package/src/engine/feature-ast/extractors/index.ts +1 -0
  37. package/src/engine/feature-ast/index.ts +2 -0
  38. package/src/engine/feature-ast/parse.ts +3 -0
  39. package/src/engine/feature-ast/patch.ts +2 -0
  40. package/src/engine/feature-ast/patcher.ts +21 -0
  41. package/src/engine/feature-ast/patterns.ts +16 -0
  42. package/src/engine/feature-ast/render.ts +15 -0
  43. package/src/engine/feature-builder-state.ts +3 -0
  44. package/src/engine/feature-entity-handlers.ts +35 -1
  45. package/src/engine/index.ts +3 -0
  46. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  47. package/src/engine/pattern-library/library.ts +2 -0
  48. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  49. package/src/engine/registry-facade.ts +9 -0
  50. package/src/engine/registry-ingest.ts +10 -0
  51. package/src/engine/registry-state.ts +3 -0
  52. package/src/engine/types/config.ts +2 -497
  53. package/src/engine/types/define-handler.ts +2 -94
  54. package/src/engine/types/entity-handlers.ts +2 -30
  55. package/src/engine/types/feature.ts +2 -1021
  56. package/src/engine/types/fields.ts +2 -685
  57. package/src/engine/types/handlers.ts +2 -820
  58. package/src/engine/types/hooks.ts +2 -170
  59. package/src/engine/types/index.ts +44 -36
  60. package/src/engine/types/nav.ts +2 -67
  61. package/src/engine/types/ownership.ts +2 -83
  62. package/src/engine/types/projection.ts +2 -165
  63. package/src/engine/types/screen.ts +2 -747
  64. package/src/engine/types/step.ts +2 -334
  65. package/src/engine/types/workspace.ts +2 -42
  66. package/src/errors/write-error-info.ts +6 -22
  67. package/src/event-store/errors.ts +2 -35
  68. package/src/event-store/event-store.ts +2 -21
  69. package/src/event-store/snapshot.ts +11 -35
  70. package/src/event-store/types.ts +2 -22
  71. package/src/files/provider-resolver.ts +3 -5
  72. package/src/files/types.ts +5 -54
  73. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  74. package/src/pipeline/__tests__/dispatcher.test.ts +96 -0
  75. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  76. package/src/pipeline/dispatch-shared.ts +39 -1
  77. package/src/pipeline/dispatch-stream.ts +74 -0
  78. package/src/pipeline/dispatcher-utils.ts +1 -1
  79. package/src/pipeline/dispatcher.ts +7 -0
  80. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  81. package/src/rate-limit/resolver.ts +10 -30
  82. package/src/secrets/envelope-cipher.ts +4 -6
  83. package/src/secrets/types.ts +2 -177
  84. package/src/time/tz-context.ts +9 -56
@@ -6,60 +6,11 @@ export type FileMetadata = {
6
6
  readonly size: number;
7
7
  };
8
8
 
9
- // Options for `getSignedUrl`. `contentDisposition` lets the caller hint the
10
- // browser to download-with-name vs inline-display (maps to ResponseContent-
11
- // Disposition on S3). Keep the option-bag small and additive; provider impls
12
- // that don't support a given hint should ignore it rather than error.
13
- export type SignedUrlOptions = {
14
- readonly contentDisposition?: string;
15
- };
16
-
17
- // Options fuer `writeStream`. `mimeType` ist Content-Type-Hint analog zu
18
- // `write`. `contentLength` ist optional fuer Provider die einen Length-
19
- // Header brauchen (S3 multipart hat einen TransferManager, kann auch ohne
20
- // length); local-Provider ignoriert beides.
21
- export type WriteStreamOptions = {
22
- readonly mimeType?: string;
23
- readonly contentLength?: number;
24
- };
25
-
26
- // Primitive storage contract: key+bytes in, bytes out. Metadata (fileName,
27
- // mimeType, size) lives on the FileRef row — the provider only needs to
28
- // shuttle bytes. `mimeType` on write() is a hint for providers that need a
29
- // Content-Type header (S3/R2/…); local filesystems can ignore it.
30
- //
31
- // **Streaming (`writeStream` + `readStream`) ist PFLICHT** — beide
32
- // Methoden sind required, kein optional-feature. Begruendung:
33
- // - User-Data-Export (Atom 3c) braucht beide, sonst silent fail bei
34
- // erstem Job mit fileRefs in Production.
35
- // - Apps die nur kleine Files (Avatar-Uploads, Profile-Pics) handeln,
36
- // koennen trivial via `oneShot`-Pattern den Stream-Contract erfuellen
37
- // (single-chunk yield von write/read-Bytes). 5 Zeilen pro Provider.
38
- // - Optional-Type wuerde TypeScript-Lying erlauben: Type sagt "kann
39
- // fehlen", Worker throws zur Runtime → App-Authors sehen den Bug
40
- // erst in Production. Required + TS-enforced ist ehrlich.
41
- //
42
- // `getSignedUrl` BLEIBT optional: object-store backends (S3/R2/GCS)
43
- // implement it so clients can download directly from the provider after
44
- // the server has checked access — offloads bandwidth and enables browser-
45
- // native caching. Filesystem providers leave it undefined; the route then
46
- // returns 501 and the client falls back to streaming via GET /files/:id.
47
- // Callers must feature-detect via `typeof provider.getSignedUrl === "function"`.
48
- // Hier ist Optional korrekt weil die Fallback-Pfad existiert — kein
49
- // silent-fail, sondern 501 + alternativer download.
50
- export type FileStorageProvider = {
51
- write(key: string, data: Uint8Array, mimeType?: string): Promise<void>;
52
- writeStream(
53
- key: string,
54
- source: AsyncIterable<Uint8Array>,
55
- options?: WriteStreamOptions,
56
- ): Promise<void>;
57
- read(key: string): Promise<Uint8Array>;
58
- readStream(key: string): AsyncIterable<Uint8Array>;
59
- delete(key: string): Promise<void>;
60
- exists(key: string): Promise<boolean>;
61
- getSignedUrl?(key: string, expiresInSeconds: number, options?: SignedUrlOptions): Promise<string>;
62
- };
9
+ export type {
10
+ FileStorageProvider,
11
+ SignedUrlOptions,
12
+ WriteStreamOptions,
13
+ } from "@cosmicdrift/kumiko-types/file-storage-provider-types";
63
14
 
64
15
  export type FileValidationOptions = {
65
16
  readonly maxSize?: string | undefined;
@@ -1,8 +1,9 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
2
3
  import { requestContext } from "../../api/request-context";
3
4
  import { createRegistry, defineFeature } from "../../engine";
4
5
  import type { AppContext, Registry } from "../../engine/types";
5
- import { createTestRedis, type TestRedis } from "../../stack";
6
+ import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
6
7
  import { sleep, waitFor } from "../../testing";
7
8
  import {
8
9
  createJobRunner,
@@ -103,6 +104,30 @@ const testFeature = defineFeature("test", (r) => {
103
104
  await sleep(500);
104
105
  },
105
106
  );
107
+ r.writeHandler(
108
+ "capped-event",
109
+ z.object({ n: z.number() }),
110
+ async (event) => ({ isSuccess: true, data: { n: event.payload.n } }),
111
+ { access: { openToAll: true } },
112
+ );
113
+ // Event-triggered twin — exercises handleEvent's maxPerTenant guard
114
+ // (dispatch and handleEvent share isOverPerTenantLimit).
115
+ r.job(
116
+ "perTenantLimitedEvent",
117
+ {
118
+ trigger: { on: "test:write:capped-event" },
119
+ concurrency: "parallel",
120
+ maxPerTenant: 2,
121
+ },
122
+ async (payload) => {
123
+ jobLog.push({
124
+ name: "test:job:per-tenant-limited-event",
125
+ payload,
126
+ timestamp: Date.now(),
127
+ });
128
+ await sleep(500);
129
+ },
130
+ );
106
131
 
107
132
  // Job that fails
108
133
  r.job("failingJob", { trigger: { manual: true }, retries: 1 }, async () => {
@@ -130,6 +155,18 @@ const testFeature = defineFeature("test", (r) => {
130
155
  timestamp: Date.now(),
131
156
  });
132
157
  });
158
+ // Exercises createJobLogger (info/warn/error/debug/child) — otherwise those
159
+ // one-liners stay uncovered even though every job builds a logger.
160
+ r.job("logProbe", { trigger: { manual: true } }, async (payload, ctx) => {
161
+ expect(ctx.log).toBeDefined();
162
+ const log = ctx.log!;
163
+ log.info("log-probe-info", { n: payload["n"] });
164
+ log.warn("log-probe-warn");
165
+ log.error("log-probe-error", { ok: false });
166
+ log.debug("log-probe-debug");
167
+ log.child({ probe: true }).info("log-probe-child");
168
+ jobLog.push({ name: "test:job:log-probe", payload, timestamp: Date.now() });
169
+ });
133
170
  });
134
171
 
135
172
  beforeAll(async () => {
@@ -423,6 +460,26 @@ describe("concurrency: debounce", () => {
423
460
  });
424
461
  });
425
462
 
463
+ describe("concurrency: replace", () => {
464
+ test("enqueuer-only: each dispatch removes prior waiting peers", async () => {
465
+ // No consumer → jobs stay waiting. The replace branch walks getWaiting()
466
+ // and removes same-name peers before add — so three dispatches leave one.
467
+ await withRunner(
468
+ async (runner) => {
469
+ const id1 = await runner.dispatch("test:job:replace-job", { n: 1 });
470
+ const id2 = await runner.dispatch("test:job:replace-job", { n: 2 });
471
+ const id3 = await runner.dispatch("test:job:replace-job", { n: 3 });
472
+ expect(id1).toBeDefined();
473
+ expect(id2).toBeDefined();
474
+ expect(id3).toBeDefined();
475
+ expect(id1).not.toBe("skipped");
476
+ expect(id3).not.toBe(id1);
477
+ },
478
+ { consumerLane: undefined },
479
+ );
480
+ });
481
+ });
482
+
426
483
  describe("concurrency: maxPerTenant", () => {
427
484
  test("max=2: third dispatch for same tenant returns skipped, other tenant unaffected", async () => {
428
485
  clearLog();
@@ -486,6 +543,18 @@ describe("concurrency: maxPerTenant", () => {
486
543
 
487
544
  // --- Correlation propagation ---
488
545
 
546
+ describe("job logger", () => {
547
+ test("handler can call info/warn/error/debug/child without throw", async () => {
548
+ clearLog();
549
+ await withRunner(async (runner) => {
550
+ await runner.dispatch("test:job:log-probe", { n: 1 });
551
+ await waitFor(() => {
552
+ expect(jobLog.some((e) => e.name === "test:job:log-probe")).toBe(true);
553
+ });
554
+ });
555
+ });
556
+ });
557
+
489
558
  describe("correlation propagation", () => {
490
559
  test("dispatch inside requestContext.run passes correlationId into the job", async () => {
491
560
  clearLog();
@@ -599,6 +668,38 @@ describe("error handling", () => {
599
668
  });
600
669
  });
601
670
  });
671
+
672
+ test("perTenant with getActiveTenantIds fans out one run per tenant", async () => {
673
+ clearLog();
674
+ const tenants = [101, 102];
675
+ await withRunner(
676
+ async (runner) => {
677
+ await runner.dispatch("test:job:per-tenant-fanout", { n: 7 });
678
+ await waitFor(() => {
679
+ const runs = jobLog.filter((e) => e.name === "test:job:per-tenant-fanout");
680
+ expect(runs.length).toBe(2);
681
+ expect(runs.every((e) => e.payload["n"] === 7)).toBe(true);
682
+ });
683
+ },
684
+ { getActiveTenantIds: async () => tenants },
685
+ );
686
+ });
687
+ });
688
+
689
+ describe("handleEvent maxPerTenant", () => {
690
+ test("skips enqueue when tenant is already at the cap", async () => {
691
+ clearLog();
692
+ await withRunner(async (runner) => {
693
+ const user = { ...TestUsers.admin, tenantId: "cap-tenant-evt" };
694
+ await runner.handleEvent("test:write:capped-event", { n: 1 }, user);
695
+ await runner.handleEvent("test:write:capped-event", { n: 2 }, user);
696
+ // Third must be skipped by the maxPerTenant guard.
697
+ await runner.handleEvent("test:write:capped-event", { n: 3 }, user);
698
+ await sleep(200);
699
+ const started = jobLog.filter((e) => e.name === "test:job:per-tenant-limited-event");
700
+ expect(started.length).toBeLessThanOrEqual(2);
701
+ });
702
+ });
602
703
  });
603
704
 
604
705
  // --- Registry ---
@@ -25,6 +25,17 @@ const echoFeature = defineFeature("echo", (r) => {
25
25
  { access: { openToAll: true } },
26
26
  );
27
27
 
28
+ r.streamHandler(
29
+ "item:tail",
30
+ z.object({ count: z.number().int().min(0) }),
31
+ async function* (query) {
32
+ for (let i = 0; i < query.payload.count; i++) {
33
+ yield { i };
34
+ }
35
+ },
36
+ { access: { roles: ["Admin"] } },
37
+ );
38
+
28
39
  r.hook("validation", "item:create", (data) => {
29
40
  if (data["name"] === "forbidden") return [{ field: "name", error: "forbidden_name" }];
30
41
  return null;
@@ -214,6 +225,55 @@ describe("dispatcher.query", () => {
214
225
  });
215
226
  });
216
227
 
228
+ async function collect<T>(gen: AsyncGenerator<T>): Promise<T[]> {
229
+ const out: T[] = [];
230
+ for await (const chunk of gen) out.push(chunk);
231
+ return out;
232
+ }
233
+
234
+ describe("dispatcher.stream", () => {
235
+ test("validates and calls stream handler, yielding chunks in order", async () => {
236
+ const dispatcher = createTestDispatcher();
237
+ const chunks = await collect(
238
+ dispatcher.stream(
239
+ "echo:stream:item:tail",
240
+ { count: 3 },
241
+ createTestUser({ roles: ["Admin"] }),
242
+ ),
243
+ );
244
+
245
+ expect(chunks).toEqual([{ i: 0 }, { i: 1 }, { i: 2 }]);
246
+ });
247
+
248
+ test("gates fire on first pull, not on the stream() call itself", async () => {
249
+ const dispatcher = createTestDispatcher();
250
+ const gen = dispatcher.stream(
251
+ "echo:stream:item:tail",
252
+ { count: -1 },
253
+ createTestUser({ roles: ["Admin"] }),
254
+ );
255
+
256
+ await expect(collect(gen)).rejects.toMatchObject({ code: "validation_error", httpStatus: 400 });
257
+ });
258
+
259
+ test("rejects unauthorized user", async () => {
260
+ const dispatcher = createTestDispatcher();
261
+ const guest = createTestUser({ roles: ["Guest"] });
262
+
263
+ await expect(
264
+ collect(dispatcher.stream("echo:stream:item:tail", { count: 1 }, guest)),
265
+ ).rejects.toMatchObject({ code: "access_denied" });
266
+ });
267
+
268
+ test("throws for unknown stream handler", async () => {
269
+ const dispatcher = createTestDispatcher();
270
+
271
+ await expect(
272
+ collect(dispatcher.stream("nonexistent", {}, createTestUser({ roles: ["Admin"] }))),
273
+ ).rejects.toMatchObject({ code: "not_found", httpStatus: 404 });
274
+ });
275
+ });
276
+
217
277
  // --- postQuery hooks on standalone (entity-less) queries ---
218
278
 
219
279
  describe("dispatcher.query postQuery hooks", () => {
@@ -362,6 +422,20 @@ describe("dispatcher feature-gate", () => {
362
422
  async (event) => ({ isSuccess: true, data: { name: event.payload.name } }),
363
423
  { access: { roles: ["Admin"] } },
364
424
  );
425
+ r.streamHandler(
426
+ "widget:tail",
427
+ z.object({}).passthrough(),
428
+ async function* () {
429
+ // Never completes on its own — the mid-stream toggle-off test
430
+ // relies on the generator staying open past the first yield.
431
+ let i = 0;
432
+ while (true) {
433
+ yield { i };
434
+ i++;
435
+ }
436
+ },
437
+ { access: { openToAll: true } },
438
+ );
365
439
  });
366
440
  }
367
441
 
@@ -397,6 +471,28 @@ describe("dispatcher feature-gate", () => {
397
471
  });
398
472
  });
399
473
 
474
+ test("disabling the feature mid-stream aborts an already-open stream", async () => {
475
+ const registry = createRegistry([toggled()]);
476
+ const disabled = new Set<string>();
477
+ const dispatcher = createDispatcher(
478
+ registry,
479
+ {},
480
+ {
481
+ effectiveFeatures: () => {
482
+ const all = new Set(registry.features.keys());
483
+ for (const d of disabled) all.delete(d);
484
+ return all;
485
+ },
486
+ },
487
+ );
488
+
489
+ const gen = dispatcher.stream("toggled:stream:widget:tail", {}, user);
490
+ expect((await gen.next()).value).toEqual({ i: 0 });
491
+
492
+ disabled.add("toggled");
493
+ await expect(gen.next()).rejects.toThrow(/feature toggled is disabled/);
494
+ });
495
+
400
496
  test("write of disabled feature returns WriteFailure with feature_disabled reason", async () => {
401
497
  const registry = createRegistry([toggled()]);
402
498
  const disabled = new Set<string>(["toggled"]);
@@ -4,6 +4,7 @@ import {
4
4
  createEntity,
5
5
  createRegistry,
6
6
  createTextField,
7
+ type DeleteContext,
7
8
  defineFeature,
8
9
  type PostSaveHookFn,
9
10
  type PreSaveHookFn,
@@ -528,3 +529,210 @@ describe("buildEventId — dedup key construction", () => {
528
529
  expect(buildEventId("handler", { id: 0 }, "phase")).toBeNull();
529
530
  });
530
531
  });
532
+
533
+ // --- PreDelete / PostDelete pipeline ---
534
+
535
+ const deletectx: DeleteContext = {
536
+ kind: "delete",
537
+ id: 1,
538
+ data: { email: "test@test.de" },
539
+ entityName: "user",
540
+ };
541
+
542
+ describe("runPreDelete", () => {
543
+ test("runs feature + entity + system hooks, all inTransaction (throw on error)", async () => {
544
+ const calls: string[] = [];
545
+ const feature = defineFeature("test", (r) => {
546
+ r.entity("user", createEntity({ table: "Users", fields: { email: createTextField() } }));
547
+ r.writeHandler("user", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
548
+ access: { openToAll: true },
549
+ });
550
+ r.hook("preDelete", "user", async () => {
551
+ calls.push("handler");
552
+ });
553
+ r.hook("preDelete", { allOf: "user" }, async () => {
554
+ calls.push("entity");
555
+ });
556
+ });
557
+ const registry = createRegistry([feature]);
558
+ const systemHooks: SystemHooks = {
559
+ preDelete: [
560
+ {
561
+ name: "sys",
562
+ priority: 1000,
563
+ fn: async () => {
564
+ calls.push("system");
565
+ },
566
+ },
567
+ ],
568
+ };
569
+ const pipeline = createLifecycleHooks(registry, systemHooks);
570
+ await pipeline.runPreDelete("test:write:user", deletectx, {});
571
+ expect(calls).toEqual(["handler", "entity", "system"]);
572
+ });
573
+
574
+ test("a hook throwing aborts the delete (rejects, not swallowed)", async () => {
575
+ const feature = defineFeature("test", (r) => {
576
+ r.entity("user", createEntity({ table: "Users", fields: { email: createTextField() } }));
577
+ r.writeHandler("user", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
578
+ access: { openToAll: true },
579
+ });
580
+ r.hook("preDelete", "user", async () => {
581
+ throw new Error("blocked-delete");
582
+ });
583
+ });
584
+ const registry = createRegistry([feature]);
585
+ const pipeline = createLifecycleHooks(registry);
586
+ await expect(pipeline.runPreDelete("test:write:user", deletectx, {})).rejects.toThrow(
587
+ "blocked-delete",
588
+ );
589
+ });
590
+
591
+ test("system hook with a non-inTransaction phase is skipped", async () => {
592
+ const registry = makeRegistry();
593
+ const calls: string[] = [];
594
+ const systemHooks: SystemHooks = {
595
+ preDelete: [
596
+ {
597
+ name: "sys",
598
+ priority: 1000,
599
+ phase: "afterCommit",
600
+ fn: async () => {
601
+ calls.push("system");
602
+ },
603
+ },
604
+ ],
605
+ };
606
+ const pipeline = createLifecycleHooks(registry, systemHooks);
607
+ await pipeline.runPreDelete("test:write:user", deletectx, {});
608
+ expect(calls).toEqual([]);
609
+ });
610
+ });
611
+
612
+ describe("runPostDelete", () => {
613
+ test("runs feature then system hooks (best-effort by default phase)", async () => {
614
+ const calls: string[] = [];
615
+ const feature = defineFeature("test", (r) => {
616
+ r.entity("user", createEntity({ table: "Users", fields: { email: createTextField() } }));
617
+ r.writeHandler("user", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
618
+ access: { openToAll: true },
619
+ });
620
+ r.hook("postDelete", "user", async () => {
621
+ calls.push("feature");
622
+ });
623
+ });
624
+ const registry = createRegistry([feature]);
625
+ const systemHooks: SystemHooks = {
626
+ postDelete: [
627
+ {
628
+ name: "sys",
629
+ priority: 1000,
630
+ fn: async () => {
631
+ calls.push("system");
632
+ },
633
+ },
634
+ ],
635
+ };
636
+ const pipeline = createLifecycleHooks(registry, systemHooks);
637
+ await pipeline.runPostDelete("test:write:user", deletectx, {});
638
+ expect(calls).toEqual(["feature", "system"]);
639
+ });
640
+
641
+ test("inTransaction phase: hook errors throw", async () => {
642
+ const feature = defineFeature("test", (r) => {
643
+ r.entity("user", createEntity({ table: "Users", fields: { email: createTextField() } }));
644
+ r.writeHandler("user", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
645
+ access: { openToAll: true },
646
+ });
647
+ r.hook(
648
+ "postDelete",
649
+ "user",
650
+ async () => {
651
+ throw new Error("postDelete-inTx-boom");
652
+ },
653
+ { phase: "inTransaction" },
654
+ );
655
+ });
656
+ const registry = createRegistry([feature]);
657
+ const pipeline = createLifecycleHooks(registry);
658
+ await expect(
659
+ pipeline.runPostDelete("test:write:user", deletectx, {}, "inTransaction"),
660
+ ).rejects.toThrow("postDelete-inTx-boom");
661
+ });
662
+ });
663
+
664
+ // --- Batch hooks ---
665
+
666
+ describe("runPostSaveBatch / runPostDeleteBatch", () => {
667
+ test("no batch hooks registered → resolves without throwing", async () => {
668
+ const pipeline = createLifecycleHooks(makeRegistry());
669
+ await expect(pipeline.runPostSaveBatch([savectx], {})).resolves.toBeUndefined();
670
+ await expect(pipeline.runPostDeleteBatch([deletectx], {})).resolves.toBeUndefined();
671
+ // Should not throw — nothing registered.
672
+ });
673
+
674
+ test("runPostSaveBatch runs all system hooks concurrently with the batch payload", async () => {
675
+ const seen: (readonly SaveContext[])[] = [];
676
+ const systemHooks: SystemHooks = {
677
+ postSaveBatch: [
678
+ {
679
+ name: "a",
680
+ priority: 1000,
681
+ fn: async (results) => {
682
+ seen.push(results);
683
+ },
684
+ },
685
+ ],
686
+ };
687
+ const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
688
+ await pipeline.runPostSaveBatch([savectx], {});
689
+ expect(seen).toEqual([[savectx]]);
690
+ });
691
+
692
+ test("runPostDeleteBatch runs all system hooks with the batch payload", async () => {
693
+ const seen: (readonly DeleteContext[])[] = [];
694
+ const systemHooks: SystemHooks = {
695
+ postDeleteBatch: [
696
+ {
697
+ name: "a",
698
+ priority: 1000,
699
+ fn: async (payloads) => {
700
+ seen.push(payloads);
701
+ },
702
+ },
703
+ ],
704
+ };
705
+ const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
706
+ await pipeline.runPostDeleteBatch([deletectx], {});
707
+ expect(seen).toEqual([[deletectx]]);
708
+ });
709
+
710
+ test("one batch hook throwing doesn't stop the others (Promise.allSettled) — logged, never thrown", async () => {
711
+ const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
712
+ 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
+ },
721
+ },
722
+ {
723
+ name: "ok",
724
+ priority: 1001,
725
+ fn: async () => {
726
+ calls.push("ok-ran");
727
+ },
728
+ },
729
+ ],
730
+ };
731
+ const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
732
+ // Must not throw.
733
+ await pipeline.runPostSaveBatch([savectx], {});
734
+ expect(calls).toEqual(["ok-ran"]);
735
+ expect(consoleSpy).toHaveBeenCalled();
736
+ consoleSpy.mockRestore();
737
+ });
738
+ });
@@ -559,7 +559,7 @@ export function buildHandlerContext(
559
559
  export async function runHandlerInstrumented<T>(
560
560
  ctx: DispatchContext,
561
561
  type: string,
562
- operation: "query" | "write",
562
+ operation: "query" | "write" | "stream",
563
563
  user: SessionUser,
564
564
  inner: () => Promise<T>,
565
565
  ): Promise<T> {
@@ -609,6 +609,44 @@ export async function runHandlerInstrumented<T>(
609
609
  }
610
610
  }
611
611
 
612
+ // Generator-native counterpart to runHandlerInstrumented — a stream's
613
+ // lifetime spans every `for await` pull the caller makes, so the span
614
+ // can't be scoped via withSpan's single-callback shape. startSpan/end
615
+ // bracket the whole yield* instead; metrics land in the same finally
616
+ // path so success/failure/throw all hit one emit, like the Promise path.
617
+ export async function* runStreamInstrumented<T>(
618
+ ctx: DispatchContext,
619
+ type: string,
620
+ user: SessionUser,
621
+ inner: () => AsyncGenerator<T>,
622
+ ): AsyncGenerator<T> {
623
+ const { tracer: dispatcherTracer, meter: dispatcherMeter, registry } = ctx;
624
+ const start = performance.now();
625
+ let success = true;
626
+ let errorClass: string | undefined;
627
+ const span = dispatcherTracer.startSpan("kumiko.dispatcher.handler", {
628
+ attributes: dispatcherSpanAttributes(type, "stream", user, registry.getHandlerFeature(type)),
629
+ });
630
+ try {
631
+ yield* inner();
632
+ } catch (error) {
633
+ success = false;
634
+ errorClass = error instanceof Error && error.name ? error.name : "UnknownError";
635
+ span.setStatus("error", errorClass);
636
+ throw error;
637
+ } finally {
638
+ span.end();
639
+ if (!success && errorClass) {
640
+ emitDispatcherError(dispatcherMeter, { handler: type, errorClass });
641
+ }
642
+ emitDispatcherHandler(
643
+ dispatcherMeter,
644
+ { handler: type, success },
645
+ (performance.now() - start) / 1000,
646
+ );
647
+ }
648
+ }
649
+
612
650
  // Feature-toggle gate. Returns the error to fold into a WriteFailure in the
613
651
  // write path, or throws for the query path (where throws flow through the
614
652
  // same outer instrumentation wrapper as other dispatcher errors).
@@ -0,0 +1,74 @@
1
+ import type { DbTx } from "../db/connection";
2
+ import { hasAccess } from "../engine/access";
3
+ import type { SessionUser } from "../engine/types";
4
+ import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors";
5
+ import { assertNoSecretLeak } from "../secrets";
6
+ import {
7
+ buildHandlerContext,
8
+ type DispatchContext,
9
+ enforceRateLimit,
10
+ ensureFeatureEnabled,
11
+ runStreamInstrumented,
12
+ } from "./dispatch-shared";
13
+
14
+ // Standalone stream execution — used by the public dispatcher.stream().
15
+ // Chunk-by-chunk analog of executeQuery: same gate order (feature → rate-
16
+ // limit → access → validation → handler), but yields incrementally instead
17
+ // of returning a single response. streamHandler never entity-maps (unlike
18
+ // write/queryHandler — see feature-entity-handlers.ts), so there's no
19
+ // field-access filter or postQuery-hook stage to run here.
20
+ export async function* executeStream(
21
+ ctx: DispatchContext,
22
+ type: string,
23
+ payload: unknown,
24
+ user: SessionUser,
25
+ tx?: DbTx,
26
+ ): AsyncGenerator<unknown> {
27
+ yield* runStreamInstrumented(ctx, type, user, () =>
28
+ executeStreamInner(ctx, type, payload, user, tx),
29
+ );
30
+ }
31
+
32
+ async function* executeStreamInner(
33
+ ctx: DispatchContext,
34
+ type: string,
35
+ payload: unknown,
36
+ user: SessionUser,
37
+ tx?: DbTx,
38
+ ): AsyncGenerator<unknown> {
39
+ const { registry } = ctx;
40
+ const handler = registry.getStreamHandler(type);
41
+ if (!handler) throw new NotFoundError("handler", type);
42
+
43
+ await ensureFeatureEnabled(ctx, type, user.tenantId);
44
+
45
+ if (handler.rateLimit !== undefined) {
46
+ await enforceRateLimit(ctx, handler.rateLimit, type, user);
47
+ }
48
+
49
+ if (!hasAccess(user, handler.access)) {
50
+ throw new AccessDeniedError({
51
+ message: `access denied for ${type}`,
52
+ details: { handler: type },
53
+ });
54
+ }
55
+
56
+ const parsed = handler.schema.safeParse(payload);
57
+ if (!parsed.success) {
58
+ throw validationErrorFromZod(parsed.error);
59
+ }
60
+
61
+ const handlerContext = buildHandlerContext(ctx, type, user, tx);
62
+ const chunks = handler.handler({ type, payload: parsed.data, user }, handlerContext);
63
+
64
+ // Consumer-driven pull (for await) is the backpressure mechanism — the
65
+ // handler generator only advances once the caller reads the previous
66
+ // chunk, no explicit buffering/throttling needed on either side.
67
+ for await (const chunk of chunks) {
68
+ // Re-checked per chunk, not just at stream-start: a feature disabled
69
+ // mid-stream must cut an already-open stream, not just block new ones.
70
+ await ensureFeatureEnabled(ctx, type, user.tenantId);
71
+ assertNoSecretLeak(chunk);
72
+ yield chunk;
73
+ }
74
+ }
@@ -38,7 +38,7 @@ export function describeShape(result: unknown): string {
38
38
 
39
39
  export function dispatcherSpanAttributes(
40
40
  type: string,
41
- operation: "query" | "write",
41
+ operation: "query" | "write" | "stream",
42
42
  user: SessionUser,
43
43
  feature: string | undefined,
44
44
  ) {