@cosmicdrift/kumiko-framework 0.307.0 → 0.308.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 (161) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/anonymous-access.integration.test.ts +1 -1
  3. package/src/__tests__/error-contract.integration.test.ts +1 -1
  4. package/src/__tests__/field-access.integration.test.ts +1 -1
  5. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +1 -1
  6. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +1 -1
  7. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +1 -1
  8. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +1 -1
  9. package/src/__tests__/full-stack.integration.test.ts +1 -1
  10. package/src/__tests__/transition-guard.integration.test.ts +1 -1
  11. package/src/api/__tests__/api.test.ts +1 -1
  12. package/src/api/__tests__/batch.integration.test.ts +1 -1
  13. package/src/api/__tests__/body-limit.test.ts +1 -1
  14. package/src/api/__tests__/dispatcher-live.integration.test.ts +1 -1
  15. package/src/api/__tests__/extra-routes.integration.test.ts +1 -1
  16. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +1 -1
  17. package/src/api/__tests__/nested-write-ownership.integration.test.ts +1 -1
  18. package/src/api/__tests__/nested-write.integration.test.ts +1 -1
  19. package/src/api/__tests__/pii-leak-guard.integration.test.ts +1 -1
  20. package/src/api/__tests__/redis-sse-broker.integration.test.ts +73 -6
  21. package/src/api/__tests__/request-locale.integration.test.ts +1 -1
  22. package/src/api/__tests__/server-boot-guards.test.ts +1 -1
  23. package/src/api/__tests__/server-error-logging.test.ts +1 -1
  24. package/src/api/__tests__/sse-broker.test.ts +107 -13
  25. package/src/api/__tests__/stream-escape-hatch.integration.test.ts +1 -1
  26. package/src/api/auth-routes.ts +1 -1
  27. package/src/api/redis-sse-broker.ts +51 -11
  28. package/src/api/server.ts +11 -0
  29. package/src/api/sse-broker.ts +61 -22
  30. package/src/changes.json +37 -0
  31. package/src/compliance/override-schema.ts +1 -1
  32. package/src/crypto/__tests__/event-pii.test.ts +1 -1
  33. package/src/crypto/kms-adapter.ts +1 -1
  34. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +1 -1
  35. package/src/db/queries/event-consumer.ts +98 -6
  36. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +1 -1
  37. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +1 -1
  38. package/src/engine/__tests__/boot-validator-dashboard.test.ts +1 -1
  39. package/src/engine/__tests__/boot-validator-nav-area.test.ts +1 -1
  40. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
  41. package/src/engine/__tests__/boot-validator-projection-list.test.ts +1 -1
  42. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +1 -1
  43. package/src/engine/__tests__/boot-validator-query-refs.test.ts +1 -1
  44. package/src/engine/__tests__/boot-validator-ref-entity.test.ts +1 -1
  45. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +1 -1
  46. package/src/engine/__tests__/boot-validator.test.ts +35 -1
  47. package/src/engine/__tests__/build-app-schema.test.ts +1 -1
  48. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +1 -1
  49. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +1 -1
  50. package/src/engine/__tests__/engine.test.ts +1 -1
  51. package/src/engine/__tests__/event-helpers.test.ts +1 -1
  52. package/src/engine/__tests__/event-migration-declarative.test.ts +1 -1
  53. package/src/engine/__tests__/extend-entity-projection.test.ts +1 -1
  54. package/src/engine/__tests__/extends-registrar.test.ts +1 -1
  55. package/src/engine/__tests__/hook-phases.test.ts +1 -1
  56. package/src/engine/__tests__/lifecycle-hooks.test.ts +1 -1
  57. package/src/engine/__tests__/pipeline-engine.test.ts +1 -1
  58. package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
  59. package/src/engine/__tests__/pipeline-observability.integration.test.ts +1 -1
  60. package/src/engine/__tests__/pipeline-performance.integration.test.ts +1 -1
  61. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +1 -1
  62. package/src/engine/__tests__/post-query-hook.test.ts +1 -1
  63. package/src/engine/__tests__/projection-detail-actions.test.ts +1 -1
  64. package/src/engine/__tests__/projection-detail-singleton.test.ts +1 -1
  65. package/src/engine/__tests__/projection-detail-tabs.test.ts +1 -1
  66. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +1 -1
  67. package/src/engine/__tests__/registry.test.ts +1 -1
  68. package/src/engine/__tests__/screen.test.ts +1 -1
  69. package/src/engine/__tests__/validate-projection-allowlist.test.ts +1 -1
  70. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +1 -1
  71. package/src/engine/boot-validator/__tests__/action-icon.test.ts +81 -0
  72. package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +1 -1
  73. package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +1 -1
  74. package/src/engine/boot-validator/__tests__/parent-ref.test.ts +1 -1
  75. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +1 -1
  76. package/src/engine/boot-validator/screens.ts +83 -0
  77. package/src/engine/define-handler.ts +2 -1
  78. package/src/engine/entity-handlers.ts +2 -1
  79. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +1 -1
  80. package/src/engine/feature-ast/pattern-change-schema.ts +1 -1
  81. package/src/engine/feature-builder-state.ts +2 -1
  82. package/src/engine/feature-config-events-jobs.ts +2 -1
  83. package/src/engine/feature-entity-handlers.ts +2 -1
  84. package/src/engine/schema-builder.ts +1 -1
  85. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
  86. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +1 -1
  87. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
  88. package/src/env/__tests__/compose-env-schema.test.ts +1 -1
  89. package/src/env/__tests__/dry-run.test.ts +1 -1
  90. package/src/env/__tests__/parse-env-dry-run.test.ts +1 -1
  91. package/src/env/_zod-introspect.ts +1 -1
  92. package/src/env/dry-run.ts +1 -1
  93. package/src/env/index.ts +1 -1
  94. package/src/errors/__tests__/classes.test.ts +1 -1
  95. package/src/es-ops/__tests__/runner.integration.test.ts +1 -1
  96. package/src/event-store/__tests__/backfill-pii.integration.test.ts +1 -1
  97. package/src/event-store/__tests__/event-attribution.integration.test.ts +1 -1
  98. package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
  99. package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -1
  100. package/src/jobs/__tests__/job-event-trigger.integration.test.ts +1 -1
  101. package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +1 -1
  102. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +1 -1
  103. package/src/jobs/__tests__/job-retention.integration.test.ts +1 -1
  104. package/src/jobs/__tests__/job-write-as.integration.test.ts +1 -1
  105. package/src/jobs/__tests__/jobs.integration.test.ts +1 -1
  106. package/src/observability/__tests__/metrics-wiring.test.ts +1 -1
  107. package/src/observability/__tests__/observability.integration.test.ts +1 -1
  108. package/src/observability/metrics-wiring.ts +1 -1
  109. package/src/pipeline/__tests__/archive-stream.integration.test.ts +1 -1
  110. package/src/pipeline/__tests__/causation-chain.integration.test.ts +1 -1
  111. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +1 -1
  112. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +1 -1
  113. package/src/pipeline/__tests__/dispatcher.test.ts +1 -1
  114. package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +1 -1
  115. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +1 -1
  116. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +1 -1
  117. package/src/pipeline/__tests__/event-dedup.integration.test.ts +1 -1
  118. package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +1 -1
  119. package/src/pipeline/__tests__/event-dispatcher-batch-delivery.test.ts +178 -0
  120. package/src/pipeline/__tests__/event-dispatcher-batch-handler.integration.test.ts +143 -0
  121. package/src/pipeline/__tests__/event-dispatcher-idle.integration.test.ts +94 -0
  122. package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +103 -3
  123. package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +1 -1
  124. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +1 -1
  125. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +1 -1
  126. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  127. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
  128. package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +1 -1
  129. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +116 -1
  130. package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +1 -1
  131. package/src/pipeline/__tests__/pending-gap-ranges.test.ts +94 -0
  132. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +1 -1
  133. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +1 -1
  134. package/src/pipeline/__tests__/query-projection.integration.test.ts +1 -1
  135. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +1 -1
  136. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +1 -1
  137. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +1 -1
  138. package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +1 -1
  139. package/src/pipeline/__tests__/try-append-event.integration.test.ts +1 -1
  140. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +1 -1
  141. package/src/pipeline/dispatch-stream.ts +6 -1
  142. package/src/pipeline/event-dispatcher-delivery.ts +132 -37
  143. package/src/pipeline/event-dispatcher.ts +96 -9
  144. package/src/pipeline/index.ts +1 -0
  145. package/src/pipeline/msp-rebuild.ts +39 -1
  146. package/src/pipeline/pending-gap-ranges.ts +48 -0
  147. package/src/pipeline/system-hooks.ts +223 -68
  148. package/src/pipeline/write-origin.ts +1 -1
  149. package/src/rate-limit/__tests__/dispatcher-l3.integration.test.ts +1 -1
  150. package/src/search/__tests__/derive-search-adapter-config.test.ts +65 -0
  151. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +91 -0
  152. package/src/search/__tests__/search-consumer-batch.integration.test.ts +306 -0
  153. package/src/search/derive-search-adapter-config.ts +27 -0
  154. package/src/search/index.ts +1 -0
  155. package/src/search/meilisearch-adapter.ts +96 -14
  156. package/src/secrets/__tests__/contains-secret.test.ts +1 -1
  157. package/src/stack/__tests__/request-helper.integration.test.ts +1 -1
  158. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
  159. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +1 -1
  160. package/src/testing/__tests__/e2e-generator.test.ts +1 -1
  161. package/src/testing/e2e-generator.ts +1 -1
@@ -6,7 +6,7 @@
6
6
  // streams never pay for extra metadata writes.
7
7
 
8
8
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
9
- import { z } from "zod";
9
+ import * as z from "zod";
10
10
  import { createEventStoreExecutor } from "../../db/event-store-executor";
11
11
  import { buildEntityTable } from "../../db/table-builder";
12
12
  import { createEntity, createTextField, defineFeature } from "../../engine";
@@ -16,7 +16,7 @@
16
16
  // msp-multi-hop.integration.ts.
17
17
 
18
18
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
19
- import { z } from "zod";
19
+ import * as z from "zod";
20
20
  import { requestContext } from "../../api/request-context";
21
21
  import { createEventStoreExecutor } from "../../db/event-store-executor";
22
22
  import { selectMany } from "../../db/query";
@@ -1,5 +1,5 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { createEventStoreExecutor } from "../../db/event-store-executor";
4
4
  import { asRawClient, selectMany } from "../../db/query";
5
5
  import { buildEntityTable } from "../../db/table-builder";
@@ -1,5 +1,5 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { defineFeature } from "../../engine";
4
4
  import { setupTestStack, type TestStack, TestUsers } from "../../stack";
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import type { SseBroker } from "../../api/sse-broker";
4
4
  import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
5
5
  import type { TenantId } from "../../engine/types/identifiers";
@@ -10,7 +10,7 @@
10
10
  // projections-runner fires on appendEvent, dispatcher routes appendEvent to
11
11
  // the aggregate stream), any of the assertions below go red.
12
12
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
13
- import { z } from "zod";
13
+ import * as z from "zod";
14
14
  import {
15
15
  integer as pgInteger,
16
16
  table as pgTable,
@@ -4,7 +4,7 @@
4
4
  // system-identity-switch.integration.test.ts.
5
5
 
6
6
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
- import { z } from "zod";
7
+ import * as z from "zod";
8
8
  import { defineUnmanagedTable } from "../../db/entity-table-meta";
9
9
  import { executeRawQuery } from "../../db/queries/raw-sql";
10
10
  import { createSystemUser, defineFeature } from "../../engine";
@@ -3,7 +3,7 @@
3
3
  // Real HTTP calls + setupTestStack — never createTestDispatcher.
4
4
 
5
5
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
- import { z } from "zod";
6
+ import * as z from "zod";
7
7
  import { defineUnmanagedTable } from "../../db/entity-table-meta";
8
8
  import { defineFeature } from "../../engine";
9
9
  import { createTestUser, setupTestStack, type TestStack } from "../../stack";
@@ -1,5 +1,5 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { createEventStoreExecutor } from "../../db/event-store-executor";
4
4
  import { defineFeature, type SaveContext } from "../../engine";
5
5
  import {
@@ -11,7 +11,7 @@
11
11
  // 3. r.defineEvent returns `{ name: qualifiedName, schema }` — callers
12
12
  // pass `def.name` to ctx.appendEvent without building the qn manually.
13
13
  import { afterEach, beforeAll, describe, expect, test } from "bun:test";
14
- import { z } from "zod";
14
+ import * as z from "zod";
15
15
  import { selectMany } from "../../db/query";
16
16
  import { defineFeature } from "../../engine";
17
17
  import { eventsTable } from "../../event-store";
@@ -0,0 +1,178 @@
1
+ // #3227 — deliverEvents' batchHandler fast-path: batch success must land
2
+ // the exact outcome the per-event loop would produce, a batch throw falls
3
+ // back to per-event delivery without bumping attempts for the failed
4
+ // batch attempt itself, and pending-gap resolution still splits pending
5
+ // vs new ids the same way it does for the per-event path.
6
+
7
+ import { beforeAll, describe, expect, test } from "bun:test";
8
+ import type { AppContext } from "../../engine/types";
9
+ import { ensureTemporalPolyfill } from "../../time/polyfill";
10
+ import type { EventConsumer } from "../event-dispatcher";
11
+ import {
12
+ type ConsumerStateRow,
13
+ deliverEvents,
14
+ type StoredEventRow,
15
+ } from "../event-dispatcher-delivery";
16
+
17
+ beforeAll(async () => {
18
+ await ensureTemporalPolyfill();
19
+ });
20
+
21
+ function stubContext(): AppContext {
22
+ return {
23
+ db: {} as unknown as AppContext["db"],
24
+ redis: {} as unknown as AppContext["redis"],
25
+ registry: {} as unknown as AppContext["registry"],
26
+ } as AppContext;
27
+ }
28
+
29
+ function stubEvent(id: bigint): StoredEventRow {
30
+ return {
31
+ id,
32
+ aggregateId: "agg-1",
33
+ aggregateType: "widget",
34
+ tenantId: "tenant-1",
35
+ version: 1,
36
+ type: "widget.created",
37
+ eventVersion: 1,
38
+ payload: {},
39
+ metadata: { userId: "system" },
40
+ createdAt: Temporal.Now.instant(),
41
+ createdBy: "system",
42
+ };
43
+ }
44
+
45
+ function stubState(overrides: Partial<ConsumerStateRow> = {}): ConsumerStateRow {
46
+ return {
47
+ name: "consumer",
48
+ instanceId: "shared",
49
+ lastProcessedEventId: 0n,
50
+ status: "idle",
51
+ attempts: 0,
52
+ rearmCount: 0,
53
+ pendingGaps: [],
54
+ lastError: null,
55
+ updatedAt: Temporal.Now.instant(),
56
+ ...overrides,
57
+ };
58
+ }
59
+
60
+ describe("deliverEvents — batchHandler fast-path", () => {
61
+ test("batch success calls batchHandler once, never handler, and matches the per-event outcome", async () => {
62
+ const rows = [1n, 2n, 3n].map(stubEvent);
63
+ const batchCalls: string[][] = [];
64
+ let handlerCalls = 0;
65
+
66
+ const batchConsumer: EventConsumer = {
67
+ name: "search",
68
+ batchHandler: async (events) => {
69
+ batchCalls.push(events.map((e) => e.id));
70
+ },
71
+ handler: async () => {
72
+ handlerCalls += 1;
73
+ },
74
+ };
75
+ const perEventConsumer: EventConsumer = {
76
+ name: "search",
77
+ handler: async () => {
78
+ // no-op — succeeds for every event, same as the batch above
79
+ },
80
+ };
81
+
82
+ const batchOutcome = await deliverEvents(batchConsumer, rows, stubContext(), 10, stubState());
83
+ const perEventOutcome = await deliverEvents(
84
+ perEventConsumer,
85
+ rows,
86
+ stubContext(),
87
+ 10,
88
+ stubState(),
89
+ );
90
+
91
+ expect(batchCalls).toEqual([["1", "2", "3"]]);
92
+ expect(handlerCalls).toBe(0);
93
+ expect(batchOutcome).toEqual(perEventOutcome);
94
+ expect(batchOutcome.cursor).toBe(3n);
95
+ expect(batchOutcome.attempts).toBe(0);
96
+ expect(batchOutcome.lastError).toBeNull();
97
+ expect(batchOutcome.processed).toBe(3);
98
+ expect(batchOutcome.failed).toBe(0);
99
+ });
100
+
101
+ test("pending-gap ids resolve the same way as per-event delivery", async () => {
102
+ const rows = [4n, 7n, 11n, 12n].map(stubEvent);
103
+ const consumer: EventConsumer = {
104
+ name: "search",
105
+ batchHandler: async () => {
106
+ // succeeds
107
+ },
108
+ handler: async () => {
109
+ throw new Error("handler must not run on batch success");
110
+ },
111
+ };
112
+
113
+ const outcome = await deliverEvents(
114
+ consumer,
115
+ rows,
116
+ stubContext(),
117
+ 10,
118
+ stubState({ lastProcessedEventId: 10n }),
119
+ );
120
+
121
+ expect(outcome.resolvedPendingIds).toEqual([4n, 7n]);
122
+ expect(outcome.cursor).toBe(12n);
123
+ });
124
+
125
+ test("batch throws, one poison row: falls back to per-event delivery without bumping attempts for the batch failure itself", async () => {
126
+ const rows = [1n, 2n, 3n, 4n, 5n].map(stubEvent);
127
+ const handlerCalls: string[] = [];
128
+ const consumer: EventConsumer = {
129
+ name: "search",
130
+ errorPolicy: { maxAttempts: 2 },
131
+ batchHandler: async () => {
132
+ throw new Error("batch failed");
133
+ },
134
+ handler: async (event) => {
135
+ handlerCalls.push(event.id);
136
+ if (event.id === "3") throw new Error("poison event");
137
+ },
138
+ };
139
+
140
+ const first = await deliverEvents(consumer, rows, stubContext(), 10, stubState());
141
+ expect(first.cursor).toBe(2n);
142
+ expect(first.attempts).toBe(1);
143
+ expect(first.deadLettered).toBe(false);
144
+ expect(handlerCalls).toEqual(["1", "2", "3"]);
145
+
146
+ const remaining = [3n, 4n, 5n].map(stubEvent);
147
+ const second = await deliverEvents(
148
+ consumer,
149
+ remaining,
150
+ stubContext(),
151
+ 10,
152
+ stubState({ lastProcessedEventId: first.cursor, attempts: first.attempts }),
153
+ );
154
+ expect(second.attempts).toBe(2);
155
+ expect(second.deadLettered).toBe(true);
156
+ });
157
+
158
+ test("batch throws with skipApplyErrors: true skips the poison row and delivers the rest via handler", async () => {
159
+ const rows = [1n, 2n, 3n, 4n, 5n].map(stubEvent);
160
+ const handlerCalls: string[] = [];
161
+ const consumer: EventConsumer = {
162
+ name: "search",
163
+ errorPolicy: { skipApplyErrors: true },
164
+ batchHandler: async () => {
165
+ throw new Error("batch failed");
166
+ },
167
+ handler: async (event) => {
168
+ handlerCalls.push(event.id);
169
+ if (event.id === "3") throw new Error("poison event");
170
+ },
171
+ };
172
+
173
+ const outcome = await deliverEvents(consumer, rows, stubContext(), 10, stubState());
174
+
175
+ expect(outcome.cursor).toBe(5n);
176
+ expect(handlerCalls).toEqual(["1", "2", "3", "4", "5"]);
177
+ });
178
+ });
@@ -0,0 +1,143 @@
1
+ // #3227 — batchHandler wired into a real dispatcher pass: a successful
2
+ // batch is delivered in one call (handler never runs, cursor lands at the
3
+ // last event) and a batch+handler failure still dead-letters through the
4
+ // normal errorPolicy machinery, delivering the poisoned event via handler
5
+ // exactly once before it does.
6
+
7
+ import { afterEach, beforeAll, describe, expect, test } from "bun:test";
8
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
9
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
10
+ import { defineFeature } from "../../engine";
11
+ import type { StoredEvent } from "../../event-store";
12
+ import {
13
+ createEventDispatcher,
14
+ type EventConsumer,
15
+ type EventDispatcher,
16
+ getConsumerState,
17
+ } from "../../pipeline";
18
+ import {
19
+ resetEventStore,
20
+ setupTestStack,
21
+ type TestStack,
22
+ TestUsers,
23
+ unsafeCreateEntityTable,
24
+ } from "../../stack";
25
+ import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
26
+
27
+ const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
28
+ entityName: "widget",
29
+ });
30
+
31
+ const feature = defineFeature("batchhandlerintegration", (r) => {
32
+ r.entity("widget", sharedWidgetEntity);
33
+ });
34
+
35
+ const admin = TestUsers.admin;
36
+ let stack: TestStack;
37
+ let tdb: TenantDb;
38
+
39
+ beforeAll(async () => {
40
+ stack = await setupTestStack({ features: [feature], systemHooks: [] });
41
+ await unsafeCreateEntityTable(stack.db, sharedWidgetEntity, "widget");
42
+ tdb = createTenantDb(stack.db, admin.tenantId);
43
+ });
44
+
45
+ afterEach(async () => {
46
+ await resetEventStore(stack, ["read_widgets"]);
47
+ });
48
+
49
+ async function appendWidget(name: string): Promise<string> {
50
+ const created = await executor.create({ name }, admin, tdb);
51
+ if (!created.isSuccess) throw new Error("create failed");
52
+ return String(created.data.id);
53
+ }
54
+
55
+ function buildDispatcher(consumers: readonly EventConsumer[]): EventDispatcher {
56
+ return createEventDispatcher({
57
+ db: stack.db,
58
+ consumers,
59
+ context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
60
+ batchSize: 200,
61
+ // No timer/LISTEN involved — runOnce() below drives every pass
62
+ // explicitly, so pollIntervalMs never fires a competing pass.
63
+ pollIntervalMs: 60_000,
64
+ });
65
+ }
66
+
67
+ describe("event-dispatcher — batchHandler", () => {
68
+ test("a successful batch delivers all events in one call, handler stays unused, cursor lands on the last event", async () => {
69
+ const consumerName = "batchhandlerintegration:success";
70
+ const batchSizes: number[] = [];
71
+ const batchIds: bigint[] = [];
72
+ const handlerIds: string[] = [];
73
+
74
+ const consumer: EventConsumer = {
75
+ name: consumerName,
76
+ batchHandler: async (events) => {
77
+ batchSizes.push(events.length);
78
+ for (const event of events) batchIds.push(BigInt(event.id));
79
+ },
80
+ handler: async (event) => {
81
+ handlerIds.push(event.aggregateId);
82
+ },
83
+ };
84
+
85
+ const dispatcher = buildDispatcher([consumer]);
86
+ await dispatcher.ensureRegistered();
87
+
88
+ await appendWidget("one");
89
+ await appendWidget("two");
90
+ await appendWidget("three");
91
+
92
+ const result = await dispatcher.runOnce();
93
+
94
+ expect(batchSizes).toEqual([3]);
95
+ expect(handlerIds).toEqual([]);
96
+ expect(result.byConsumer[consumerName]).toEqual({ processed: 3, failed: 0 });
97
+
98
+ const state = await getConsumerState(stack.db, consumerName);
99
+ expect(state?.status).toBe("idle");
100
+ expect(state?.lastProcessedEventId).toBe(batchIds.at(-1));
101
+ });
102
+
103
+ test("batch throws, handler poisons the 2nd event: dead-letters at maxAttempts, first event delivered exactly once via handler", async () => {
104
+ const consumerName = "batchhandlerintegration:poison";
105
+ const handlerCalls: StoredEvent[] = [];
106
+ let poisonAggregateId: string | undefined;
107
+
108
+ const consumer: EventConsumer = {
109
+ name: consumerName,
110
+ errorPolicy: { maxAttempts: 2 },
111
+ batchHandler: async () => {
112
+ throw new Error("batch always fails in this test");
113
+ },
114
+ handler: async (event) => {
115
+ handlerCalls.push(event);
116
+ if (event.aggregateId === poisonAggregateId) {
117
+ throw new Error("poisoned event");
118
+ }
119
+ },
120
+ };
121
+
122
+ const dispatcher = buildDispatcher([consumer]);
123
+ await dispatcher.ensureRegistered();
124
+
125
+ await appendWidget("first");
126
+ poisonAggregateId = await appendWidget("second");
127
+
128
+ const firstPass = await dispatcher.runOnce();
129
+ expect(firstPass.byConsumer[consumerName]?.failed).toBeGreaterThan(0);
130
+
131
+ const secondPass = await dispatcher.runOnce();
132
+ expect(secondPass.byConsumer[consumerName]).toBeDefined();
133
+
134
+ const state = await getConsumerState(stack.db, consumerName);
135
+ expect(state?.status).toBe("dead");
136
+
137
+ const firstEventDeliveries = handlerCalls.filter(
138
+ (event) => event.aggregateId === handlerCalls[0]?.aggregateId,
139
+ );
140
+ expect(firstEventDeliveries).toHaveLength(1);
141
+ expect(state?.lastProcessedEventId).toBe(BigInt(handlerCalls[0]?.id ?? "0"));
142
+ });
143
+ });
@@ -0,0 +1,94 @@
1
+ // A provably-idle consumer's doPass turn must not take the state row's
2
+ // FOR UPDATE SKIP LOCKED lock: that lock sets xmax on the row, requires an
3
+ // xid, writes WAL, and forces a commit fsync — on every poll tick, forever,
4
+ // even with zero events in the system. The pre-check in
5
+ // selectIdleConsumerKeys (event-dispatcher-delivery.ts) must keep an idle
6
+ // consumer's row untouched entirely, verified here via the row's own
7
+ // system column `xmax`: unchanged across an idle runOnce() means no lock
8
+ // was ever taken on it.
9
+
10
+ import { afterEach, beforeAll, describe, expect, test } from "bun:test";
11
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
12
+ import { asRawClient } from "../../db/query";
13
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
14
+ import { defineFeature } from "../../engine";
15
+ import {
16
+ resetEventStore,
17
+ setupTestStack,
18
+ type TestStack,
19
+ TestUsers,
20
+ unsafeCreateEntityTable,
21
+ } from "../../stack";
22
+ import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
23
+ import { SHARED_INSTANCE_SENTINEL } from "../event-consumer-state";
24
+
25
+ const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
26
+ entityName: "widget",
27
+ });
28
+
29
+ let observed: Array<{ name: string }> = [];
30
+
31
+ const idleFeature = defineFeature("idletest", (r) => {
32
+ r.entity("widget", sharedWidgetEntity);
33
+
34
+ r.multiStreamProjection({
35
+ name: "observer",
36
+ apply: {
37
+ "widget.created": async (event) => {
38
+ observed.push({ name: event.payload["name"] as string });
39
+ },
40
+ },
41
+ });
42
+ });
43
+
44
+ const admin = TestUsers.admin;
45
+ const qn = "idletest:projection:observer";
46
+ let stack: TestStack;
47
+ let tdb: TenantDb;
48
+
49
+ beforeAll(async () => {
50
+ stack = await setupTestStack({
51
+ features: [idleFeature],
52
+ systemHooks: [],
53
+ });
54
+ await unsafeCreateEntityTable(stack.db, sharedWidgetEntity, "widget");
55
+ tdb = createTenantDb(stack.db, admin.tenantId);
56
+ });
57
+
58
+ afterEach(async () => {
59
+ observed = [];
60
+ await resetEventStore(stack, ["read_widgets"]);
61
+ });
62
+
63
+ async function appendWidget(name: string): Promise<void> {
64
+ await executor.create({ name }, admin, tdb);
65
+ }
66
+
67
+ async function readConsumerRowXmax(): Promise<string> {
68
+ const rows = (await asRawClient(stack.db).unsafe(
69
+ `SELECT xmax::text FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2`,
70
+ [qn, SHARED_INSTANCE_SENTINEL],
71
+ )) as ReadonlyArray<{ xmax: string }>;
72
+ const xmax = rows[0]?.xmax;
73
+ if (xmax === undefined) throw new Error("consumer state row missing");
74
+ return xmax;
75
+ }
76
+
77
+ describe("idle event-dispatcher passes take no row lock", () => {
78
+ test("xmax is unchanged across an idle runOnce() after draining, then a new event is still delivered", async () => {
79
+ await appendWidget("drain-me");
80
+ await stack.eventDispatcher?.runOnce();
81
+ expect(observed).toEqual([{ name: "drain-me" }]);
82
+
83
+ const xmaxBeforeIdlePass = await readConsumerRowXmax();
84
+
85
+ await stack.eventDispatcher?.runOnce();
86
+ const xmaxAfterIdlePass = await readConsumerRowXmax();
87
+ expect(xmaxAfterIdlePass).toBe(xmaxBeforeIdlePass);
88
+
89
+ await appendWidget("wake-me");
90
+ const result = await stack.eventDispatcher?.runOnce();
91
+ expect(result?.processed).toBeGreaterThan(0);
92
+ expect(observed).toEqual([{ name: "drain-me" }, { name: "wake-me" }]);
93
+ });
94
+ });
@@ -13,12 +13,12 @@
13
13
  // event-triggered workflow hits in production.
14
14
 
15
15
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
16
- import type { DbTx } from "../../db/connection";
17
- import { insertOne } from "../../db/query";
16
+ import type { DbConnection, DbTx } from "../../db/connection";
17
+ import { asRawClient, insertOne } from "../../db/query";
18
18
  import type { AppContext } from "../../engine/types";
19
19
  import { eventsTable } from "../../event-store";
20
20
  import { createTestDb, type TestDb, TestUsers } from "../../stack";
21
- import { createEventConsumerStateTable } from "../event-consumer-state";
21
+ import { createEventConsumerStateTable, SHARED_INSTANCE_SENTINEL } from "../event-consumer-state";
22
22
  import { createEventDispatcher, type EventConsumer, getConsumerState } from "../event-dispatcher";
23
23
  import { fetchPendingEvents } from "../event-dispatcher-delivery";
24
24
 
@@ -49,6 +49,40 @@ async function appendHistoricalEvent(type: string): Promise<void> {
49
49
  historicalEventCount += 1;
50
50
  }
51
51
 
52
+ // Grabs an id (like appendHistoricalEvent) but never commits — the row
53
+ // stays invisible until the caller releases and awaits the enclosing tx.
54
+ async function appendEventRawHoldingTx(tx: DbTx, type: string): Promise<void> {
55
+ await asRawClient(tx).unsafe(
56
+ `INSERT INTO "kumiko_events"
57
+ (aggregate_id, aggregate_type, tenant_id, version, type, payload, metadata, created_by)
58
+ VALUES ($1::uuid, 'start-from-test-source', $2::uuid, 1, $3, '{}'::jsonb, '{}'::jsonb, 'test')`,
59
+ [crypto.randomUUID(), admin.tenantId, type],
60
+ );
61
+ }
62
+
63
+ async function selectVisibleMaxEventId(): Promise<bigint> {
64
+ const rows = (await asRawClient(testDb.db).unsafe(
65
+ `SELECT COALESCE(MAX("id"), 0)::text AS max FROM "kumiko_events"`,
66
+ )) as ReadonlyArray<{ max: string }>;
67
+ return BigInt(rows[0]?.max ?? "0");
68
+ }
69
+
70
+ async function readPendingGaps(
71
+ db: DbConnection,
72
+ consumerName: string,
73
+ ): Promise<ReadonlyArray<{ from: string; to: string; xmax: string }>> {
74
+ const rows = (await asRawClient(db).unsafe(
75
+ `SELECT "pending_gaps", jsonb_typeof("pending_gaps") AS kind FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2`,
76
+ [consumerName, SHARED_INSTANCE_SENTINEL],
77
+ )) as ReadonlyArray<{
78
+ pending_gaps: ReadonlyArray<{ from: string; to: string; xmax: string }>;
79
+ kind: string;
80
+ }>;
81
+ // A double-encoded write lands as a jsonb string scalar, not an array.
82
+ if (rows[0] && rows[0].kind !== "array") throw new Error(`pending_gaps is jsonb ${rows[0].kind}`);
83
+ return rows[0]?.pending_gaps ?? [];
84
+ }
85
+
52
86
  beforeAll(async () => {
53
87
  // createTestDb() already materializes kumiko_events (see stack/db.ts).
54
88
  testDb = await createTestDb();
@@ -173,4 +207,70 @@ describe("EventConsumer.startFrom", () => {
173
207
  const afterRedeploy = await getConsumerState(testDb.db, consumer.name);
174
208
  expect(afterRedeploy?.lastProcessedEventId).toBe(advanced?.lastProcessedEventId);
175
209
  });
210
+
211
+ test("'now' registered while a lower-id write is still uncommitted seeds a pending gap and later delivers it", async () => {
212
+ const captured: string[] = [];
213
+ const consumer: EventConsumer = {
214
+ name: "test:consumer:now-out-of-order",
215
+ startFrom: "now",
216
+ handler: async (event) => {
217
+ captured.push(event.type);
218
+ },
219
+ };
220
+
221
+ // Tx A grabs the LOW id first but holds its transaction open — invisible
222
+ // to insertConsumerIfAbsent's horizon read below, same mechanic as the
223
+ // live-dispatcher out-of-order case in event-dispatcher-commit-order.
224
+ let releaseA!: () => void;
225
+ const aGate = new Promise<void>((resolve) => {
226
+ releaseA = resolve;
227
+ });
228
+ let markAInserted!: () => void;
229
+ const aInserted = new Promise<void>((resolve) => {
230
+ markAInserted = resolve;
231
+ });
232
+ const aDone = testDb.db
233
+ .begin(async (tx: DbTx) => {
234
+ await appendEventRawHoldingTx(tx, "start-from-test.low-id");
235
+ markAInserted();
236
+ await aGate;
237
+ })
238
+ .catch(() => {});
239
+ await aInserted;
240
+
241
+ // Tx B commits AFTER A grabbed its id, so B's id is the higher, visible
242
+ // MAX(id) when the consumer is registered below. A's still-open tx
243
+ // already consumed a sequence value, so B's actual id is one past
244
+ // historicalEventCount, not equal to it — read the real MAX(id).
245
+ await appendHistoricalEvent("start-from-test.high-id");
246
+ const headAtMount = await selectVisibleMaxEventId();
247
+
248
+ const dispatcher = createEventDispatcher({
249
+ db: testDb.db,
250
+ consumers: [consumer],
251
+ context: stubContext(),
252
+ });
253
+
254
+ try {
255
+ await dispatcher.ensureRegistered();
256
+
257
+ const seeded = await getConsumerState(testDb.db, consumer.name);
258
+ expect(seeded?.lastProcessedEventId).toBe(headAtMount);
259
+ expect(await readPendingGaps(testDb.db, consumer.name)).not.toEqual([]);
260
+
261
+ // Nothing to deliver yet — A's transaction hasn't committed.
262
+ const passWhileOpen = await dispatcher.runOnce();
263
+ expect(passWhileOpen.byConsumer[consumer.name]).toEqual({ processed: 0, failed: 0 });
264
+ } finally {
265
+ releaseA();
266
+ await aDone;
267
+ }
268
+
269
+ // A commits now — its lower id is delivered as a resolved pending gap,
270
+ // even though the cursor already sits above it from registration.
271
+ const pass = await dispatcher.runOnce();
272
+ expect(pass.byConsumer[consumer.name]).toEqual({ processed: 1, failed: 0 });
273
+ expect(captured).toEqual(["start-from-test.low-id"]);
274
+ expect(await readPendingGaps(testDb.db, consumer.name)).toEqual([]);
275
+ });
176
276
  });
@@ -8,7 +8,7 @@
8
8
  // consecutive versions without re-reading the DB.
9
9
 
10
10
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
11
- import { z } from "zod";
11
+ import * as z from "zod";
12
12
  import { createEventStoreExecutor } from "../../db/event-store-executor";
13
13
  import { asRawClient } from "../../db/query-api";
14
14
  import { buildEntityTable } from "../../db/table-builder";
@@ -1,5 +1,5 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { createEventStoreExecutor } from "../../db/event-store-executor";
4
4
  import { asRawClient, selectMany } from "../../db/query";
5
5
  import { buildEntityTable } from "../../db/table-builder";
@@ -17,7 +17,7 @@
17
17
  // produce, not something exercised end-to-end by any test today.
18
18
 
19
19
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
20
- import { z } from "zod";
20
+ import * as z from "zod";
21
21
  import { entityEventName } from "../../db";
22
22
  import { defineFeature } from "../../engine";
23
23
  import { createInMemoryFileProvider, type InMemoryFileProvider } from "../../files";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, spyOn, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import {
4
4
  createEntity,
5
5
  createRegistry,