@cosmicdrift/kumiko-framework 0.158.2 → 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 (168) hide show
  1. package/package.json +7 -2
  2. package/src/__tests__/consumer-cli.integration.test.ts +110 -0
  3. package/src/api/__tests__/api.test.ts +65 -0
  4. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  5. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
  6. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
  7. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  8. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  9. package/src/api/__tests__/jwt.test.ts +150 -1
  10. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
  11. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  12. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  13. package/src/api/api-constants.ts +5 -0
  14. package/src/api/auth-middleware.ts +48 -59
  15. package/src/api/auth-routes.ts +51 -17
  16. package/src/api/index.ts +3 -3
  17. package/src/api/jwt.ts +148 -7
  18. package/src/api/pii-leak-guard.ts +5 -2
  19. package/src/api/routes.ts +57 -0
  20. package/src/api/server.ts +19 -5
  21. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  22. package/src/bun-db/query.ts +46 -27
  23. package/src/consumer-cli.ts +87 -0
  24. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  25. package/src/crypto/blind-index.ts +8 -4
  26. package/src/crypto/event-pii.ts +1 -0
  27. package/src/crypto/kms-adapter.ts +2 -118
  28. package/src/crypto/pii-field-encryption.ts +49 -15
  29. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  30. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  31. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
  32. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  33. package/src/db/blind-index-cleanup.ts +3 -1
  34. package/src/db/connection.ts +3 -11
  35. package/src/db/cursor.ts +1 -18
  36. package/src/db/dialect.ts +8 -19
  37. package/src/db/encryption.ts +2 -3
  38. package/src/db/entity-table-meta-types.ts +2 -0
  39. package/src/db/entity-table-meta.ts +16 -90
  40. package/src/db/event-store-executor.ts +4 -96
  41. package/src/db/queries/backfill-pii.ts +1 -0
  42. package/src/db/queries/event-consumer.ts +35 -2
  43. package/src/db/table-builder.ts +2 -19
  44. package/src/db/tenant-db.ts +6 -55
  45. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  46. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  47. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  48. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  49. package/src/engine/__tests__/define-roles.test.ts +21 -0
  50. package/src/engine/__tests__/engine.test.ts +28 -0
  51. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  52. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  53. package/src/engine/__tests__/registry.test.ts +40 -0
  54. package/src/engine/__tests__/store-table.test.ts +12 -0
  55. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  56. package/src/engine/boot-validator/action-wiring.ts +1 -1
  57. package/src/engine/boot-validator/boot-check.ts +21 -0
  58. package/src/engine/boot-validator/entity-handler.ts +10 -1
  59. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  60. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  61. package/src/engine/boot-validator/index.ts +3 -9
  62. package/src/engine/boot-validator/screens.ts +1 -1
  63. package/src/engine/define-feature.ts +2 -0
  64. package/src/engine/define-handler.ts +11 -91
  65. package/src/engine/entity-handlers.ts +15 -27
  66. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  67. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  68. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  69. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  70. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  71. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  72. package/src/engine/feature-ast/extractors/index.ts +1 -0
  73. package/src/engine/feature-ast/index.ts +2 -0
  74. package/src/engine/feature-ast/parse.ts +3 -0
  75. package/src/engine/feature-ast/patch.ts +2 -0
  76. package/src/engine/feature-ast/patcher.ts +21 -0
  77. package/src/engine/feature-ast/patterns.ts +16 -0
  78. package/src/engine/feature-ast/render.ts +15 -0
  79. package/src/engine/feature-builder-state.ts +6 -0
  80. package/src/engine/feature-config-events-jobs.ts +1 -1
  81. package/src/engine/feature-entity-handlers.ts +36 -2
  82. package/src/engine/feature-ui-extensions.ts +5 -1
  83. package/src/engine/field-helpers.ts +31 -0
  84. package/src/engine/handler-helpers.ts +26 -0
  85. package/src/engine/hook-helpers.ts +14 -0
  86. package/src/engine/index.ts +5 -2
  87. package/src/engine/ownership.ts +22 -76
  88. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  89. package/src/engine/pattern-library/library.ts +2 -0
  90. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  91. package/src/engine/registry-facade.ts +9 -0
  92. package/src/engine/registry-ingest.ts +10 -0
  93. package/src/engine/registry-state.ts +3 -0
  94. package/src/engine/registry-validate.ts +1 -1
  95. package/src/engine/screen-helpers.ts +54 -0
  96. package/src/engine/tier-resolver-extension.ts +3 -2
  97. package/src/engine/types/config.ts +2 -497
  98. package/src/engine/types/define-handler.ts +2 -0
  99. package/src/engine/types/entity-handlers.ts +2 -0
  100. package/src/engine/types/event-type-map.ts +1 -37
  101. package/src/engine/types/feature.ts +2 -976
  102. package/src/engine/types/fields.ts +2 -697
  103. package/src/engine/types/handlers.ts +2 -839
  104. package/src/engine/types/hooks.ts +2 -184
  105. package/src/engine/types/http-route.ts +1 -72
  106. package/src/engine/types/identifiers.ts +1 -47
  107. package/src/engine/types/index.ts +66 -33
  108. package/src/engine/types/nav.ts +2 -67
  109. package/src/engine/types/ownership.ts +2 -0
  110. package/src/engine/types/projection.ts +2 -165
  111. package/src/engine/types/relations.ts +1 -51
  112. package/src/engine/types/screen.ts +2 -793
  113. package/src/engine/types/step.ts +2 -334
  114. package/src/engine/types/target-ref.ts +1 -21
  115. package/src/engine/types/tree-node.ts +1 -129
  116. package/src/engine/types/workspace.ts +2 -42
  117. package/src/entrypoint/index.ts +2 -2
  118. package/src/errors/write-error-info.ts +6 -22
  119. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  120. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  121. package/src/event-store/errors.ts +2 -35
  122. package/src/event-store/event-store.ts +28 -51
  123. package/src/event-store/events-schema.ts +1 -10
  124. package/src/event-store/index.ts +3 -2
  125. package/src/event-store/snapshot.ts +11 -35
  126. package/src/event-store/types.ts +2 -0
  127. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  128. package/src/files/file-handle.ts +2 -19
  129. package/src/files/provider-resolver.ts +3 -5
  130. package/src/files/types.ts +5 -54
  131. package/src/i18n/required-surface-keys.ts +1 -1
  132. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  133. package/src/logging/types.ts +1 -7
  134. package/src/observability/types/index.ts +1 -29
  135. package/src/observability/types/metric.ts +1 -56
  136. package/src/observability/types/provider.ts +1 -32
  137. package/src/observability/types/span.ts +1 -58
  138. package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
  139. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  140. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  141. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  142. package/src/pipeline/dispatch-shared.ts +51 -3
  143. package/src/pipeline/dispatch-stream.ts +74 -0
  144. package/src/pipeline/dispatcher-utils.ts +1 -1
  145. package/src/pipeline/dispatcher.ts +7 -0
  146. package/src/pipeline/entity-cache.ts +2 -33
  147. package/src/pipeline/event-consumer-state.ts +28 -3
  148. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  149. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  150. package/src/pipeline/event-dispatcher.ts +27 -1
  151. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  152. package/src/pipeline/system-hooks.ts +7 -0
  153. package/src/rate-limit/resolver.ts +10 -30
  154. package/src/search/types.ts +1 -39
  155. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  156. package/src/secrets/__tests__/envelope.test.ts +1 -1
  157. package/src/secrets/envelope-cipher.ts +17 -45
  158. package/src/secrets/types.ts +2 -177
  159. package/src/stack/__tests__/event-collector.test.ts +42 -0
  160. package/src/testing/__tests__/late-bound.test.ts +25 -0
  161. package/src/testing/__tests__/wait-for.test.ts +53 -0
  162. package/src/testing/boot-validator-fixture.ts +1 -1
  163. package/src/testing/file-provider-contract.ts +84 -0
  164. package/src/testing/handler-context.ts +1 -1
  165. package/src/testing/index.ts +1 -0
  166. package/src/time/geo-tz.ts +1 -32
  167. package/src/time/tz-context.ts +9 -56
  168. package/src/ui-types/index.ts +7 -7
@@ -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
+ });
@@ -484,8 +484,18 @@ export function buildHandlerContext(
484
484
  // When the feature-toggles or tier-engine feature isn't wired (no
485
485
  // effectiveFeatures callback), always returns true — apps without
486
486
  // tier-cuts treat all features on.
487
- hasFeature: (featureName: string): boolean =>
488
- effectiveFeatures ? effectiveFeatures(user.tenantId).has(featureName) : true,
487
+ //
488
+ // Falls back to the live trialGate when the sync set says the feature
489
+ // is off — the sync set never contains trial-tier features (time-
490
+ // derived, can't boot-cache), so without this a trial tenant checking
491
+ // a companion feature's toggle would silently read `false` even though
492
+ // the dispatch gate already lets trial-tier handlers run.
493
+ hasFeature: async (featureName: string): Promise<boolean> => {
494
+ if (!effectiveFeatures) return true;
495
+ if (effectiveFeatures(user.tenantId).has(featureName)) return true;
496
+ if (!effectiveFeatures.trialGate) return false;
497
+ return effectiveFeatures.trialGate(user.tenantId, featureName);
498
+ },
489
499
  };
490
500
 
491
501
  // Registry is always the dispatcher's registry — injecting it here lets
@@ -549,7 +559,7 @@ export function buildHandlerContext(
549
559
  export async function runHandlerInstrumented<T>(
550
560
  ctx: DispatchContext,
551
561
  type: string,
552
- operation: "query" | "write",
562
+ operation: "query" | "write" | "stream",
553
563
  user: SessionUser,
554
564
  inner: () => Promise<T>,
555
565
  ): Promise<T> {
@@ -599,6 +609,44 @@ export async function runHandlerInstrumented<T>(
599
609
  }
600
610
  }
601
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
+
602
650
  // Feature-toggle gate. Returns the error to fold into a WriteFailure in the
603
651
  // write path, or throws for the query path (where throws flow through the
604
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
  ) {
@@ -8,6 +8,7 @@ import { runBatch, unwrapSingle } from "./dispatch-batch";
8
8
  import { executeQuery } from "./dispatch-query";
9
9
  import type { BatchCommand, BatchResult, DispatchContext } from "./dispatch-shared";
10
10
  import { resolveAuthClaimsFn } from "./dispatch-shared";
11
+ import { executeStream } from "./dispatch-stream";
11
12
  import { type HandlerType, resolveType } from "./dispatcher-utils";
12
13
  import type { IdempotencyGuard } from "./idempotency";
13
14
  import type { LifecycleHooks } from "./lifecycle-pipeline";
@@ -52,6 +53,10 @@ export type Dispatcher = {
52
53
  requestId?: string,
53
54
  ): Promise<WriteResult>;
54
55
  query(type: HandlerType, payload: unknown, user: SessionUser): Promise<unknown>;
56
+ // AsyncGenerator, not Promise — gates (feature/rate-limit/access/
57
+ // validation) fire on the consumer's first `.next()` pull, not on this
58
+ // call, since they live inside the underlying async function*.
59
+ stream(type: HandlerType, payload: unknown, user: SessionUser): AsyncGenerator<unknown>;
55
60
  command(type: HandlerType, payload: unknown, user: SessionUser): Promise<void>;
56
61
  // Atomic multi-command write: all commands run in a single DB transaction.
57
62
  // On any failure, the transaction rolls back and afterCommit hooks do NOT fire.
@@ -114,6 +119,8 @@ export function createDispatcher(
114
119
 
115
120
  query: (typeOrRef, payload, user) => executeQuery(ctx, resolveType(typeOrRef), payload, user),
116
121
 
122
+ stream: (typeOrRef, payload, user) => executeStream(ctx, resolveType(typeOrRef), payload, user),
123
+
117
124
  async command(typeOrRef, payload, user) {
118
125
  const type = resolveType(typeOrRef);
119
126
  const batchResult = await runBatch(ctx, [{ type, payload }], user);
@@ -1,3 +1,4 @@
1
+ import type { EntityCache } from "@cosmicdrift/kumiko-types/entity-cache";
1
2
  import type Redis from "ioredis";
2
3
  import type { EntityId, TenantId } from "../engine/types/identifiers";
3
4
  import { RedisKeys } from "./redis-keys";
@@ -23,39 +24,7 @@ function parseCached(raw: string): Record<string, unknown> | null {
23
24
  }
24
25
  }
25
26
 
26
- export type EntityCache = {
27
- /** Get a single cached entity. Returns null on miss. */
28
- get(
29
- tenantId: TenantId,
30
- entityName: string,
31
- id: EntityId,
32
- ): Promise<Record<string, unknown> | null>;
33
-
34
- /** Get multiple cached entities. Returns a Map of id → data (misses are absent). */
35
- mget(
36
- tenantId: TenantId,
37
- entityName: string,
38
- ids: readonly EntityId[],
39
- ): Promise<Map<EntityId, Record<string, unknown>>>;
40
-
41
- /** Cache a single entity. */
42
- set(
43
- tenantId: TenantId,
44
- entityName: string,
45
- id: EntityId,
46
- data: Record<string, unknown>,
47
- ): Promise<void>;
48
-
49
- /** Cache multiple entities at once. */
50
- mset(
51
- tenantId: TenantId,
52
- entityName: string,
53
- entries: ReadonlyArray<{ id: EntityId; data: Record<string, unknown> }>,
54
- ): Promise<void>;
55
-
56
- /** Invalidate a single cached entity. */
57
- del(tenantId: TenantId, entityName: string, id: EntityId): Promise<void>;
58
- };
27
+ export type { EntityCache } from "@cosmicdrift/kumiko-types/entity-cache";
59
28
 
60
29
  export type EntityCacheOptions = {
61
30
  ttlSeconds?: number;
@@ -10,7 +10,8 @@ import {
10
10
  sql,
11
11
  text,
12
12
  } from "../db/dialect";
13
- import { tableExists } from "../db/schema-inspection";
13
+ import { alterTableAddColumn } from "../db/queries/test-stack";
14
+ import { columnNamesOf, tableExists } from "../db/schema-inspection";
14
15
  import { unsafePushTables } from "../stack";
15
16
 
16
17
  // Reserved sentinel used in the instance_id column for consumers whose
@@ -72,6 +73,14 @@ export const eventConsumerStateTable = pgTable(
72
73
  .default(sql`0`),
73
74
  status: text("status").notNull().default("idle"),
74
75
  attempts: integer("attempts").notNull().default(0),
76
+ // Counts automatic dead→idle revivals since the last delivery that
77
+ // advanced the cursor (event-dispatcher.ts's cooldown re-arm, see
78
+ // acquireConsumerState). A poison event never advances the cursor, so
79
+ // this climbs to maxRearmCount and then stays dead permanently. A
80
+ // successful delivery resets it to 0 (proof the consumer isn't
81
+ // poisoned), as does a manual restartConsumer()/enableConsumer()/
82
+ // skipPoisonEvent() — an operator vouching the consumer is healthy.
83
+ rearmCount: integer("rearm_count").notNull().default(0),
75
84
  lastError: text("last_error"),
76
85
  updatedAt: instant("updated_at", { precision: 3 }).notNull().default(sql`now()`),
77
86
  },
@@ -99,7 +108,23 @@ export type ConsumerStatus = (typeof ConsumerStatuses)[keyof typeof ConsumerStat
99
108
  //
100
109
  // guard:dup-ok — intentionale Parallele zu createProjectionStateTable; symmetrische State-Tabellen by design
101
110
  export async function createEventConsumerStateTable(db: DbConnection): Promise<void> {
102
- // skip: table already exists — bootstrap is called from multiple paths
103
- if (await tableExists(db, "public.kumiko_event_consumers")) return;
111
+ // skip: table already exists — bootstrap is called from multiple paths.
112
+ // Still check for columns added after the table's first deploy (e.g.
113
+ // rearm_count) so an older DB catches up without a dedicated migration.
114
+ if (await tableExists(db, "public.kumiko_event_consumers")) {
115
+ const cols = await columnNamesOf(db, "kumiko_event_consumers");
116
+ if (!cols.has("rearm_count")) {
117
+ await alterTableAddColumn(
118
+ db,
119
+ "kumiko_event_consumers",
120
+ "rearm_count",
121
+ "integer",
122
+ " DEFAULT 0",
123
+ " NOT NULL",
124
+ );
125
+ }
126
+ // skip: table (+ any missing column) is already up to date
127
+ return;
128
+ }
104
129
  await unsafePushTables(db, { kumikoEventConsumers: eventConsumerStateTable });
105
130
  }
@@ -34,6 +34,7 @@ function normalizeConsumerState(row: ConsumerStateRowShape): ConsumerRecoverySta
34
34
  status: row.status,
35
35
  lastProcessedEventId: row.lastProcessedEventId,
36
36
  attempts: row.attempts,
37
+ rearmCount: row.rearmCount,
37
38
  lastError: row.lastError,
38
39
  updatedAt: row.updatedAt,
39
40
  };
@@ -45,6 +46,7 @@ export type ConsumerRecoveryState = {
45
46
  readonly status: string;
46
47
  readonly lastProcessedEventId: bigint;
47
48
  readonly attempts: number;
49
+ readonly rearmCount: number;
48
50
  readonly lastError: string | null;
49
51
  readonly updatedAt: Temporal.Instant;
50
52
  };
@@ -170,6 +172,7 @@ export async function getConsumerState(
170
172
  readonly status: string;
171
173
  readonly lastProcessedEventId: bigint;
172
174
  readonly attempts: number;
175
+ readonly rearmCount: number;
173
176
  readonly lastError: string | null;
174
177
  readonly updatedAt: Temporal.Instant;
175
178
  } | null> {
@@ -184,6 +187,7 @@ export async function getConsumerState(
184
187
  status: row.status,
185
188
  lastProcessedEventId: row.lastProcessedEventId,
186
189
  attempts: row.attempts,
190
+ rearmCount: row.rearmCount,
187
191
  lastError: row.lastError,
188
192
  updatedAt: row.updatedAt,
189
193
  };
@@ -3,6 +3,7 @@ import type { DbConnection, DbTx } from "../db/connection";
3
3
  import {
4
4
  insertConsumerIfAbsent,
5
5
  markConsumerProcessing,
6
+ rearmDeadConsumer,
6
7
  selectConsumerForUpdateSkipLocked,
7
8
  updateConsumerDeliveryOutcome,
8
9
  } from "../db/queries/event-consumer";
@@ -38,12 +39,13 @@ export type ConsumerStateRowShape = {
38
39
  readonly lastProcessedEventId: bigint;
39
40
  readonly status: string;
40
41
  readonly attempts: number;
42
+ readonly rearmCount: number;
41
43
  readonly lastError: string | null;
42
44
  readonly updatedAt: Temporal.Instant;
43
45
  };
44
46
  export type ConsumerStateRow = ConsumerStateRowShape;
45
47
 
46
- type StoredEventRow = {
48
+ export type StoredEventRow = {
47
49
  readonly id: bigint;
48
50
  readonly aggregateId: string;
49
51
  readonly aggregateType: string;
@@ -80,6 +82,8 @@ export async function acquireConsumerState(
80
82
  tx: DbTx,
81
83
  name: string,
82
84
  instanceId: string,
85
+ rearmCooldownMs: number,
86
+ maxRearmCount: number,
83
87
  ): Promise<AcquireOutcome> {
84
88
  const rawState = await selectConsumerForUpdateSkipLocked(tx, name, instanceId);
85
89
 
@@ -100,7 +104,28 @@ export async function acquireConsumerState(
100
104
  }
101
105
 
102
106
  if (state.status === ConsumerStatuses.disabled) return { state: null, skip: "disabled" };
103
- if (state.status === ConsumerStatuses.dead) return { state: null, skip: "dead" };
107
+ if (state.status === ConsumerStatuses.dead) {
108
+ // Bounded auto-revival: a transient failure (e.g. a Meilisearch blip)
109
+ // shouldn't need an operator to notice and run restartConsumer() once
110
+ // the cause is long gone. Cooldown since the last write (the death or
111
+ // a prior re-arm) gates the retry; maxRearmCount stops a poison event
112
+ // from looping forever (re-arm → same event fails → dead → re-arm →
113
+ // ...) — after the cap it stays dead until a human intervenes.
114
+ const cooldownDeadline = Temporal.Now.instant().subtract({ milliseconds: rearmCooldownMs });
115
+ const cooldownElapsed = Temporal.Instant.compare(state.updatedAt, cooldownDeadline) <= 0;
116
+ if (cooldownElapsed && state.rearmCount < maxRearmCount) {
117
+ const rearmed = await rearmDeadConsumer(tx, name, instanceId);
118
+ const rearmedState =
119
+ rearmed &&
120
+ (coerceRow(rearmed, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
121
+ if (rearmedState) return { state: rearmedState, skip: null };
122
+ }
123
+ // ponytail: no log/metric fires when the rearm budget is exhausted here
124
+ // (the exact "braucht manuellen Eingriff" moment) — queryable via
125
+ // getConsumerState but silent otherwise. Add an emitDispatcherError-style
126
+ // signal if ops needs a push instead of a dead+lag poll.
127
+ return { state: null, skip: "dead" };
128
+ }
104
129
  return { state, skip: null };
105
130
  }
106
131
 
@@ -183,6 +208,7 @@ export async function deliverEvents(
183
208
  let attempts = state.attempts;
184
209
  let lastError: string | null = state.lastError ?? null;
185
210
  let deadLettered = false;
211
+ const effectiveMaxAttempts = consumer.errorPolicy?.maxAttempts ?? maxAttempts;
186
212
  let processed = 0;
187
213
  let failed = 0;
188
214
 
@@ -231,7 +257,7 @@ export async function deliverEvents(
231
257
  attempts += 1;
232
258
  lastError = errMessage;
233
259
  failed += 1;
234
- if (attempts >= maxAttempts) deadLettered = true;
260
+ if (attempts >= effectiveMaxAttempts) deadLettered = true;
235
261
  break;
236
262
  }
237
263
  }
@@ -66,6 +66,11 @@ export type EventConsumerHandler = (event: StoredEvent, ctx: AppContext) => Prom
66
66
  // the EventConsumer (see api/server.ts MSP wiring).
67
67
  export type EventConsumerErrorPolicy = {
68
68
  readonly skipApplyErrors?: boolean;
69
+ // Per-consumer override of EventDispatcherOptions.maxAttempts. A consumer
70
+ // that depends on infra which may still be provisioning at boot (search
71
+ // adapter, external APIs) needs more retry headroom than the dispatcher-
72
+ // wide default before it gets dead-lettered.
73
+ readonly maxAttempts?: number;
69
74
  };
70
75
 
71
76
  export type EventConsumer = {
@@ -129,6 +134,17 @@ export type EventDispatcherOptions = {
129
134
  readonly batchSize?: number;
130
135
  readonly pollIntervalMs?: number;
131
136
  readonly maxAttempts?: number;
137
+ // Bounded auto-revival of a "dead" consumer. A dead consumer whose last
138
+ // write is older than rearmCooldownMs gets reset to idle and retried.
139
+ // rearmCount tracks re-arms since the last delivery that actually
140
+ // advanced the cursor — a poison event never advances it, so it climbs
141
+ // 1→maxRearmCount and then stays dead permanently; a transient failure
142
+ // that later delivers successfully resets it back to 0, so an unrelated
143
+ // future outage gets its own fresh budget. restartConsumer()/
144
+ // enableConsumer()/skipPoisonEvent() also reset it (an operator vouching
145
+ // the consumer is healthy again).
146
+ readonly rearmCooldownMs?: number;
147
+ readonly maxRearmCount?: number;
132
148
  readonly tracer?: Tracer;
133
149
  readonly meter?: Meter;
134
150
  // Identifies THIS dispatcher process in the consumer-state table. Used as
@@ -150,6 +166,8 @@ export type EventDispatcherOptions = {
150
166
  const DEFAULT_BATCH_SIZE = 200;
151
167
  const DEFAULT_POLL_MS = 100;
152
168
  const DEFAULT_MAX_ATTEMPTS = 10;
169
+ const DEFAULT_REARM_COOLDOWN_MS = 5 * 60_000;
170
+ const DEFAULT_MAX_REARM_COUNT = 3;
153
171
 
154
172
  export function createEventDispatcher(options: EventDispatcherOptions): EventDispatcher {
155
173
  const {
@@ -159,6 +177,8 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
159
177
  batchSize = DEFAULT_BATCH_SIZE,
160
178
  pollIntervalMs = DEFAULT_POLL_MS,
161
179
  maxAttempts = DEFAULT_MAX_ATTEMPTS,
180
+ rearmCooldownMs = DEFAULT_REARM_COOLDOWN_MS,
181
+ maxRearmCount = DEFAULT_MAX_REARM_COUNT,
162
182
  } = options;
163
183
 
164
184
  // Fail-fast on misconfigured per-instance wiring. Catching this at
@@ -273,7 +293,13 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
273
293
 
274
294
  try {
275
295
  await db.begin(async (tx: DbTx) => {
276
- const acquired = await acquireConsumerState(tx, consumer.name, instanceId);
296
+ const acquired = await acquireConsumerState(
297
+ tx,
298
+ consumer.name,
299
+ instanceId,
300
+ rearmCooldownMs,
301
+ maxRearmCount,
302
+ );
277
303
  // skip: another instance holds the lock, or the consumer is
278
304
  // disabled/dead. Nothing to deliver this pass.
279
305
  if (acquired.skip !== null) {