@cosmicdrift/kumiko-framework 0.306.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 (178) 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 +133 -0
  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 +144 -1
  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 +57 -3
  28. package/src/api/request-context.ts +24 -0
  29. package/src/api/server.ts +11 -0
  30. package/src/api/sse-broker.ts +69 -12
  31. package/src/changes.json +63 -0
  32. package/src/compliance/override-schema.ts +1 -1
  33. package/src/crypto/__tests__/event-pii.test.ts +1 -1
  34. package/src/crypto/kms-adapter.ts +1 -1
  35. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +1 -1
  36. package/src/db/queries/event-consumer.ts +155 -9
  37. package/src/db/queries/event-store.ts +69 -0
  38. package/src/db/tenant-db.ts +43 -5
  39. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +1 -1
  40. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +1 -1
  41. package/src/engine/__tests__/boot-validator-dashboard.test.ts +1 -1
  42. package/src/engine/__tests__/boot-validator-nav-area.test.ts +1 -1
  43. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
  44. package/src/engine/__tests__/boot-validator-projection-list.test.ts +1 -1
  45. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +1 -1
  46. package/src/engine/__tests__/boot-validator-query-refs.test.ts +1 -1
  47. package/src/engine/__tests__/boot-validator-ref-entity.test.ts +1 -1
  48. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +1 -1
  49. package/src/engine/__tests__/boot-validator.test.ts +35 -1
  50. package/src/engine/__tests__/build-app-schema.test.ts +1 -1
  51. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +1 -1
  52. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +1 -1
  53. package/src/engine/__tests__/engine.test.ts +1 -1
  54. package/src/engine/__tests__/event-helpers.test.ts +1 -1
  55. package/src/engine/__tests__/event-migration-declarative.test.ts +1 -1
  56. package/src/engine/__tests__/extend-entity-projection.test.ts +1 -1
  57. package/src/engine/__tests__/extends-registrar.test.ts +1 -1
  58. package/src/engine/__tests__/hook-phases.test.ts +1 -1
  59. package/src/engine/__tests__/lifecycle-hooks.test.ts +1 -1
  60. package/src/engine/__tests__/pipeline-engine.test.ts +1 -1
  61. package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
  62. package/src/engine/__tests__/pipeline-observability.integration.test.ts +1 -1
  63. package/src/engine/__tests__/pipeline-performance.integration.test.ts +1 -1
  64. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +1 -1
  65. package/src/engine/__tests__/post-query-hook.test.ts +1 -1
  66. package/src/engine/__tests__/projection-detail-actions.test.ts +1 -1
  67. package/src/engine/__tests__/projection-detail-singleton.test.ts +1 -1
  68. package/src/engine/__tests__/projection-detail-tabs.test.ts +1 -1
  69. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +1 -1
  70. package/src/engine/__tests__/registry.test.ts +1 -1
  71. package/src/engine/__tests__/screen.test.ts +1 -1
  72. package/src/engine/__tests__/validate-projection-allowlist.test.ts +1 -1
  73. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +1 -1
  74. package/src/engine/boot-validator/__tests__/action-icon.test.ts +81 -0
  75. package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +1 -1
  76. package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +1 -1
  77. package/src/engine/boot-validator/__tests__/parent-ref.test.ts +1 -1
  78. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +1 -1
  79. package/src/engine/boot-validator/screens.ts +83 -0
  80. package/src/engine/define-handler.ts +2 -1
  81. package/src/engine/entity-handlers.ts +2 -1
  82. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +1 -1
  83. package/src/engine/feature-ast/pattern-change-schema.ts +1 -1
  84. package/src/engine/feature-builder-state.ts +2 -1
  85. package/src/engine/feature-config-events-jobs.ts +2 -1
  86. package/src/engine/feature-entity-handlers.ts +2 -1
  87. package/src/engine/schema-builder.ts +1 -1
  88. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
  89. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +1 -1
  90. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
  91. package/src/env/__tests__/compose-env-schema.test.ts +1 -1
  92. package/src/env/__tests__/dry-run.test.ts +1 -1
  93. package/src/env/__tests__/parse-env-dry-run.test.ts +1 -1
  94. package/src/env/_zod-introspect.ts +1 -1
  95. package/src/env/dry-run.ts +1 -1
  96. package/src/env/index.ts +1 -1
  97. package/src/errors/__tests__/classes.test.ts +1 -1
  98. package/src/es-ops/__tests__/runner.integration.test.ts +1 -1
  99. package/src/event-store/__tests__/backfill-pii.integration.test.ts +1 -1
  100. package/src/event-store/__tests__/event-attribution.integration.test.ts +54 -4
  101. package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
  102. package/src/event-store/admin-api.ts +5 -0
  103. package/src/event-store/event-store.ts +16 -7
  104. package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -1
  105. package/src/jobs/__tests__/job-event-trigger.integration.test.ts +1 -1
  106. package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +1 -1
  107. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
  108. package/src/jobs/__tests__/job-retention.integration.test.ts +1 -1
  109. package/src/jobs/__tests__/job-write-as.integration.test.ts +1 -1
  110. package/src/jobs/__tests__/jobs.integration.test.ts +1 -1
  111. package/src/jobs/job-runner.ts +61 -6
  112. package/src/observability/__tests__/metrics-wiring.test.ts +1 -1
  113. package/src/observability/__tests__/observability.integration.test.ts +1 -1
  114. package/src/observability/metrics-wiring.ts +1 -1
  115. package/src/pipeline/__tests__/archive-stream.integration.test.ts +1 -1
  116. package/src/pipeline/__tests__/causation-chain.integration.test.ts +1 -1
  117. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +1 -1
  118. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +1 -1
  119. package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
  120. package/src/pipeline/__tests__/dispatcher.test.ts +1 -1
  121. package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +1 -1
  122. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +1 -1
  123. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +1 -1
  124. package/src/pipeline/__tests__/event-dedup.integration.test.ts +1 -1
  125. package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +1 -1
  126. package/src/pipeline/__tests__/event-dispatcher-batch-delivery.test.ts +178 -0
  127. package/src/pipeline/__tests__/event-dispatcher-batch-handler.integration.test.ts +143 -0
  128. package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
  129. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
  130. package/src/pipeline/__tests__/event-dispatcher-idle.integration.test.ts +94 -0
  131. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
  132. package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
  133. package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +103 -3
  134. package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +1 -1
  135. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +1 -1
  136. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +1 -1
  137. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  138. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
  139. package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +1 -1
  140. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +116 -1
  141. package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +1 -1
  142. package/src/pipeline/__tests__/pending-gap-ranges.test.ts +94 -0
  143. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +1 -1
  144. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +262 -3
  145. package/src/pipeline/__tests__/query-projection.integration.test.ts +1 -1
  146. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +1 -1
  147. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +1 -1
  148. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +1 -1
  149. package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +1 -1
  150. package/src/pipeline/__tests__/try-append-event.integration.test.ts +1 -1
  151. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +1 -1
  152. package/src/pipeline/dispatch-batch.ts +49 -19
  153. package/src/pipeline/dispatch-stream.ts +12 -3
  154. package/src/pipeline/dispatcher-utils.ts +21 -2
  155. package/src/pipeline/dispatcher.ts +71 -6
  156. package/src/pipeline/event-consumer-state.ts +26 -0
  157. package/src/pipeline/event-dispatcher-admin.ts +32 -5
  158. package/src/pipeline/event-dispatcher-delivery.ts +212 -65
  159. package/src/pipeline/event-dispatcher.ts +253 -49
  160. package/src/pipeline/index.ts +1 -0
  161. package/src/pipeline/msp-rebuild.ts +39 -1
  162. package/src/pipeline/pending-gap-ranges.ts +120 -0
  163. package/src/pipeline/system-hooks.ts +226 -64
  164. package/src/pipeline/write-origin.ts +31 -10
  165. package/src/rate-limit/__tests__/dispatcher-l3.integration.test.ts +1 -1
  166. package/src/search/__tests__/derive-search-adapter-config.test.ts +65 -0
  167. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +91 -0
  168. package/src/search/__tests__/search-consumer-batch.integration.test.ts +306 -0
  169. package/src/search/derive-search-adapter-config.ts +27 -0
  170. package/src/search/index.ts +1 -0
  171. package/src/search/meilisearch-adapter.ts +96 -14
  172. package/src/secrets/__tests__/contains-secret.test.ts +1 -1
  173. package/src/stack/__tests__/request-helper.integration.test.ts +1 -1
  174. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
  175. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +1 -1
  176. package/src/stack/test-stack.ts +1 -1
  177. package/src/testing/__tests__/e2e-generator.test.ts +1 -1
  178. package/src/testing/e2e-generator.ts +1 -1
@@ -2,7 +2,7 @@
2
2
  // `Temporal` TYPE that ConsumerStateRow.updatedAt/StoredEventRow.createdAt
3
3
  // resolve against (same #1438 dual-package-hazard pattern as event-store.ts).
4
4
  import { Temporal as TemporalPolyfill } from "temporal-polyfill";
5
- import { requestContext } from "../api/request-context";
5
+ import { type RequestContextData, requestContext } from "../api/request-context";
6
6
  import type { DbConnection, DbTx } from "../db/connection";
7
7
  import {
8
8
  insertConsumerIfAbsent,
@@ -10,13 +10,18 @@ import {
10
10
  rearmDeadConsumer,
11
11
  recordConsumerPassFailure,
12
12
  selectConsumerForUpdateSkipLocked,
13
+ selectProvablyIdleConsumerPairs,
13
14
  updateConsumerDeliveryOutcome,
14
15
  } from "../db/queries/event-consumer";
15
- import { selectEventsHeadId } from "../db/queries/event-store";
16
- import { coerceRow, extractTableInfo, selectMany } from "../db/query";
16
+ import {
17
+ type PendingIdRange,
18
+ selectEventsHeadId,
19
+ selectPendingAndNewEventRows,
20
+ } from "../db/queries/event-store";
21
+ import { coerceRow, extractTableInfo } from "../db/query";
17
22
  import { qnScope } from "../engine/qualified-name";
18
23
  import type { AppContext } from "../engine/types";
19
- import { eventsTable, toStoredEvent as rowToStoredEvent } from "../event-store";
24
+ import { eventsTable, toStoredEvent as rowToStoredEvent, type StoredEvent } from "../event-store";
20
25
  import {
21
26
  emitDispatcherError,
22
27
  emitEventConsumerLag,
@@ -26,9 +31,18 @@ import {
26
31
  import {
27
32
  ConsumerStatuses,
28
33
  eventConsumerStateTable,
34
+ type PendingGapEntry,
29
35
  SHARED_INSTANCE_SENTINEL,
30
36
  } from "./event-consumer-state";
31
37
  import type { EventConsumer } from "./event-dispatcher";
38
+ import { parseWriteOrigin } from "./write-origin";
39
+
40
+ // Fails closed without throwing: a throw would poison the event for every consumer.
41
+ const UNPARSEABLE_STORED_WRITE_ORIGIN = {
42
+ rootHandler: "<unknown>",
43
+ anonymousRoot: true,
44
+ publicIntake: false,
45
+ } as const;
32
46
 
33
47
  // Per-consumer pass mechanics: acquire the state row, fetch pending events,
34
48
  // hand them to the consumer's handler in order, persist the outcome. Split
@@ -46,6 +60,7 @@ export type ConsumerStateRowShape = {
46
60
  readonly status: string;
47
61
  readonly attempts: number;
48
62
  readonly rearmCount: number;
63
+ readonly pendingGaps: readonly PendingGapEntry[];
49
64
  readonly lastError: string | null;
50
65
  readonly updatedAt: Temporal.Instant;
51
66
  };
@@ -145,6 +160,39 @@ export async function acquireConsumerState(
145
160
  return { state, skip: null };
146
161
  }
147
162
 
163
+ // Read-only pre-check run once per doPass, before any consumer's turn opens
164
+ // a transaction (event-dispatcher.ts's doPass/runConsumerTurn). Keyed like
165
+ // consumerBackoff/inFlightTurns: `${name}:${instanceId}`. Consumers not
166
+ // provably idle (missing row, dead, pending gaps, or an event past cursor)
167
+ // simply don't appear in the result — the caller falls through to the
168
+ // existing acquireConsumerState/FOR UPDATE SKIP LOCKED path unchanged.
169
+ //
170
+ // Race: an event that commits between this query and the next tick CAN
171
+ // make this snapshot stale — that's expected, not a bug. What it can't do
172
+ // is get silently lost: the append fires NOTIFY (or the next poll tick
173
+ // fires regardless), triggering a fresh doPass. That pass's own pre-check
174
+ // sees the new head id and excludes the consumer from "idle"; a new
175
+ // pending_gap only ever gets created when a later turn's fetch (id > the
176
+ // old cursor) walks past the row that committed late, and this same
177
+ // pre-check reads pending_gaps live on every pass, so it observes that gap
178
+ // too. In short: this can under-report idleness for one tick, never
179
+ // over-report it.
180
+ export async function selectIdleConsumerKeys(
181
+ db: DbConnection,
182
+ pairs: ReadonlyArray<{ readonly name: string; readonly instanceId: string }>,
183
+ ): Promise<ReadonlySet<string>> {
184
+ if (pairs.length === 0) return new Set();
185
+ const names = pairs.map((p) => p.name);
186
+ const instanceIds = pairs.map((p) => p.instanceId);
187
+ const idlePairs = await selectProvablyIdleConsumerPairs(
188
+ db,
189
+ names,
190
+ instanceIds,
191
+ ConsumerStatuses.dead,
192
+ );
193
+ return new Set(idlePairs.map((p) => `${p.name}:${p.instanceId}`));
194
+ }
195
+
148
196
  // Shared pre-registration: one row per (consumer, shard), cursor = 0,
149
197
  // status = idle. Shared-delivery consumers use SHARED_INSTANCE_SENTINEL;
150
198
  // per-instance consumers use the dispatcher's instanceId. Idempotent
@@ -186,17 +234,20 @@ export async function markProcessing(tx: DbTx, name: string, instanceId: string)
186
234
  await markConsumerProcessing(tx, name, instanceId);
187
235
  }
188
236
 
237
+ // `pendingRanges` are id ranges below `cursor` the consumer is still
238
+ // watching as gaps (invisible on an earlier turn — see event-dispatcher.ts's
239
+ // processConsumer). Fetching them alongside the plain `id > cursor` window
240
+ // means a row that committed late becomes visible and deliverable the next
241
+ // time this consumer's turn runs, instead of being permanently skipped.
189
242
  export async function fetchPendingEvents(
190
243
  tx: DbTx,
191
244
  cursor: bigint,
192
245
  batchSize: number,
246
+ pendingRanges: readonly PendingIdRange[] = [],
193
247
  ): Promise<ReadonlyArray<StoredEventRow>> {
194
- return (await selectMany(
195
- tx,
196
- eventsTable,
197
- { id: { gt: cursor } },
198
- { orderBy: { col: "id", direction: "asc" }, limit: batchSize },
199
- )) as ReadonlyArray<StoredEventRow>; // @cast-boundary db-row
248
+ const rawRows = await selectPendingAndNewEventRows(tx, cursor, pendingRanges, batchSize);
249
+ const info = extractTableInfo(eventsTable);
250
+ return rawRows.map((row) => coerceRow(row, info) as StoredEventRow); // @cast-boundary db-row
200
251
  }
201
252
 
202
253
  export type DeliveryOutcome = {
@@ -206,14 +257,22 @@ export type DeliveryOutcome = {
206
257
  readonly deadLettered: boolean;
207
258
  readonly processed: number;
208
259
  readonly failed: number;
260
+ // Which of the *pending* ids in `events` (id <= the cursor this delivery
261
+ // started from) got resolved this pass — delivered or skip-applied. The
262
+ // caller (event-dispatcher.ts) splits exactly these out of pending_gaps;
263
+ // everything else in the input batch was a "new" row past the old cursor.
264
+ readonly resolvedPendingIds: readonly bigint[];
209
265
  };
210
266
 
211
- // Deliver events to the consumer's handler in events.id order. Halt-on-
212
- // poison: a throw breaks the loop, the cursor stays at the last successful
213
- // event, and attempts climb. At the consumer's effectiveMaxAttempts
214
- // (errorPolicy.maxAttempts ?? maxAttempts) the caller persists status=
215
- // "dead" and the consumer is parked until ops intervenes (see
216
- // restartConsumer / skipPoisonEvent).
267
+ // Deliver events to the consumer's handler in events.id order, or — when
268
+ // the consumer wires a batchHandler — through it in one call first. Halt-
269
+ // on-poison: a throw breaks the per-event loop, the cursor stays at the
270
+ // last successful event, and attempts climb. At the consumer's
271
+ // effectiveMaxAttempts (errorPolicy.maxAttempts ?? maxAttempts) the caller
272
+ // persists status="dead" and the consumer is parked until ops intervenes
273
+ // (see restartConsumer / skipPoisonEvent). A batch that throws falls back
274
+ // to the same per-event loop for the same events — the batch failure
275
+ // itself does not bump attempts, only a per-event failure does.
217
276
  export async function deliverEvents(
218
277
  consumer: EventConsumer,
219
278
  events: ReadonlyArray<StoredEventRow>,
@@ -221,83 +280,171 @@ export async function deliverEvents(
221
280
  maxAttempts: number,
222
281
  state: ConsumerStateRow,
223
282
  ): Promise<DeliveryOutcome> {
224
- let cursor = state.lastProcessedEventId;
283
+ const startCursor = state.lastProcessedEventId;
284
+ let cursor = startCursor;
225
285
  let attempts = state.attempts;
226
286
  let lastError: string | null = state.lastError ?? null;
227
287
  let deadLettered = false;
228
288
  const effectiveMaxAttempts = consumer.errorPolicy?.maxAttempts ?? maxAttempts;
229
289
  let processed = 0;
230
290
  let failed = 0;
291
+ const resolvedPendingIds: bigint[] = [];
292
+
293
+ // A pending row sits below startCursor: it resolves its gap but never moves
294
+ // the cursor backward. ORDER BY id walks all pending rows first.
295
+ const resolve = (id: bigint): void => {
296
+ if (id > cursor) cursor = id;
297
+ if (id <= startCursor) resolvedPendingIds.push(id);
298
+ attempts = 0;
299
+ lastError = null;
300
+ };
301
+
302
+ if (consumer.batchHandler && events.length > 0) {
303
+ const batchSucceeded = await tryApplyBatch(consumer, events, context);
304
+ if (batchSucceeded) {
305
+ for (const row of events) resolve(row.id);
306
+ processed = events.length;
307
+ return { cursor, attempts, lastError, deadLettered, processed, failed, resolvedPendingIds };
308
+ }
309
+ // fall through to the per-event loop below; the batch failure itself
310
+ // does NOT bump attempts — only a per-event failure does.
311
+ }
231
312
 
232
313
  for (const row of events) {
233
314
  try {
234
- // Propagate causation: if the handler calls ctx.appendEvent, the new
235
- // event should record THIS event as its cause. correlationId is
236
- // inherited unchanged — it survives the hop across streams by design.
237
- // requestId falls back to a fresh id because the dispatcher runs
238
- // outside any HTTP request (background poll), and a stable log-
239
- // correlation handle is still useful for debugging.
240
- const stored = rowToStoredEvent(row);
241
- const correlationId = stored.metadata.correlationId ?? requestContext.generateId();
242
- const causationId = String(stored.id);
243
- const requestId = requestContext.generateId();
244
- // #3043 — an event this apply writes is attributed to the consumer, not
245
- // to whatever wrote the triggering event; causationId already links back.
246
- await requestContext.run(
247
- {
248
- requestId,
249
- correlationId,
250
- causationId,
251
- handler: consumer.name,
252
- feature: consumer.featureName ?? qnScope(consumer.name),
253
- },
254
- async () => {
255
- await consumer.handler(stored, context);
256
- },
257
- );
258
- cursor = row.id;
259
- attempts = 0;
260
- lastError = null;
315
+ await applyEvent(consumer, row, context);
316
+ resolve(row.id);
261
317
  processed += 1;
262
318
  } catch (e) {
263
319
  const errMessage = e instanceof Error ? e.message : String(e);
320
+ failed += 1;
264
321
  if (consumer.errorPolicy?.skipApplyErrors) {
265
- // Best-effort mode: record the error on the skip counter so ops
266
- // can alert on a spike of skipped events, advance the cursor past
267
- // the bad event, keep going. The consumer stays "idle", not "dead".
268
- // Also emit a warn-level log line — the metric tells ops THAT events
269
- // are being dropped, the log tells them WHICH events. Without this
270
- // a poisoned-then-skipped event is invisible to forensic search.
271
- const errorClass = e instanceof Error ? e.constructor.name : "UnknownError";
272
- emitDispatcherError(context.meter ?? getFallbackMeter(), {
273
- handler: consumer.name,
274
- errorClass,
275
- });
276
- context.log?.warn(
277
- `event-dispatcher: ${consumer.name} skipped event ${row.id} (${errorClass}): ${errMessage}`,
278
- );
279
- cursor = row.id;
280
- attempts = 0;
281
- lastError = null;
282
- failed += 1;
322
+ reportSkippedEvent(consumer, row.id, e, errMessage, context);
323
+ resolve(row.id);
283
324
  continue;
284
325
  }
285
326
  attempts += 1;
286
327
  lastError = errMessage;
287
- failed += 1;
288
328
  if (attempts >= effectiveMaxAttempts) deadLettered = true;
289
329
  break;
290
330
  }
291
331
  }
292
332
 
293
- return { cursor, attempts, lastError, deadLettered, processed, failed };
333
+ return { cursor, attempts, lastError, deadLettered, processed, failed, resolvedPendingIds };
334
+ }
335
+
336
+ // Shared requestContext scope for one apply — event-derived fields
337
+ // (correlationId, causationId, writeOrigin) come from `causationSource`;
338
+ // applyEvent uses the event itself, applyBatch uses the LAST event of the
339
+ // turn (see applyBatch for why). requestId falls back to a fresh id
340
+ // because the dispatcher runs outside any HTTP request (background poll),
341
+ // and a stable log-correlation handle is still useful for debugging.
342
+ // #3043 — an event this apply writes is attributed to the consumer, not
343
+ // to whatever wrote the triggering event; causationId already links back.
344
+ function buildConsumerRequestScope(
345
+ consumer: EventConsumer,
346
+ causationSource: StoredEvent,
347
+ ): RequestContextData {
348
+ const correlationId = causationSource.metadata.correlationId ?? requestContext.generateId();
349
+ const causationId = String(causationSource.id);
350
+ const requestId = requestContext.generateId();
351
+ // The job-trigger consumer's handleEvent stamps event-triggered jobs from this.
352
+ const rawStoredWriteOrigin = causationSource.metadata.writeOrigin;
353
+ const writeOrigin =
354
+ rawStoredWriteOrigin === undefined
355
+ ? undefined
356
+ : (parseWriteOrigin(rawStoredWriteOrigin) ?? UNPARSEABLE_STORED_WRITE_ORIGIN);
357
+ return {
358
+ requestId,
359
+ correlationId,
360
+ causationId,
361
+ handler: consumer.name,
362
+ feature: consumer.featureName ?? qnScope(consumer.name),
363
+ writeOrigin,
364
+ };
365
+ }
366
+
367
+ async function applyEvent(
368
+ consumer: EventConsumer,
369
+ row: StoredEventRow,
370
+ context: AppContext,
371
+ ): Promise<void> {
372
+ const stored = rowToStoredEvent(row);
373
+ await requestContext.run(buildConsumerRequestScope(consumer, stored), async () => {
374
+ await consumer.handler(stored, context);
375
+ });
294
376
  }
295
377
 
378
+ // Runs the consumer's batchHandler for the whole turn under one
379
+ // requestContext scope, attempted before the per-event loop. Returns
380
+ // false on failure instead of rethrowing — the caller falls back to
381
+ // per-event delivery for the exact same events, so a batch failure must
382
+ // not itself count as an applyEvent failure or bump attempts.
383
+ async function tryApplyBatch(
384
+ consumer: EventConsumer,
385
+ events: ReadonlyArray<StoredEventRow>,
386
+ context: AppContext,
387
+ ): Promise<boolean> {
388
+ try {
389
+ await applyBatch(consumer, events, context);
390
+ return true;
391
+ } catch (e) {
392
+ const errMessage = e instanceof Error ? e.message : String(e);
393
+ context.log?.warn(
394
+ `event-dispatcher: ${consumer.name} batch of ${events.length} failed, falling back to per-event delivery: ${errMessage}`,
395
+ );
396
+ return false;
397
+ }
398
+ }
399
+
400
+ async function applyBatch(
401
+ consumer: EventConsumer,
402
+ events: ReadonlyArray<StoredEventRow>,
403
+ context: AppContext,
404
+ ): Promise<void> {
405
+ const batchHandler = consumer.batchHandler;
406
+ // skip: deliverEvents only calls this for consumers that wire a batchHandler.
407
+ if (!batchHandler) return;
408
+ const stored = events.map((row) => rowToStoredEvent(row));
409
+ // correlationId/causationId/writeOrigin come from the LAST row — a
410
+ // batch-appended event's causation is attributed to the turn's most
411
+ // recent input, same as chaining N applyEvent calls would leave the
412
+ // requestContext at after the final one.
413
+ const lastStored = stored.at(-1);
414
+ // skip: deliverEvents never calls this with an empty turn.
415
+ if (!lastStored) return;
416
+ await requestContext.run(buildConsumerRequestScope(consumer, lastStored), async () => {
417
+ await batchHandler(stored, context);
418
+ });
419
+ }
420
+
421
+ // Best-effort mode: record the error on the skip counter so ops can alert on
422
+ // a spike of skipped events; the consumer stays "idle", not "dead". The
423
+ // warn-level log line tells them WHICH events — without it a
424
+ // poisoned-then-skipped event is invisible to forensic search.
425
+ function reportSkippedEvent(
426
+ consumer: EventConsumer,
427
+ eventId: bigint,
428
+ e: unknown,
429
+ errMessage: string,
430
+ context: AppContext,
431
+ ): void {
432
+ const errorClass = e instanceof Error ? e.constructor.name : "UnknownError";
433
+ emitDispatcherError(context.meter ?? getFallbackMeter(), { handler: consumer.name, errorClass });
434
+ context.log?.warn(
435
+ `event-dispatcher: ${consumer.name} skipped event ${eventId} (${errorClass}): ${errMessage}`,
436
+ );
437
+ }
438
+
439
+ export type PersistedConsumerOutcome = DeliveryOutcome & {
440
+ readonly pendingGaps: readonly PendingGapEntry[];
441
+ };
442
+
296
443
  export async function persistConsumerOutcome(
297
444
  tx: DbTx,
298
445
  name: string,
299
446
  instanceId: string,
300
- outcome: DeliveryOutcome,
447
+ outcome: PersistedConsumerOutcome,
301
448
  ): Promise<void> {
302
449
  await updateConsumerDeliveryOutcome(tx, name, instanceId, outcome);
303
450
  }