@cosmicdrift/kumiko-framework 0.165.0 → 2.0.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 (146) hide show
  1. package/package.json +5 -3
  2. package/src/__tests__/consumer-cli.integration.test.ts +32 -0
  3. package/src/__tests__/schema-cli.integration.test.ts +1 -1
  4. package/src/api/__tests__/api.test.ts +267 -19
  5. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  6. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
  7. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +2 -1
  8. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
  9. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
  10. package/src/api/__tests__/batch.integration.test.ts +21 -2
  11. package/src/api/__tests__/jwt.test.ts +52 -2
  12. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
  13. package/src/api/__tests__/sse-broker.test.ts +57 -0
  14. package/src/api/__tests__/sse-route.test.ts +4 -0
  15. package/src/api/auth-routes.ts +165 -33
  16. package/src/api/index.ts +1 -0
  17. package/src/api/jwt.ts +22 -1
  18. package/src/api/routes.ts +103 -35
  19. package/src/api/server.ts +17 -1
  20. package/src/api/sse-broker.ts +39 -0
  21. package/src/bun-db/index.ts +1 -0
  22. package/src/bun-db/query.ts +12 -3
  23. package/src/consumer-cli.ts +60 -13
  24. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +14 -1
  25. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  26. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  27. package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
  28. package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
  29. package/src/db/api.ts +2 -2
  30. package/src/db/bun-provider.ts +2 -2
  31. package/src/db/connection.ts +6 -3
  32. package/src/db/dialect.ts +1 -6
  33. package/src/db/entity-table-meta-types.ts +1 -1
  34. package/src/db/event-store-executor-context.ts +2 -3
  35. package/src/db/event-store-executor-read.ts +2 -3
  36. package/src/db/event-store-executor-write.ts +8 -0
  37. package/src/db/index.ts +8 -1
  38. package/src/db/located-timestamp.ts +4 -0
  39. package/src/db/migrate-runner.ts +107 -11
  40. package/src/db/pg-error.ts +8 -0
  41. package/src/db/postgres-provider.ts +2 -2
  42. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  43. package/src/db/queries/ddl.ts +45 -0
  44. package/src/db/queries/event-store.ts +97 -5
  45. package/src/db/queries/test-stack.ts +4 -30
  46. package/src/db/reference-data.ts +2 -3
  47. package/src/db/replay-migration-sql.ts +114 -12
  48. package/src/db/tenant-db.ts +2 -4
  49. package/src/engine/__tests__/engine.test.ts +30 -0
  50. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  51. package/src/engine/__tests__/store-table.test.ts +2 -2
  52. package/src/engine/boot-validator/nav.ts +5 -0
  53. package/src/engine/constants.ts +32 -6
  54. package/src/engine/create-app.ts +11 -0
  55. package/src/engine/effective-features.ts +12 -2
  56. package/src/engine/extensions/user-data.ts +12 -4
  57. package/src/engine/feature-ui-extensions.ts +2 -2
  58. package/src/engine/hook-helpers.ts +3 -1
  59. package/src/engine/index.ts +1 -1
  60. package/src/engine/ownership.ts +4 -3
  61. package/src/engine/registry-ingest.ts +14 -14
  62. package/src/engine/registry-state.ts +4 -1
  63. package/src/engine/schema-builder.ts +1 -0
  64. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  65. package/src/engine/steps/_duration-utils.ts +2 -0
  66. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  67. package/src/engine/types/config.ts +1 -1
  68. package/src/engine/types/define-handler.ts +1 -1
  69. package/src/engine/types/entity-handlers.ts +1 -1
  70. package/src/engine/types/event-type-map.ts +1 -1
  71. package/src/engine/types/feature.ts +1 -1
  72. package/src/engine/types/fields.ts +1 -1
  73. package/src/engine/types/handlers.ts +1 -1
  74. package/src/engine/types/hooks.ts +1 -1
  75. package/src/engine/types/http-route.ts +1 -1
  76. package/src/engine/types/nav.ts +1 -1
  77. package/src/engine/types/ownership.ts +1 -1
  78. package/src/engine/types/projection.ts +1 -1
  79. package/src/engine/types/relations.ts +1 -1
  80. package/src/engine/types/screen.ts +1 -1
  81. package/src/engine/types/step.ts +1 -1
  82. package/src/engine/types/target-ref.ts +1 -1
  83. package/src/engine/types/tree-node.ts +1 -1
  84. package/src/engine/types/workspace.ts +1 -1
  85. package/src/engine/validate-projection-allowlist.ts +5 -5
  86. package/src/errors/classes.ts +21 -0
  87. package/src/errors/index.ts +1 -0
  88. package/src/errors/write-error-info.ts +6 -2
  89. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  90. package/src/event-store/__tests__/event-store.integration.test.ts +32 -0
  91. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +22 -4
  92. package/src/event-store/admin-api.ts +11 -4
  93. package/src/event-store/event-store.ts +19 -4
  94. package/src/event-store/types.ts +1 -1
  95. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  96. package/src/files/__tests__/local-provider.test.ts +31 -0
  97. package/src/files/__tests__/write-stream.test.ts +3 -3
  98. package/src/files/index.ts +1 -1
  99. package/src/files/local-provider.ts +6 -1
  100. package/src/files/types.ts +8 -1
  101. package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
  102. package/src/jobs/job-runner.ts +41 -11
  103. package/src/logging/types.ts +1 -1
  104. package/src/observability/index.ts +1 -0
  105. package/src/observability/standard-metrics.ts +35 -2
  106. package/src/observability/types/index.ts +1 -1
  107. package/src/observability/types/metric.ts +1 -1
  108. package/src/observability/types/provider.ts +1 -1
  109. package/src/observability/types/span.ts +1 -1
  110. package/src/pipeline/__tests__/dispatcher.test.ts +151 -0
  111. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  112. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
  113. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +107 -97
  114. package/src/pipeline/dispatch-shared.ts +59 -6
  115. package/src/pipeline/dispatch-stream.ts +32 -11
  116. package/src/pipeline/dispatcher.ts +7 -1
  117. package/src/pipeline/event-consumer-state.ts +16 -13
  118. package/src/pipeline/event-dispatcher-delivery.ts +20 -6
  119. package/src/pipeline/event-dispatcher.ts +22 -0
  120. package/src/pipeline/index.ts +2 -0
  121. package/src/pipeline/system-hooks.ts +87 -0
  122. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  123. package/src/rate-limit/resolver.ts +6 -2
  124. package/src/schema-cli.ts +24 -12
  125. package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
  126. package/src/search/reindex-entity.ts +31 -2
  127. package/src/search/types.ts +1 -1
  128. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
  129. package/src/stack/db.ts +2 -1
  130. package/src/stack/push-entity-projection-tables.ts +2 -1
  131. package/src/stack/request-helper.ts +20 -1
  132. package/src/stack/table-helpers.ts +6 -4
  133. package/src/stack/test-stack.ts +18 -15
  134. package/src/testing/__tests__/late-bound.test.ts +7 -0
  135. package/src/testing/__tests__/wait-for.test.ts +6 -0
  136. package/src/testing/file-provider-contract.ts +26 -6
  137. package/src/testing/index.ts +1 -0
  138. package/src/testing/late-bound.ts +5 -3
  139. package/src/testing/wait-for.ts +3 -0
  140. package/src/testing/without-ambient-temporal.ts +14 -0
  141. package/src/time/geo-tz.ts +1 -1
  142. package/src/time/polyfill.ts +21 -38
  143. package/src/time/tz-context.ts +37 -31
  144. package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
  145. package/src/utils/safe-json.ts +13 -1
  146. package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
@@ -202,74 +202,78 @@ describe("runPostSave", () => {
202
202
  test("postSave errors don't throw — logged and continued", async () => {
203
203
  const calls: string[] = [];
204
204
  const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
205
-
206
- const registry = makeRegistry({
207
- postSave: [
208
- async () => {
209
- throw new Error("feature-fail");
210
- },
211
- ],
212
- });
213
-
214
- const systemHooks: SystemHooks = {
215
- postSave: [
216
- {
217
- name: "search",
218
- priority: 1000,
219
- fn: async () => {
220
- calls.push("search-ran");
205
+ try {
206
+ const registry = makeRegistry({
207
+ postSave: [
208
+ async () => {
209
+ throw new Error("feature-fail");
221
210
  },
222
- },
223
- ],
224
- };
225
-
226
- const pipeline = createLifecycleHooks(registry, systemHooks);
227
- // Should not throw
228
- await pipeline.runPostSave("test:write:user", savectx, {});
211
+ ],
212
+ });
229
213
 
230
- // System hook still ran despite feature hook failure
231
- expect(calls).toContain("search-ran");
232
- expect(consoleSpy).toHaveBeenCalled();
233
- consoleSpy.mockRestore();
214
+ const systemHooks: SystemHooks = {
215
+ postSave: [
216
+ {
217
+ name: "search",
218
+ priority: 1000,
219
+ fn: async () => {
220
+ calls.push("search-ran");
221
+ },
222
+ },
223
+ ],
224
+ };
225
+
226
+ const pipeline = createLifecycleHooks(registry, systemHooks);
227
+ // Should not throw
228
+ await pipeline.runPostSave("test:write:user", savectx, {});
229
+
230
+ // System hook still ran despite feature hook failure
231
+ expect(calls).toContain("search-ran");
232
+ expect(consoleSpy).toHaveBeenCalled();
233
+ } finally {
234
+ consoleSpy.mockRestore();
235
+ }
234
236
  });
235
237
 
236
238
  test("system hook failure doesn't block other system hooks", async () => {
237
239
  const calls: string[] = [];
238
240
  const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
239
-
240
- const registry = makeRegistry();
241
-
242
- const systemHooks: SystemHooks = {
243
- postSave: [
244
- {
245
- name: "search",
246
- priority: 1000,
247
- fn: async () => {
248
- throw new Error("meili-down");
241
+ try {
242
+ const registry = makeRegistry();
243
+
244
+ const systemHooks: SystemHooks = {
245
+ postSave: [
246
+ {
247
+ name: "search",
248
+ priority: 1000,
249
+ fn: async () => {
250
+ throw new Error("meili-down");
251
+ },
249
252
  },
250
- },
251
- {
252
- name: "sse",
253
- priority: 1001,
254
- fn: async () => {
255
- calls.push("sse-ran");
253
+ {
254
+ name: "sse",
255
+ priority: 1001,
256
+ fn: async () => {
257
+ calls.push("sse-ran");
258
+ },
256
259
  },
257
- },
258
- {
259
- name: "audit",
260
- priority: 1002,
261
- fn: async () => {
262
- calls.push("audit-ran");
260
+ {
261
+ name: "audit",
262
+ priority: 1002,
263
+ fn: async () => {
264
+ calls.push("audit-ran");
265
+ },
263
266
  },
264
- },
265
- ],
266
- };
267
+ ],
268
+ };
267
269
 
268
- const pipeline = createLifecycleHooks(registry, systemHooks);
269
- await pipeline.runPostSave("test:write:user", savectx, {});
270
+ const pipeline = createLifecycleHooks(registry, systemHooks);
271
+ await pipeline.runPostSave("test:write:user", savectx, {});
270
272
 
271
- expect(calls).toEqual(["sse-ran", "audit-ran"]);
272
- consoleSpy.mockRestore();
273
+ expect(calls).toEqual(["sse-ran", "audit-ran"]);
274
+ } finally {
275
+ consoleSpy.mockRestore();
276
+ }
273
277
  });
274
278
  });
275
279
 
@@ -336,29 +340,32 @@ describe("runPostSave phase routing", () => {
336
340
 
337
341
  test("afterCommit phase: hook errors are logged, never thrown", async () => {
338
342
  const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
339
- const afterRan: string[] = [];
340
- const feature = defineFeature("phases", (r) => {
341
- r.entity("user", createEntity({ table: "Users", fields: {} }));
342
- r.writeHandler("user", z.object({}), async () => ({ isSuccess: true, data: null }), {
343
- access: { openToAll: true },
344
- });
345
- r.hook("postSave", "user", async () => {
346
- throw new Error("afterCommit-boom");
343
+ try {
344
+ const afterRan: string[] = [];
345
+ const feature = defineFeature("phases", (r) => {
346
+ r.entity("user", createEntity({ table: "Users", fields: {} }));
347
+ r.writeHandler("user", z.object({}), async () => ({ isSuccess: true, data: null }), {
348
+ access: { openToAll: true },
349
+ });
350
+ r.hook("postSave", "user", async () => {
351
+ throw new Error("afterCommit-boom");
352
+ });
353
+ r.hook("postSave", "user", async () => {
354
+ afterRan.push("second");
355
+ });
347
356
  });
348
- r.hook("postSave", "user", async () => {
349
- afterRan.push("second");
350
- });
351
- });
352
- const registry = createRegistry([feature]);
353
- const pipeline = createLifecycleHooks(registry);
357
+ const registry = createRegistry([feature]);
358
+ const pipeline = createLifecycleHooks(registry);
354
359
 
355
- // Must not throw — errors are swallowed + logged
356
- await pipeline.runPostSave("phases:write:user", savectx, {}, "afterCommit");
360
+ // Must not throw — errors are swallowed + logged
361
+ await pipeline.runPostSave("phases:write:user", savectx, {}, "afterCommit");
357
362
 
358
- // Subsequent hooks still fire (failure in one hook doesn't block the rest)
359
- expect(afterRan).toEqual(["second"]);
360
- expect(consoleSpy).toHaveBeenCalled();
361
- consoleSpy.mockRestore();
363
+ // Subsequent hooks still fire (failure in one hook doesn't block the rest)
364
+ expect(afterRan).toEqual(["second"]);
365
+ expect(consoleSpy).toHaveBeenCalled();
366
+ } finally {
367
+ consoleSpy.mockRestore();
368
+ }
362
369
  });
363
370
 
364
371
  test("system hooks respect their phase setting", async () => {
@@ -724,28 +731,31 @@ describe("runPostSaveBatch / runPostDeleteBatch", () => {
724
731
  ],
725
732
  ])("one %s hook throwing doesn't stop the others (Promise.allSettled) — logged, never thrown", async (_name, buildHooks, run) => {
726
733
  const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
727
- const calls: string[] = [];
728
- const systemHooks = buildHooks([
729
- {
730
- name: "failing",
731
- priority: 1000,
732
- fn: async () => {
733
- throw new Error("batch-hook-boom");
734
+ try {
735
+ const calls: string[] = [];
736
+ const systemHooks = buildHooks([
737
+ {
738
+ name: "failing",
739
+ priority: 1000,
740
+ fn: async () => {
741
+ throw new Error("batch-hook-boom");
742
+ },
734
743
  },
735
- },
736
- {
737
- name: "ok",
738
- priority: 1001,
739
- fn: async () => {
740
- calls.push("ok-ran");
744
+ {
745
+ name: "ok",
746
+ priority: 1001,
747
+ fn: async () => {
748
+ calls.push("ok-ran");
749
+ },
741
750
  },
742
- },
743
- ]);
744
- const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
745
- // Must not throw.
746
- await run(pipeline);
747
- expect(calls).toEqual(["ok-ran"]);
748
- expect(consoleSpy).toHaveBeenCalled();
749
- consoleSpy.mockRestore();
751
+ ]);
752
+ const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
753
+ // Must not throw.
754
+ await run(pipeline);
755
+ expect(calls).toEqual(["ok-ran"]);
756
+ expect(consoleSpy).toHaveBeenCalled();
757
+ } finally {
758
+ consoleSpy.mockRestore();
759
+ }
750
760
  });
751
761
  });
@@ -1,4 +1,5 @@
1
1
  import { requestContext } from "../api/request-context";
2
+ import type { SseBroker } from "../api/sse-broker";
2
3
  import type { DbConnection, DbRunner, DbTx } from "../db/connection";
3
4
  import { runInSavepoint, selectMany } from "../db/query";
4
5
  import type { buildEntityTable } from "../db/table-builder";
@@ -30,7 +31,10 @@ import {
30
31
  isStreamArchived,
31
32
  restoreStream as restoreStreamHelper,
32
33
  } from "../event-store/archive";
33
- import { VersionConflictError as EventStoreVersionConflictError } from "../event-store/errors";
34
+ import {
35
+ IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError,
36
+ VersionConflictError as EventStoreVersionConflictError,
37
+ } from "../event-store/errors";
34
38
  import {
35
39
  getStreamVersion,
36
40
  loadAggregate,
@@ -53,6 +57,7 @@ import {
53
57
  emitDispatcherHandler,
54
58
  type getFallbackMeter,
55
59
  getFallbackTracer,
60
+ observabilityContext,
56
61
  } from "../observability";
57
62
  import { buildBucketKey } from "../rate-limit";
58
63
  import { createTzContext } from "../time";
@@ -92,6 +97,7 @@ export type DispatchContext = {
92
97
  lifecycle: LifecycleHooks | undefined;
93
98
  jobRunner: JobRunnerRef | undefined;
94
99
  effectiveFeatures: EffectiveFeaturesResolver | undefined;
100
+ sseBroker: SseBroker | undefined;
95
101
  tableCache: Map<string, ReturnType<typeof buildEntityTable>>;
96
102
  transitionCache: Map<string, ReturnType<typeof defineTransitions>>;
97
103
  tracer: ReturnType<typeof getFallbackTracer>;
@@ -208,6 +214,9 @@ export function buildHandlerContext(
208
214
  // `queryAs` / `writeAs` let a handler explicitly switch identity
209
215
  // (e.g. system-privileged lookups that bypass field-access read filters).
210
216
  const bridgeSink = afterCommitHooks ?? [];
217
+ const scheduleAfterCommit = (hook: AfterCommitHook): void => {
218
+ bridgeSink.push(hook);
219
+ };
211
220
  const bridge = {
212
221
  query: (targetType: string, payload: unknown) =>
213
222
  executeQuery(ctx, targetType, payload, user, tx), // @wrapper-known semantic-alias
@@ -263,6 +272,9 @@ export function buildHandlerContext(
263
272
  if (e instanceof EventStoreVersionConflictError) {
264
273
  return { ok: false as const, conflict: e };
265
274
  }
275
+ if (e instanceof EventStoreIdempotentAppendConflictError) {
276
+ return { ok: false as const, conflict: e };
277
+ }
266
278
  throw e;
267
279
  }
268
280
  },
@@ -542,6 +554,7 @@ export function buildHandlerContext(
542
554
  _userId: user.id,
543
555
  _tenantId: user.tenantId,
544
556
  _handlerType: type,
557
+ scheduleAfterCommit,
545
558
  ...(includeDeleted && { includeDeleted: true }),
546
559
  ...bridge,
547
560
  } as HandlerContext; // @cast-boundary engine-bridge
@@ -611,9 +624,13 @@ export async function runHandlerInstrumented<T>(
611
624
 
612
625
  // Generator-native counterpart to runHandlerInstrumented — a stream's
613
626
  // 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.
627
+ // can't be scoped via withSpan's single-callback shape. Drive the inner
628
+ // generator manually (not yield*): each pull must run inside
629
+ // observabilityContext.run({ activeSpan: span }) so handler-side
630
+ // startSpan() calls parent onto the dispatcher span. finally forwards
631
+ // .return() to the inner generator (yield* did that for free). Metrics
632
+ // land in the same finally path so success/failure/throw/abort all hit
633
+ // one emit, like the Promise path.
617
634
  export async function* runStreamInstrumented<T>(
618
635
  ctx: DispatchContext,
619
636
  type: string,
@@ -623,25 +640,61 @@ export async function* runStreamInstrumented<T>(
623
640
  const { tracer: dispatcherTracer, meter: dispatcherMeter, registry } = ctx;
624
641
  const start = performance.now();
625
642
  let success = true;
643
+ // Set only once `inner()` has drained on its own — NOT set when the
644
+ // consumer walks away early (generator.return(), the normal SSE-abort
645
+ // path in api/routes.ts). yield* forwards that .return() straight through
646
+ // without throwing, so without this flag an aborted stream would fall
647
+ // through to the success-metric branch below and get counted as a clean
648
+ // completion.
649
+ let completedNormally = false;
626
650
  let errorClass: string | undefined;
627
651
  const span = dispatcherTracer.startSpan("kumiko.dispatcher.handler", {
628
652
  attributes: dispatcherSpanAttributes(type, "stream", user, registry.getHandlerFeature(type)),
629
653
  });
654
+ const it = inner();
630
655
  try {
631
- yield* inner();
656
+ let next = await observabilityContext.run({ activeSpan: span }, () => it.next());
657
+ while (!next.done) {
658
+ yield next.value;
659
+ next = await observabilityContext.run({ activeSpan: span }, () => it.next());
660
+ }
661
+ completedNormally = true;
662
+ return next.value;
632
663
  } catch (error) {
633
664
  success = false;
634
665
  errorClass = error instanceof Error && error.name ? error.name : "UnknownError";
666
+ if (error instanceof Error) span.recordException(error);
635
667
  span.setStatus("error", errorClass);
636
668
  throw error;
637
669
  } finally {
670
+ // forward close so inner()'s finally still fires — yield* did this for free
671
+ try {
672
+ await observabilityContext.run({ activeSpan: span }, () => it.return?.(undefined));
673
+ } catch (closeError) {
674
+ // Only fold this in when nothing has already failed — a close-time
675
+ // error while an earlier error is in flight would mask the real
676
+ // cause reported above.
677
+ if (success) {
678
+ success = false;
679
+ errorClass =
680
+ closeError instanceof Error && closeError.name ? closeError.name : "UnknownError";
681
+ span.setStatus("error", errorClass);
682
+ }
683
+ }
638
684
  span.end();
639
685
  if (!success && errorClass) {
640
686
  emitDispatcherError(dispatcherMeter, { handler: type, errorClass });
641
687
  }
688
+ // Consumer-abort (generator.return(), the normal SSE Tab-close path)
689
+ // is not a handler failure — label it outcome:"aborted" so live-stream
690
+ // success rates stay meaningful. True handler throws keep success:false.
642
691
  emitDispatcherHandler(
643
692
  dispatcherMeter,
644
- { handler: type, success },
693
+ {
694
+ handler: type,
695
+ success,
696
+ outcome: completedNormally ? "completed" : "aborted",
697
+ },
645
698
  (performance.now() - start) / 1000,
646
699
  );
647
700
  }
@@ -53,17 +53,38 @@ async function* executeStreamInner(
53
53
  throw validationErrorFromZod(parsed.error);
54
54
  }
55
55
 
56
- const handlerContext = buildHandlerContext(ctx, type, user);
57
- const chunks = handler.handler({ type, payload: parsed.data, user }, handlerContext);
56
+ // Mid-stream access revocation: subscribed for the stream's lifetime, not
57
+ // just checked once like hasAccess above. A role/session/tenant change
58
+ // fires this via ctx.sseBroker.publishAccessInvalidation(userId) (session-
59
+ // revoke / tenant-membership consumers — issue #1559/#1560); the flag is
60
+ // read at the same per-chunk cadence as ensureFeatureEnabled below, so a
61
+ // stream cuts before its next chunk rather than running indefinitely.
62
+ let accessInvalidated = false;
63
+ const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(user.id, () => {
64
+ accessInvalidated = true;
65
+ });
58
66
 
59
- // Consumer-driven pull (for await) is the backpressure mechanism — the
60
- // handler generator only advances once the caller reads the previous
61
- // chunk, no explicit buffering/throttling needed on either side.
62
- for await (const chunk of chunks) {
63
- // Re-checked per chunk, not just at stream-start: a feature disabled
64
- // mid-stream must cut an already-open stream, not just block new ones.
65
- await ensureFeatureEnabled(ctx, type, user.tenantId);
66
- assertNoSecretLeak(chunk);
67
- yield chunk;
67
+ try {
68
+ const handlerContext = buildHandlerContext(ctx, type, user);
69
+ const chunks = handler.handler({ type, payload: parsed.data, user }, handlerContext);
70
+
71
+ // Consumer-driven pull (for await) is the backpressure mechanism the
72
+ // handler generator only advances once the caller reads the previous
73
+ // chunk, no explicit buffering/throttling needed on either side.
74
+ for await (const chunk of chunks) {
75
+ // Re-checked per chunk, not just at stream-start: a feature disabled
76
+ // mid-stream must cut an already-open stream, not just block new ones.
77
+ await ensureFeatureEnabled(ctx, type, user.tenantId);
78
+ if (accessInvalidated) {
79
+ throw new AccessDeniedError({
80
+ message: `access revoked mid-stream for ${type}`,
81
+ details: { handler: type },
82
+ });
83
+ }
84
+ assertNoSecretLeak(chunk);
85
+ yield chunk;
86
+ }
87
+ } finally {
88
+ unsubscribeAccessInvalidation?.();
68
89
  }
69
90
  }
@@ -1,3 +1,4 @@
1
+ import type { SseBroker } from "../api/sse-broker";
1
2
  import type { buildEntityTable } from "../db/table-builder";
2
3
  import type { defineTransitions } from "../engine/state-machine";
3
4
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
@@ -43,6 +44,10 @@ export type DispatcherOptions = {
43
44
  // event-skips and a confusing operator-UI — the framework cannot
44
45
  // enforce this contract, but the recipe-test pins the convention.
45
46
  effectiveFeatures?: EffectiveFeaturesResolver;
47
+ // In-memory SSE broker — dispatch-stream.ts subscribes to a stream's
48
+ // user-scoped access-invalidation channel on it. Absent in setups without
49
+ // SSE wired up (dispatch-stream then just skips the subscription).
50
+ sseBroker?: SseBroker;
46
51
  };
47
52
 
48
53
  export type Dispatcher = {
@@ -82,7 +87,7 @@ export function createDispatcher(
82
87
  context: AppContext,
83
88
  options: DispatcherOptions = {},
84
89
  ): Dispatcher {
85
- const { idempotency, lifecycle, jobRunner, effectiveFeatures } = options;
90
+ const { idempotency, lifecycle, jobRunner, effectiveFeatures, sseBroker } = options;
86
91
 
87
92
  // Pre-build tables and transition maps for auto-guard (avoid per-request allocation)
88
93
  const tableCache = new Map<string, ReturnType<typeof buildEntityTable>>();
@@ -101,6 +106,7 @@ export function createDispatcher(
101
106
  lifecycle,
102
107
  jobRunner,
103
108
  effectiveFeatures,
109
+ sseBroker,
104
110
  tableCache,
105
111
  transitionCache,
106
112
  tracer: dispatcherTracer,
@@ -10,8 +10,8 @@ import {
10
10
  sql,
11
11
  text,
12
12
  } from "../db/dialect";
13
- import { alterTableAddColumn } from "../db/queries/test-stack";
14
- import { columnNamesOf, tableExists } from "../db/schema-inspection";
13
+ import { alterTableAddColumn } from "../db/queries/ddl";
14
+ import { tableExists } from "../db/schema-inspection";
15
15
  import { unsafePushTables } from "../stack";
16
16
 
17
17
  // Reserved sentinel used in the instance_id column for consumers whose
@@ -112,17 +112,20 @@ export async function createEventConsumerStateTable(db: DbConnection): Promise<v
112
112
  // Still check for columns added after the table's first deploy (e.g.
113
113
  // rearm_count) so an older DB catches up without a dedicated migration.
114
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
- }
115
+ // ADD COLUMN IF NOT EXISTS is idempotent and race-safe on its own —
116
+ // no need to pre-check columnNamesOf first. Two instances booting
117
+ // concurrently against the same DB (this table is explicitly
118
+ // multi-instance) both ALTER, neither crashes on "column already
119
+ // exists" (#1362).
120
+ await alterTableAddColumn(
121
+ db,
122
+ "kumiko_event_consumers",
123
+ "rearm_count",
124
+ "integer",
125
+ " DEFAULT 0",
126
+ " NOT NULL",
127
+ /* ifNotExists */ true,
128
+ );
126
129
  // skip: table (+ any missing column) is already up to date
127
130
  return;
128
131
  }
@@ -1,3 +1,7 @@
1
+ // Value-only import, aliased to avoid shadowing the ambient global
2
+ // `Temporal` TYPE that ConsumerStateRow.updatedAt/StoredEventRow.createdAt
3
+ // resolve against (same #1438 dual-package-hazard pattern as event-store.ts).
4
+ import { Temporal as TemporalPolyfill } from "temporal-polyfill";
1
5
  import { requestContext } from "../api/request-context";
2
6
  import type { DbConnection, DbTx } from "../db/connection";
3
7
  import {
@@ -111,8 +115,18 @@ export async function acquireConsumerState(
111
115
  // a prior re-arm) gates the retry; maxRearmCount stops a poison event
112
116
  // from looping forever (re-arm → same event fails → dead → re-arm →
113
117
  // ...) — 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;
118
+ const cooldownDeadline = TemporalPolyfill.Now.instant().subtract({
119
+ milliseconds: rearmCooldownMs,
120
+ });
121
+ // @cast-boundary temporal-polyfill-vs-ambient: same TC39 Temporal.Instant
122
+ // at runtime — state.updatedAt is DB-row-typed against the ambient
123
+ // global, two distinct nominal types across the two .d.ts sources (see
124
+ // event-store.ts).
125
+ const cooldownElapsed =
126
+ TemporalPolyfill.Instant.compare(
127
+ state.updatedAt as unknown as InstanceType<typeof TemporalPolyfill.Instant>,
128
+ cooldownDeadline,
129
+ ) <= 0;
116
130
  if (cooldownElapsed && state.rearmCount < maxRearmCount) {
117
131
  const rearmed = await rearmDeadConsumer(tx, name, instanceId);
118
132
  const rearmedState =
@@ -120,10 +134,10 @@ export async function acquireConsumerState(
120
134
  (coerceRow(rearmed, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
121
135
  if (rearmedState) return { state: rearmedState, skip: null };
122
136
  }
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.
137
+ // Caller (event-dispatcher.ts's processConsumer) emits
138
+ // kumiko_event_consumer_rearm_exhausted_total once per (consumer,
139
+ // instance) transition into this branch it has the process-lifetime
140
+ // state to dedupe across poll passes that this pure function doesn't.
127
141
  return { state: null, skip: "dead" };
128
142
  }
129
143
  return { state, skip: null };
@@ -4,6 +4,7 @@ import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
4
4
  import { EVENTS_PUBSUB_CHANNEL, type StoredEvent } from "../event-store";
5
5
  import {
6
6
  emitEventConsumerPassOutcome,
7
+ emitEventConsumerRearmExhausted,
7
8
  emitEventDispatcherListenConnected,
8
9
  getFallbackMeter,
9
10
  getFallbackTracer,
@@ -201,6 +202,14 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
201
202
  }
202
203
  const tracer: Tracer = options.tracer ?? getFallbackTracer();
203
204
  const meter: Meter = options.meter ?? getFallbackMeter();
205
+ // Tracks which (consumer, instanceId) pairs already fired
206
+ // kumiko_event_consumer_rearm_exhausted_total, so a consumer stuck dead
207
+ // across many poll passes emits the ops-signal once, not every pass
208
+ // (that would be log/metric spam for a state that hasn't changed).
209
+ // Process-lifetime only — restarting the dispatcher re-arms reporting,
210
+ // which is fine: a fresh process re-observing a still-dead consumer is
211
+ // exactly the "still needs a human" signal ops wants.
212
+ const reportedDeadConsumers = new Set<string>();
204
213
 
205
214
  let running = false;
206
215
  // Separate from `running` on purpose: pre-registration of consumer state
@@ -304,8 +313,21 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
304
313
  // disabled/dead. Nothing to deliver this pass.
305
314
  if (acquired.skip !== null) {
306
315
  span.setAttribute("consumer.skip_reason", acquired.skip);
316
+ if (acquired.skip === "dead") {
317
+ const reportKey = `${consumer.name}:${instanceId}`;
318
+ if (!reportedDeadConsumers.has(reportKey)) {
319
+ reportedDeadConsumers.add(reportKey);
320
+ emitEventConsumerRearmExhausted(meter, { consumer: consumer.name, instanceId });
321
+ }
322
+ }
323
+ // skip: skip reason already recorded on the span above (and, for
324
+ // "dead", already emitted as a metric) — nothing left to deliver.
307
325
  return;
308
326
  }
327
+ // Acquired normally (including via a successful auto-rearm) — clear
328
+ // any prior dead-report so a future exhaustion re-emits instead of
329
+ // staying permanently suppressed by this process's Set.
330
+ reportedDeadConsumers.delete(`${consumer.name}:${instanceId}`);
309
331
 
310
332
  const events = await fetchPendingEvents(tx, acquired.state.lastProcessedEventId, batchSize);
311
333
  // skip: nothing to deliver — no markProcessing/persistConsumerOutcome write,
@@ -57,6 +57,8 @@ export {
57
57
  } from "./projection-state";
58
58
  export { runProjectionsForEvent } from "./projections-runner";
59
59
  export {
60
+ ACCESS_INVALIDATION_CONSUMER_NAME,
61
+ createAccessInvalidationEventConsumer,
60
62
  createSearchEventConsumer,
61
63
  createSseBroadcastEventConsumer,
62
64
  SEARCH_CONSUMER_NAME,