@cosmicdrift/kumiko-framework 0.165.0 → 0.165.2

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 (157) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -3
  3. package/src/__tests__/consumer-cli.integration.test.ts +32 -0
  4. package/src/__tests__/schema-cli.integration.test.ts +1 -1
  5. package/src/api/__tests__/api.test.ts +267 -19
  6. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  7. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
  8. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +2 -1
  9. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
  10. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +150 -0
  11. package/src/api/__tests__/batch.integration.test.ts +21 -2
  12. package/src/api/__tests__/jwt.test.ts +52 -2
  13. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
  14. package/src/api/__tests__/sse-broker.test.ts +57 -0
  15. package/src/api/__tests__/sse-route.test.ts +4 -0
  16. package/src/api/auth-routes.ts +178 -33
  17. package/src/api/index.ts +1 -0
  18. package/src/api/jwt.ts +22 -1
  19. package/src/api/routes.ts +103 -35
  20. package/src/api/server.ts +26 -2
  21. package/src/api/sse-broker.ts +38 -0
  22. package/src/bun-db/index.ts +1 -0
  23. package/src/bun-db/query.ts +14 -5
  24. package/src/consumer-cli.ts +60 -13
  25. package/src/db/__tests__/decimal-field.test.ts +3 -3
  26. package/src/db/__tests__/entity-table-meta-source.test.ts +43 -8
  27. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +14 -1
  28. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  29. package/src/db/__tests__/migrate-runner.test.ts +75 -0
  30. package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
  31. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +6 -6
  32. package/src/db/__tests__/tenant-db-where-merge.test.ts +10 -6
  33. package/src/db/api.ts +2 -2
  34. package/src/db/bun-provider.ts +2 -2
  35. package/src/db/collect-table-metas.ts +3 -3
  36. package/src/db/connection.ts +6 -3
  37. package/src/db/dialect.ts +1 -6
  38. package/src/db/entity-table-meta-types.ts +1 -1
  39. package/src/db/entity-table-meta.ts +49 -32
  40. package/src/db/event-store-executor-context.ts +2 -3
  41. package/src/db/event-store-executor-read.ts +2 -3
  42. package/src/db/event-store-executor-write.ts +8 -0
  43. package/src/db/index.ts +13 -2
  44. package/src/db/located-timestamp.ts +4 -0
  45. package/src/db/migrate-runner.ts +102 -11
  46. package/src/db/pg-error.ts +8 -0
  47. package/src/db/postgres-provider.ts +2 -2
  48. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  49. package/src/db/queries/ddl.ts +45 -0
  50. package/src/db/queries/event-store.ts +97 -5
  51. package/src/db/queries/test-stack.ts +4 -30
  52. package/src/db/reference-data.ts +2 -3
  53. package/src/db/replay-migration-sql.ts +114 -12
  54. package/src/db/table-builder.ts +2 -2
  55. package/src/db/tenant-db.ts +3 -5
  56. package/src/engine/__tests__/engine.test.ts +30 -0
  57. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  58. package/src/engine/__tests__/store-table.test.ts +10 -13
  59. package/src/engine/boot-validator/nav.ts +5 -0
  60. package/src/engine/constants.ts +28 -6
  61. package/src/engine/create-app.ts +11 -0
  62. package/src/engine/effective-features.ts +12 -2
  63. package/src/engine/extensions/user-data.ts +12 -4
  64. package/src/engine/feature-ast/extractors/round5.ts +1 -1
  65. package/src/engine/feature-ui-extensions.ts +3 -3
  66. package/src/engine/hook-helpers.ts +3 -1
  67. package/src/engine/index.ts +1 -1
  68. package/src/engine/ownership.ts +4 -3
  69. package/src/engine/registry-ingest.ts +14 -14
  70. package/src/engine/registry-state.ts +6 -3
  71. package/src/engine/schema-builder.ts +1 -0
  72. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  73. package/src/engine/steps/_duration-utils.ts +2 -0
  74. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  75. package/src/engine/types/config.ts +1 -1
  76. package/src/engine/types/define-handler.ts +1 -1
  77. package/src/engine/types/entity-handlers.ts +1 -1
  78. package/src/engine/types/event-type-map.ts +1 -1
  79. package/src/engine/types/feature.ts +1 -1
  80. package/src/engine/types/fields.ts +1 -1
  81. package/src/engine/types/handlers.ts +1 -1
  82. package/src/engine/types/hooks.ts +1 -1
  83. package/src/engine/types/http-route.ts +1 -1
  84. package/src/engine/types/nav.ts +1 -1
  85. package/src/engine/types/ownership.ts +1 -1
  86. package/src/engine/types/projection.ts +1 -1
  87. package/src/engine/types/relations.ts +1 -1
  88. package/src/engine/types/screen.ts +1 -1
  89. package/src/engine/types/step.ts +1 -1
  90. package/src/engine/types/target-ref.ts +1 -1
  91. package/src/engine/types/tree-node.ts +1 -1
  92. package/src/engine/types/workspace.ts +1 -1
  93. package/src/engine/validate-projection-allowlist.ts +6 -6
  94. package/src/errors/classes.ts +21 -0
  95. package/src/errors/index.ts +1 -0
  96. package/src/errors/write-error-info.ts +6 -2
  97. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  98. package/src/event-store/__tests__/event-store.integration.test.ts +32 -0
  99. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +22 -4
  100. package/src/event-store/admin-api.ts +11 -4
  101. package/src/event-store/event-store.ts +19 -4
  102. package/src/event-store/types.ts +1 -1
  103. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  104. package/src/files/__tests__/local-provider.test.ts +31 -0
  105. package/src/files/__tests__/write-stream.test.ts +3 -3
  106. package/src/files/index.ts +1 -1
  107. package/src/files/local-provider.ts +6 -1
  108. package/src/files/types.ts +8 -1
  109. package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
  110. package/src/jobs/job-runner.ts +41 -11
  111. package/src/logging/types.ts +1 -1
  112. package/src/migrations/__tests__/kumiko-drift.integration.test.ts +2 -2
  113. package/src/migrations/projection-table-index.ts +1 -1
  114. package/src/observability/index.ts +1 -0
  115. package/src/observability/standard-metrics.ts +35 -2
  116. package/src/observability/types/index.ts +1 -1
  117. package/src/observability/types/metric.ts +1 -1
  118. package/src/observability/types/provider.ts +1 -1
  119. package/src/observability/types/span.ts +1 -1
  120. package/src/pipeline/__tests__/dispatcher.test.ts +212 -0
  121. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  122. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
  123. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +107 -97
  124. package/src/pipeline/dispatch-shared.ts +59 -6
  125. package/src/pipeline/dispatch-stream.ts +50 -11
  126. package/src/pipeline/dispatcher.ts +7 -1
  127. package/src/pipeline/event-consumer-state.ts +16 -13
  128. package/src/pipeline/event-dispatcher-delivery.ts +20 -6
  129. package/src/pipeline/event-dispatcher.ts +22 -0
  130. package/src/pipeline/index.ts +2 -0
  131. package/src/pipeline/system-hooks.ts +87 -0
  132. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  133. package/src/rate-limit/resolver.ts +6 -2
  134. package/src/schema-cli.ts +24 -12
  135. package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
  136. package/src/search/reindex-entity.ts +31 -2
  137. package/src/search/types.ts +1 -1
  138. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
  139. package/src/stack/db.ts +2 -1
  140. package/src/stack/push-entity-projection-tables.ts +2 -1
  141. package/src/stack/request-helper.ts +20 -1
  142. package/src/stack/table-helpers.ts +6 -4
  143. package/src/stack/test-stack.ts +18 -15
  144. package/src/testing/__tests__/late-bound.test.ts +7 -0
  145. package/src/testing/__tests__/wait-for.test.ts +6 -0
  146. package/src/testing/file-provider-contract.ts +26 -6
  147. package/src/testing/index.ts +1 -0
  148. package/src/testing/late-bound.ts +5 -3
  149. package/src/testing/wait-for.ts +3 -0
  150. package/src/testing/without-ambient-temporal.ts +14 -0
  151. package/src/time/__tests__/polyfill-reinstall.test.ts +17 -0
  152. package/src/time/geo-tz.ts +1 -1
  153. package/src/time/polyfill.ts +28 -39
  154. package/src/time/tz-context.ts +30 -24
  155. package/src/utils/__tests__/safe-json-temporal.test.ts +14 -0
  156. package/src/utils/safe-json.ts +3 -2
  157. package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
@@ -8,7 +8,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
10
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
11
- import { buildEntityTableMeta } from "../../db/entity-table-meta";
11
+ import { deriveEntityTableMeta } from "../../db/entity-table-meta";
12
12
  import { generateMigration, writeSnapshotJson } from "../../db/migrate-generator";
13
13
  import {
14
14
  baselineMigrations,
@@ -200,7 +200,7 @@ describe("kumiko-drift end-to-end (generate → apply → gate)", () => {
200
200
  table: "kdrift_gen",
201
201
  fields: { name: createTextField({ required: true }) },
202
202
  });
203
- const meta = buildEntityTableMeta("kdriftGen", entity);
203
+ const meta = deriveEntityTableMeta("kdriftGen", entity);
204
204
  const result = generateMigration({
205
205
  metas: [meta],
206
206
  prevSnapshot: null,
@@ -3,7 +3,7 @@
3
3
  // rebuild-Marker → mappt Tabellen auf Projektionen → rebuildProjection).
4
4
  //
5
5
  // Drizzle-frei: der Tabellen-Name kommt aus dem kumiko-Symbol das
6
- // buildEntityTable/buildEntityTableMeta an die Table-Definition hängt.
6
+ // buildEntityTable/deriveEntityTableMeta an die Table-Definition hängt.
7
7
 
8
8
  import { extractTableName } from "../db";
9
9
  import type { Registry } from "../engine/types/feature";
@@ -50,6 +50,7 @@ export {
50
50
  emitDispatcherHandler,
51
51
  emitEventConsumerLag,
52
52
  emitEventConsumerPassOutcome,
53
+ emitEventConsumerRearmExhausted,
53
54
  emitEventDispatcherListenConnected,
54
55
  emitHttpRequest,
55
56
  emitJobQueueDepth,
@@ -23,7 +23,7 @@ export const STANDARD_METRIC_DEFS: readonly MetricDefinition[] = [
23
23
  name: "kumiko_dispatcher_handler_duration_seconds",
24
24
  type: "histogram",
25
25
  description: "Dispatcher handler latency in seconds.",
26
- labels: ["handler", "success"],
26
+ labels: ["handler", "success", "outcome"],
27
27
  buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
28
28
  },
29
29
  {
@@ -83,6 +83,20 @@ export const STANDARD_METRIC_DEFS: readonly MetricDefinition[] = [
83
83
  "Event deliveries that threw. Repeated failures on the same event lead to dead-letter.",
84
84
  labels: ["consumer", "instance_id"],
85
85
  },
86
+ // Fires once per (consumer, instance) transition into "stuck dead" —
87
+ // the rearm-cooldown/rearm-budget in event-dispatcher-delivery.ts gave up
88
+ // and the consumer now needs an operator (restartConsumer()) to recover.
89
+ // Without this, "dead + auto-rearm exhausted" is only visible via a
90
+ // getConsumerState poll — ops has no push signal that a consumer crossed
91
+ // from "will self-heal" to "needs a human". Deliberately a counter, not a
92
+ // gauge: emitted once per transition (event-dispatcher.ts tracks which
93
+ // consumers already fired this), not once per still-dead poll pass.
94
+ {
95
+ name: "kumiko_event_consumer_rearm_exhausted_total",
96
+ type: "counter",
97
+ description: "Consumer transitioned to dead with its auto-rearm budget exhausted.",
98
+ labels: ["consumer", "instance_id"],
99
+ },
86
100
  // LISTEN-subscription health. 1 = active, 0 = dropped. Drops to 0 while
87
101
  // the dispatcher is running signal that delivery latency has regressed
88
102
  // from sub-millisecond (LISTEN) to pollIntervalMs (timer fallback); ops
@@ -141,12 +155,18 @@ export function emitHttpRequest(
141
155
 
142
156
  export function emitDispatcherHandler(
143
157
  meter: Meter,
144
- labels: { readonly handler: string; readonly success: boolean },
158
+ labels: {
159
+ readonly handler: string;
160
+ readonly success: boolean;
161
+ /** completed = drained normally; aborted = consumer walked away (SSE). */
162
+ readonly outcome?: "completed" | "aborted";
163
+ },
145
164
  durationSeconds: number,
146
165
  ): void {
147
166
  meter.histogram("kumiko_dispatcher_handler_duration_seconds").observe(durationSeconds, {
148
167
  handler: labels.handler,
149
168
  success: String(labels.success),
169
+ outcome: labels.outcome ?? "completed",
150
170
  });
151
171
  }
152
172
 
@@ -203,6 +223,19 @@ export function emitEventConsumerLag(
203
223
  });
204
224
  }
205
225
 
226
+ // Caller (event-dispatcher.ts) is responsible for calling this exactly
227
+ // once per (consumer, instanceId) transition into exhausted-dead — not
228
+ // once per poll pass while it stays dead.
229
+ export function emitEventConsumerRearmExhausted(
230
+ meter: Meter,
231
+ labels: { readonly consumer: string; readonly instanceId: string },
232
+ ): void {
233
+ meter.counter("kumiko_event_consumer_rearm_exhausted_total").inc(1, {
234
+ consumer: labels.consumer,
235
+ instance_id: labels.instanceId,
236
+ });
237
+ }
238
+
206
239
  export function emitEventDispatcherListenConnected(meter: Meter, connected: boolean): void {
207
240
  meter.gauge("kumiko_event_dispatcher_listen_connected").set(connected ? 1 : 0);
208
241
  }
@@ -1 +1 @@
1
- export * from "@cosmicdrift/kumiko-types/observability";
1
+ export type * from "@cosmicdrift/kumiko-types/observability";
@@ -1 +1 @@
1
- export * from "@cosmicdrift/kumiko-types/observability/metric";
1
+ export type * from "@cosmicdrift/kumiko-types/observability/metric";
@@ -1 +1 @@
1
- export * from "@cosmicdrift/kumiko-types/observability/provider";
1
+ export type * from "@cosmicdrift/kumiko-types/observability/provider";
@@ -1 +1 @@
1
- export * from "@cosmicdrift/kumiko-types/observability/span";
1
+ export type * from "@cosmicdrift/kumiko-types/observability/span";
@@ -1,9 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { z } from "zod";
3
+ import type { SseBroker } from "../../api/sse-broker";
3
4
  import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
4
5
  import type { TenantId } from "../../engine/types/identifiers";
5
6
  import { createSecret } from "../../secrets/types";
6
7
  import { createTestUser } from "../../stack";
8
+ import { createRecordingProvider } from "../../testing";
7
9
  import { createDispatcher } from "../dispatcher";
8
10
 
9
11
  const streamCleanupState = { closed: false };
@@ -291,6 +293,66 @@ describe("dispatcher.stream", () => {
291
293
  ).rejects.toMatchObject({ code: "access_denied" });
292
294
  });
293
295
 
296
+ test("access invalidation mid-stream rejects with AccessDeniedError and unsubscribes", async () => {
297
+ let onInvalidate: (() => void) | undefined;
298
+ let unsubscribeCalls = 0;
299
+ const broker: SseBroker = {
300
+ addClient() {
301
+ return "c";
302
+ },
303
+ removeClient() {},
304
+ pushToChannel() {},
305
+ getClientCount() {
306
+ return 0;
307
+ },
308
+ getTotalClientCount() {
309
+ return 0;
310
+ },
311
+ subscribeAccessInvalidation(_userId, cb) {
312
+ onInvalidate = cb;
313
+ return () => {
314
+ unsubscribeCalls++;
315
+ };
316
+ },
317
+ publishAccessInvalidation() {},
318
+ };
319
+
320
+ let releaseHang: (() => void) | undefined;
321
+ const hang = new Promise<void>((resolve) => {
322
+ releaseHang = resolve;
323
+ });
324
+
325
+ const revokeFeature = defineFeature("revoke", (r) => {
326
+ r.streamHandler(
327
+ "tail",
328
+ z.object({}),
329
+ async function* () {
330
+ yield { i: 0 };
331
+ await hang;
332
+ yield { i: 1 };
333
+ releaseHang?.();
334
+ },
335
+ { access: { roles: ["Admin"] } },
336
+ );
337
+ });
338
+
339
+ const user = createTestUser({ roles: ["Admin"] });
340
+ const dispatcher = createDispatcher(createRegistry([revokeFeature]), {}, { sseBroker: broker });
341
+ const gen = dispatcher.stream("revoke:stream:tail", {}, user);
342
+ const first = await gen.next();
343
+ expect(first.value).toEqual({ i: 0 });
344
+ expect(onInvalidate).toBeDefined();
345
+ // Start the idle second pull, then revoke — mirrors heartbeat-only SSE
346
+ // streams that must die without waiting for the next chunk (#1563).
347
+ const second = gen.next();
348
+ onInvalidate?.();
349
+ await expect(second).rejects.toMatchObject({
350
+ code: "access_denied",
351
+ message: expect.stringContaining("access revoked mid-stream"),
352
+ });
353
+ expect(unsubscribeCalls).toBe(1);
354
+ });
355
+
294
356
  test("throws for unknown stream handler", async () => {
295
357
  const dispatcher = createTestDispatcher();
296
358
 
@@ -324,6 +386,156 @@ describe("dispatcher.stream", () => {
324
386
 
325
387
  expect(streamCleanupState.closed).toBe(true);
326
388
  });
389
+
390
+ test("activates the dispatcher span in observabilityContext for every pull, not just the first", async () => {
391
+ // Regression for #1516: runStreamInstrumented used to start the span but
392
+ // never run the stream inside observabilityContext.run(...), so a
393
+ // handler-side startSpan() picked up whatever ALS context happened to be
394
+ // active at that pull (or none) instead of the stream's dispatcher span.
395
+ const provider = createRecordingProvider();
396
+ const nestedFeature = defineFeature("nested", (r) => {
397
+ r.streamHandler(
398
+ "tail",
399
+ z.object({ count: z.number().int().min(0) }),
400
+ async function* (query, handlerCtx) {
401
+ for (let i = 0; i < query.payload.count; i++) {
402
+ const child = handlerCtx.tracer.startSpan("nested.child");
403
+ child.end();
404
+ yield { i };
405
+ }
406
+ },
407
+ { access: { openToAll: true } },
408
+ );
409
+ });
410
+ const dispatcher = createDispatcher(createRegistry([nestedFeature]), {
411
+ tracer: provider.tracer,
412
+ meter: provider.meter,
413
+ });
414
+
415
+ await collect(dispatcher.stream("nested:stream:tail", { count: 3 }, createTestUser()));
416
+
417
+ const dispatcherSpan = provider.spansByName("kumiko.dispatcher.handler")[0]!;
418
+ const childSpans = provider.spansByName("nested.child");
419
+ expect(childSpans).toHaveLength(3);
420
+ for (const child of childSpans) {
421
+ expect(child.parentSpanId).toBe(dispatcherSpan.spanId);
422
+ }
423
+ });
424
+
425
+ test("a close-time error on an otherwise-clean stream is reported, not swallowed", async () => {
426
+ // Regression for #1543: runStreamInstrumented's finally used to
427
+ // catch+discard any error from generator.return() unconditionally, even
428
+ // when nothing else had failed — an abandoned stream whose handler's
429
+ // own cleanup throws (closing a cursor/unsubscribing) was invisible to
430
+ // both the error-counter and the span status.
431
+ const provider = createRecordingProvider();
432
+ const closeFailFeature = defineFeature("closefail", (r) => {
433
+ r.streamHandler(
434
+ "tail",
435
+ z.object({}),
436
+ async function* () {
437
+ try {
438
+ yield { i: 0 };
439
+ yield { i: 1 };
440
+ } finally {
441
+ // biome-ignore lint/correctness/noUnsafeFinally: deliberately simulating a handler whose close-time cleanup fails
442
+ throw new Error("close-boom");
443
+ }
444
+ },
445
+ { access: { openToAll: true } },
446
+ );
447
+ });
448
+ const dispatcher = createDispatcher(createRegistry([closeFailFeature]), {
449
+ tracer: provider.tracer,
450
+ meter: provider.meter,
451
+ });
452
+
453
+ const gen = dispatcher.stream("closefail:stream:tail", {}, createTestUser());
454
+ for await (const _chunk of gen) {
455
+ break;
456
+ }
457
+
458
+ const errorCounters = provider.metricEvents.filter(
459
+ (e) => e.type === "counter.inc" && e.name === "kumiko_dispatcher_handler_errors_total",
460
+ );
461
+ expect(errorCounters).toHaveLength(1);
462
+ const dispatcherSpan = provider.spansByName("kumiko.dispatcher.handler")[0]!;
463
+ expect(dispatcherSpan.status).toBe("error");
464
+ });
465
+
466
+ test("draining a stream to completion emits outcome:completed", async () => {
467
+ // completedNormally=true only when the inner generator finishes on its
468
+ // own — pins the success-path label so a refactor that drops the flag
469
+ // cannot silently reclassify clean streams as aborted (#1551).
470
+ const provider = createRecordingProvider();
471
+ const feature = defineFeature("complete", (r) => {
472
+ r.streamHandler(
473
+ "tail",
474
+ z.object({}),
475
+ async function* () {
476
+ yield { i: 0 };
477
+ yield { i: 1 };
478
+ },
479
+ { access: { openToAll: true } },
480
+ );
481
+ });
482
+ const dispatcher = createDispatcher(createRegistry([feature]), {
483
+ tracer: provider.tracer,
484
+ meter: provider.meter,
485
+ });
486
+
487
+ await collect(dispatcher.stream("complete:stream:tail", {}, createTestUser()));
488
+
489
+ const duration = provider.metricEvents.find(
490
+ (e) =>
491
+ e.type === "histogram.observe" && e.name === "kumiko_dispatcher_handler_duration_seconds",
492
+ );
493
+ expect(duration).toBeDefined();
494
+ expect(duration?.labels).toMatchObject({
495
+ handler: "complete:stream:tail",
496
+ success: "true",
497
+ outcome: "completed",
498
+ });
499
+ });
500
+
501
+ test("generator.return() mid-stream emits outcome:aborted, not completed", async () => {
502
+ // Consumer walk-away (SSE tab-close) must not count as a clean
503
+ // completion — completedNormally stays false when return() interrupts
504
+ // the loop (#1551).
505
+ const provider = createRecordingProvider();
506
+ const feature = defineFeature("abort", (r) => {
507
+ r.streamHandler(
508
+ "tail",
509
+ z.object({}),
510
+ async function* () {
511
+ yield { i: 0 };
512
+ yield { i: 1 };
513
+ yield { i: 2 };
514
+ },
515
+ { access: { openToAll: true } },
516
+ );
517
+ });
518
+ const dispatcher = createDispatcher(createRegistry([feature]), {
519
+ tracer: provider.tracer,
520
+ meter: provider.meter,
521
+ });
522
+
523
+ const gen = dispatcher.stream("abort:stream:tail", {}, createTestUser());
524
+ const first = await gen.next();
525
+ expect(first.value).toEqual({ i: 0 });
526
+ await gen.return(undefined);
527
+
528
+ const duration = provider.metricEvents.find(
529
+ (e) =>
530
+ e.type === "histogram.observe" && e.name === "kumiko_dispatcher_handler_duration_seconds",
531
+ );
532
+ expect(duration).toBeDefined();
533
+ expect(duration?.labels).toMatchObject({
534
+ handler: "abort:stream:tail",
535
+ success: "true",
536
+ outcome: "aborted",
537
+ });
538
+ });
327
539
  });
328
540
 
329
541
  // --- postQuery hooks on standalone (entity-less) queries ---
@@ -0,0 +1,31 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { createTestDb, type TestDb } from "../../stack";
3
+ import { createEventConsumerStateTable } from "../event-consumer-state";
4
+
5
+ // #1362: two dispatcher instances booting concurrently against the same DB
6
+ // both see kumiko_event_consumers already present but rearm_count missing,
7
+ // and both ALTER TABLE. Without IF NOT EXISTS the loser crashes on boot
8
+ // with "column already exists" — a real TOCTOU since this table is
9
+ // explicitly multi-instance.
10
+ describe("createEventConsumerStateTable — concurrent boot", () => {
11
+ let testDb: TestDb;
12
+
13
+ beforeAll(async () => {
14
+ testDb = await createTestDb();
15
+ });
16
+
17
+ afterAll(async () => {
18
+ await testDb.cleanup();
19
+ });
20
+
21
+ test("running it twice concurrently does not throw", async () => {
22
+ await createEventConsumerStateTable(testDb.db);
23
+
24
+ await expect(
25
+ Promise.all([
26
+ createEventConsumerStateTable(testDb.db),
27
+ createEventConsumerStateTable(testDb.db),
28
+ ]),
29
+ ).resolves.toBeDefined();
30
+ });
31
+ });
@@ -11,6 +11,13 @@ import { createEventStoreExecutor } from "../../db/event-store-executor";
11
11
  import { asRawClient } from "../../db/query";
12
12
  import { createTenantDb, type TenantDb } from "../../db/tenant-db";
13
13
  import { defineFeature } from "../../engine";
14
+ import {
15
+ DEFAULT_SENSITIVE_CONFIG,
16
+ type MetricEvent,
17
+ type ObservabilityProvider,
18
+ RecordingMeter,
19
+ RecordingTracer,
20
+ } from "../../observability";
14
21
  import { getConsumerState } from "../../pipeline";
15
22
  import {
16
23
  resetEventStore,
@@ -20,6 +27,7 @@ import {
20
27
  unsafeCreateEntityTable,
21
28
  } from "../../stack";
22
29
  import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
30
+ import { withoutAmbientTemporal } from "../../testing/without-ambient-temporal";
23
31
  import { SHARED_INSTANCE_SENTINEL } from "../event-consumer-state";
24
32
 
25
33
  const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
@@ -151,6 +159,23 @@ describe("issue #1350 — bounded dead-consumer re-arm", () => {
151
159
  expect(deadAgain?.rearmCount).toBe(0);
152
160
  });
153
161
 
162
+ test("kumiko-framework#1525: cooldown-elapsed re-arm check works without relying on globalThis.Temporal", async () => {
163
+ poisonNames.add("poison");
164
+ await appendWidget("poison");
165
+ await driveUntilDead();
166
+ await backdateUpdatedAt(10);
167
+
168
+ // ensureTemporalPolyfill re-installs if the ambient global was torn down,
169
+ // so runOnce stays safe if a nested path calls it during this window.
170
+ await withoutAmbientTemporal(async () => {
171
+ await stack.eventDispatcher?.runOnce();
172
+ });
173
+
174
+ const revived = await getConsumerState(stack.db, qn);
175
+ expect(revived?.status).toBe("idle");
176
+ expect(revived?.rearmCount).toBe(1);
177
+ });
178
+
154
179
  test("permanently dead after maxRearmCount cycles, even once cooldown elapses again", async () => {
155
180
  poisonNames.add("poison");
156
181
  await appendWidget("poison");
@@ -177,4 +202,62 @@ describe("issue #1350 — bounded dead-consumer re-arm", () => {
177
202
  expect(stillExhausted?.status).toBe("dead");
178
203
  expect(stillExhausted?.rearmCount).toBe(3);
179
204
  });
205
+
206
+ test("kumiko_event_consumer_rearm_exhausted_total fires once, not per still-dead pass (#1362)", async () => {
207
+ // Dedicated stack with a RecordingMeter — module-level `stack` above is
208
+ // shared across every test in this file and has no metric capture.
209
+ const metricEvents: MetricEvent[] = [];
210
+ const meter = new RecordingMeter((e) => metricEvents.push(e));
211
+ const tracer = new RecordingTracer({
212
+ sensitiveConfig: DEFAULT_SENSITIVE_CONFIG,
213
+ onSpanEnd: () => {},
214
+ });
215
+ const recordingProvider: ObservabilityProvider = {
216
+ name: "recording",
217
+ meter,
218
+ tracer,
219
+ shutdown: async () => {},
220
+ };
221
+
222
+ const recStack = await setupTestStack({
223
+ features: [rearmFeature],
224
+ systemHooks: [],
225
+ observability: recordingProvider,
226
+ });
227
+ try {
228
+ await unsafeCreateEntityTable(recStack.db, sharedWidgetEntity, "widget");
229
+ const recTdb = createTenantDb(recStack.db, admin.tenantId);
230
+
231
+ poisonNames.add("poison-metric");
232
+ await executor.create({ name: "poison-metric" }, admin, recTdb);
233
+ for (let i = 0; i < 10; i++) {
234
+ await recStack.eventDispatcher?.runOnce();
235
+ }
236
+ // The 10th pass is the one whose failed delivery flips status to
237
+ // "dead" (persisted for the NEXT pass to see) — acquireConsumerState's
238
+ // dead-branch (where the exhausted-metric fires) only runs starting
239
+ // from the pass AFTER that transition, same as the "stays dead within
240
+ // cooldown window" test above needing one extra runOnce().
241
+ await recStack.eventDispatcher?.runOnce();
242
+
243
+ const exhaustedEvents = () =>
244
+ metricEvents.filter(
245
+ (e) =>
246
+ e.type === "counter.inc" && e.name === "kumiko_event_consumer_rearm_exhausted_total",
247
+ );
248
+ expect(exhaustedEvents().length).toBe(1);
249
+
250
+ // Several more passes while it's still dead — must stay at 1, not
251
+ // re-fire on every pass that observes "still dead" (log/metric spam
252
+ // the finding flagged).
253
+ for (let i = 0; i < 5; i++) {
254
+ await recStack.eventDispatcher?.runOnce();
255
+ }
256
+ expect(exhaustedEvents().length).toBe(1);
257
+ expect(exhaustedEvents()[0]?.labels?.["consumer"]).toBe(qn);
258
+ } finally {
259
+ await resetEventStore(recStack, ["read_widgets"]);
260
+ await recStack.cleanup();
261
+ }
262
+ });
180
263
  });