@cosmicdrift/kumiko-framework 0.305.0 → 0.307.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 (87) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/redis-sse-broker.integration.test.ts +66 -0
  3. package/src/api/__tests__/server-boot-guards.test.ts +1 -0
  4. package/src/api/__tests__/server-error-logging.test.ts +71 -0
  5. package/src/api/__tests__/sse-broker.test.ts +49 -0
  6. package/src/api/redis-sse-broker.ts +17 -3
  7. package/src/api/request-context.ts +29 -4
  8. package/src/api/routes.ts +26 -1
  9. package/src/api/sse-broker.ts +29 -11
  10. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  11. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  12. package/src/bun-db/query.ts +42 -18
  13. package/src/changes.json +92 -0
  14. package/src/db/__tests__/pg-error.test.ts +14 -0
  15. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  16. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  17. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  18. package/src/db/index.ts +1 -1
  19. package/src/db/pg-error.ts +13 -0
  20. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  21. package/src/db/queries/event-consumer.ts +57 -3
  22. package/src/db/queries/event-store.ts +69 -0
  23. package/src/db/tenant-db.ts +133 -18
  24. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  25. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  26. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  27. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  28. package/src/engine/extension-names.ts +55 -25
  29. package/src/engine/extensions/storage-provider.ts +14 -41
  30. package/src/engine/extensions/tenant-data.ts +4 -0
  31. package/src/engine/extensions/tenant-resource.ts +40 -0
  32. package/src/engine/extensions/user-data.ts +8 -7
  33. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  34. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  35. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  36. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  37. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  38. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  39. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  40. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  41. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  42. package/src/engine/feature-ast/index.ts +11 -1
  43. package/src/engine/feature-ast/patch.ts +338 -5
  44. package/src/engine/feature-ast/patcher.ts +2 -2
  45. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  46. package/src/engine/feature-ast/patterns.ts +22 -15
  47. package/src/engine/feature-ast/render.ts +1 -0
  48. package/src/engine/feature-ui-extensions.ts +8 -7
  49. package/src/engine/index.ts +21 -5
  50. package/src/engine/types/extension-options-map.ts +1 -0
  51. package/src/engine/types/index.ts +6 -0
  52. package/src/event-store/__tests__/event-attribution.integration.test.ts +53 -3
  53. package/src/event-store/admin-api.ts +5 -0
  54. package/src/event-store/event-store.ts +16 -7
  55. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
  56. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  57. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  58. package/src/jobs/__tests__/jobs.integration.test.ts +3 -3
  59. package/src/jobs/job-runner.ts +211 -19
  60. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  61. package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
  62. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  63. package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
  64. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
  65. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
  66. package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
  67. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  68. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  69. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +261 -2
  70. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  71. package/src/pipeline/dispatch-batch.ts +102 -29
  72. package/src/pipeline/dispatch-stream.ts +7 -3
  73. package/src/pipeline/dispatcher-utils.ts +21 -2
  74. package/src/pipeline/dispatcher.ts +71 -6
  75. package/src/pipeline/event-consumer-state.ts +26 -0
  76. package/src/pipeline/event-dispatcher-admin.ts +32 -5
  77. package/src/pipeline/event-dispatcher-delivery.ts +109 -57
  78. package/src/pipeline/event-dispatcher.ts +167 -50
  79. package/src/pipeline/idempotency.ts +16 -0
  80. package/src/pipeline/pending-gap-ranges.ts +72 -0
  81. package/src/pipeline/system-hooks.ts +8 -1
  82. package/src/pipeline/system-identity-switch.ts +22 -4
  83. package/src/pipeline/write-origin.ts +31 -10
  84. package/src/stack/test-stack.ts +1 -1
  85. package/src/testing/closed-connection-error.ts +62 -0
  86. package/src/testing/index.ts +1 -0
  87. package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
@@ -1,3 +1,5 @@
1
+ import type { WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
2
+ import { runWithWriteOrigin } from "../api/request-context";
1
3
  import type { SseBroker } from "../api/sse-broker";
2
4
  import type { buildEntityTable } from "../db/table-builder";
3
5
  import {
@@ -18,7 +20,7 @@ import type {
18
20
  WriteResult,
19
21
  } from "../engine/types";
20
22
  import type { TenantId } from "../engine/types/identifiers";
21
- import { reraiseAsKumikoError } from "../errors";
23
+ import { InternalError, reraiseAsKumikoError } from "../errors";
22
24
  import { getFallbackMeter, getFallbackTracer, registerStandardMetrics } from "../observability";
23
25
  import { createEscapeHatchReportWindow } from "../observability/escape-hatch-report";
24
26
  import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership";
@@ -32,7 +34,7 @@ import type { IdempotencyGuard } from "./idempotency";
32
34
  import type { LifecycleHooks } from "./lifecycle-pipeline";
33
35
  import { createMemberReaderFn } from "./member-reader";
34
36
  import { createTenantTimezoneCache } from "./tenant-timezone-cache";
35
- import { rootWriteOrigin } from "./write-origin";
37
+ import { effectiveWriteOrigin, isPersonalDataGated, rootWriteOrigin } from "./write-origin";
36
38
 
37
39
  // Re-export for callers that reach for dispatcher-adjacent types (tests,
38
40
  // HTTP-layer stubs) — dispatch consumes these, grouping the type-surface
@@ -111,12 +113,55 @@ export type Dispatcher = {
111
113
  createMemberReader(tenantId: TenantId): MemberReader;
112
114
  };
113
115
 
116
+ // Kept off the public Dispatcher type: only a job may pass an inherited origin.
117
+ type DispatcherInternals = {
118
+ writeWithOrigin: (
119
+ type: string,
120
+ payload: unknown,
121
+ user: SessionUser,
122
+ inheritedOrigin: WriteOrigin,
123
+ ) => Promise<WriteResult>;
124
+ queryWithOrigin: (
125
+ type: string,
126
+ payload: unknown,
127
+ user: SessionUser,
128
+ inheritedOrigin: WriteOrigin,
129
+ ) => Promise<unknown>;
130
+ };
131
+
132
+ const dispatcherInternals = new WeakMap<Dispatcher, DispatcherInternals>();
133
+
114
134
  // Adapts Dispatcher's (type, payload, user) call shape to DispatchWriteRef's
115
135
  // (user, qn, payload) — JobRunner.attachDispatcher needs the latter.
116
136
  export function dispatcherToWriteRef(dispatcher: Dispatcher): DispatchWriteRef {
137
+ const internals = dispatcherInternals.get(dispatcher);
117
138
  return {
118
- write: (user, qn, payload) => dispatcher.write(qn, payload, user),
119
- queryAs: (user, qn, payload) => dispatcher.query(qn, payload, user),
139
+ write: (user, qn, payload, inheritedOrigin) => {
140
+ if (inheritedOrigin && isPersonalDataGated(inheritedOrigin)) {
141
+ if (!internals) {
142
+ throw new InternalError({
143
+ message:
144
+ `JobContext.write("${qn}") carries a gated origin but this dispatcher has no ` +
145
+ "registered origin-aware internals — refusing to fall open to the ungated path.",
146
+ });
147
+ }
148
+ return internals.writeWithOrigin(qn, payload, user, inheritedOrigin);
149
+ }
150
+ return dispatcher.write(qn, payload, user);
151
+ },
152
+ queryAs: (user, qn, payload, inheritedOrigin) => {
153
+ if (inheritedOrigin && isPersonalDataGated(inheritedOrigin)) {
154
+ if (!internals) {
155
+ throw new InternalError({
156
+ message:
157
+ `JobContext.queryAs("${qn}") carries a gated origin but this dispatcher has no ` +
158
+ "registered origin-aware internals — refusing to fall open to the ungated path.",
159
+ });
160
+ }
161
+ return internals.queryWithOrigin(qn, payload, user, inheritedOrigin);
162
+ }
163
+ return dispatcher.query(qn, payload, user);
164
+ },
120
165
  createMemberReader: (tenantId) => dispatcher.createMemberReader(tenantId),
121
166
  };
122
167
  }
@@ -173,7 +218,7 @@ export function createDispatcher(
173
218
  membershipQuery,
174
219
  };
175
220
 
176
- return {
221
+ const dispatcher: Dispatcher = {
177
222
  async write(typeOrRef, payload, user, requestId?) {
178
223
  const type = resolveType(typeOrRef);
179
224
  // Idempotency handled inside runBatch (caches BatchResult under requestId).
@@ -185,7 +230,8 @@ export function createDispatcher(
185
230
 
186
231
  query: (typeOrRef, payload, user) => {
187
232
  const type = resolveType(typeOrRef);
188
- return executeQuery(ctx, type, payload, user, rootWriteOrigin(registry, type, user));
233
+ const origin = rootWriteOrigin(registry, type, user);
234
+ return runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin));
189
235
  },
190
236
 
191
237
  stream: (typeOrRef, payload, user) => {
@@ -210,4 +256,23 @@ export function createDispatcher(
210
256
 
211
257
  createMemberReader: (tenantId) => createMemberReaderFn(ctx, tenantId),
212
258
  };
259
+
260
+ dispatcherInternals.set(dispatcher, {
261
+ writeWithOrigin: async (type, payload, user, inheritedOrigin) => {
262
+ const batchResult = await runBatch(
263
+ ctx,
264
+ [{ type, payload }],
265
+ user,
266
+ undefined,
267
+ inheritedOrigin,
268
+ );
269
+ return unwrapSingle(batchResult);
270
+ },
271
+ queryWithOrigin: (type, payload, user, inheritedOrigin) => {
272
+ const origin = effectiveWriteOrigin(rootWriteOrigin(registry, type, user), inheritedOrigin);
273
+ return runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin));
274
+ },
275
+ });
276
+
277
+ return dispatcher;
213
278
  }
@@ -5,6 +5,7 @@ import {
5
5
  index,
6
6
  instant,
7
7
  integer,
8
+ jsonb,
8
9
  table as pgTable,
9
10
  primaryKey,
10
11
  sql,
@@ -64,6 +65,21 @@ export const SHARED_INSTANCE_SENTINEL = "__shared__";
64
65
  // The default(sql`0`) on lastProcessedEventId mirrors projection-state.ts:
65
66
  // drizzle-kit's JSON snapshot generator can't serialise a bigint literal, so
66
67
  // the server-side default is specified as raw SQL instead of .default(0n).
68
+
69
+ // A contiguous run of ids below the cursor invisible on some earlier turn,
70
+ // not yet resolved (delivered) or proven burnt (see event-dispatcher.ts's
71
+ // processConsumer). Ranges, not individual ids — a large historical gap
72
+ // (retention prune, or a new consumer starting "beginning" over pruned
73
+ // history) then costs O(1) entries instead of one per missing id. xmax is
74
+ // the pg_snapshot_xmax() captured when the range was recorded: once a later
75
+ // turn's xmin passes it, every xact that could still produce a row in this
76
+ // range has finished, so it's provably rolled back, not just slow.
77
+ // bigint/xid8 travel as strings — JS bigint doesn't round-trip through jsonb.
78
+ export type PendingGapEntry = {
79
+ readonly from: string;
80
+ readonly to: string;
81
+ readonly xmax: string;
82
+ };
67
83
  export const eventConsumerStateTable = pgTable(
68
84
  "kumiko_event_consumers",
69
85
  {
@@ -82,6 +98,7 @@ export const eventConsumerStateTable = pgTable(
82
98
  // poisoned), as does a manual restartConsumer()/enableConsumer()/
83
99
  // skipPoisonEvent() — an operator vouching the consumer is healthy.
84
100
  rearmCount: integer("rearm_count").notNull().default(0),
101
+ pendingGaps: jsonb("pending_gaps").$type<PendingGapEntry[]>().default([]).notNull(),
85
102
  lastError: text("last_error"),
86
103
  updatedAt: instant("updated_at", { precision: 3 }).notNull().default(sql`now()`),
87
104
  },
@@ -142,6 +159,15 @@ export async function createEventConsumerStateTable(db: DbConnection): Promise<v
142
159
  " NOT NULL",
143
160
  /* ifNotExists */ true,
144
161
  );
162
+ await alterTableAddColumn(
163
+ db,
164
+ "kumiko_event_consumers",
165
+ "pending_gaps",
166
+ "jsonb",
167
+ " DEFAULT '[]'::jsonb",
168
+ " NOT NULL",
169
+ /* ifNotExists */ true,
170
+ );
145
171
  // Runs on every boot, including mid-rolling-deploy: a new pod can delete
146
172
  // an old pod's still-in-use per-instance row out from under it. That old
147
173
  // pod's next acquireConsumer then finds no row, delivers no more SSE/
@@ -1,13 +1,15 @@
1
1
  import type { DbConnection, DbTx } from "../db/connection";
2
2
  import {
3
3
  advanceConsumerPastEventReturning,
4
+ removePendingGapReturning,
4
5
  updateConsumerStatusReturning,
5
6
  } from "../db/queries/event-consumer";
6
- import { selectNextEventIdAfter } from "../db/queries/event-store";
7
+ import { selectNextEventIdAfter, selectSmallestVisibleIdInRanges } from "../db/queries/event-store";
7
8
  import { coerceRow, extractTableInfo, selectMany } from "../db/query";
8
9
  import { getEventsHighWaterMark } from "../event-store";
9
10
  import { eventConsumerStateTable, SHARED_INSTANCE_SENTINEL } from "./event-consumer-state";
10
11
  import type { ConsumerStateRow, ConsumerStateRowShape } from "./event-dispatcher-delivery";
12
+ import { rangeContainsId, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges";
11
13
 
12
14
  // --- Ops recovery surface ---
13
15
  //
@@ -129,10 +131,13 @@ export async function enableConsumer(
129
131
  return applyConsumerStatusTransition(db, name, instanceId, "idle");
130
132
  }
131
133
 
132
- // skipPoisonEvent advances the cursor past the first event after the
133
- // current cursor. Single TX so concurrent dispatcher passes can't double-
134
- // advance. If no event exists past the cursor, there is nothing to skip —
135
- // treat as idempotent no-op (cursor already at head).
134
+ // skipPoisonEvent advances past whatever is currently blocking the consumer.
135
+ // If the smallest visible id inside pending_gaps exists, THAT'S the poison —
136
+ // halt-on-poison walks gap ids before any id past the cursor — so it's split
137
+ // out of its range directly; the cursor stays put (it's already above this
138
+ // id). Otherwise the poison is the usual next event after the cursor. Single
139
+ // TX so concurrent dispatcher passes can't double-advance. Neither exists →
140
+ // idempotent no-op.
136
141
  export async function skipPoisonEvent(
137
142
  db: DbConnection,
138
143
  name: string,
@@ -140,6 +145,28 @@ export async function skipPoisonEvent(
140
145
  ): Promise<ConsumerRecoveryState & { readonly skippedEventId: bigint | null }> {
141
146
  const before = await requireConsumerRow(db, name, instanceId);
142
147
  return db.begin(async (tx: DbTx) => {
148
+ const pendingGaps = before.pendingGaps;
149
+ const smallestVisiblePending = await selectSmallestVisibleIdInRanges(
150
+ tx,
151
+ toIdRanges(pendingGaps),
152
+ );
153
+
154
+ if (smallestVisiblePending !== null) {
155
+ const newPendingGaps = pendingGaps.flatMap((gap) =>
156
+ rangeContainsId(gap, smallestVisiblePending)
157
+ ? splitRangeExcludingIds(gap, [smallestVisiblePending])
158
+ : [gap],
159
+ );
160
+ const raw = await removePendingGapReturning(tx, name, instanceId, newPendingGaps);
161
+ const updated =
162
+ raw && (coerceRow(raw, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
163
+ if (!updated)
164
+ throw new Error(
165
+ `Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`,
166
+ );
167
+ return { ...normalizeConsumerState(updated), skippedEventId: smallestVisiblePending };
168
+ }
169
+
143
170
  const poisonId = await selectNextEventIdAfter(tx, before.lastProcessedEventId);
144
171
  if (poisonId === null) {
145
172
  const [unchanged] = await selectMany<ConsumerStateRow>(tx, eventConsumerStateTable, {
@@ -12,8 +12,12 @@ import {
12
12
  selectConsumerForUpdateSkipLocked,
13
13
  updateConsumerDeliveryOutcome,
14
14
  } from "../db/queries/event-consumer";
15
- import { selectEventsHeadId } from "../db/queries/event-store";
16
- import { coerceRow, extractTableInfo, selectMany } from "../db/query";
15
+ import {
16
+ type PendingIdRange,
17
+ selectEventsHeadId,
18
+ selectPendingAndNewEventRows,
19
+ } from "../db/queries/event-store";
20
+ import { coerceRow, extractTableInfo } from "../db/query";
17
21
  import { qnScope } from "../engine/qualified-name";
18
22
  import type { AppContext } from "../engine/types";
19
23
  import { eventsTable, toStoredEvent as rowToStoredEvent } from "../event-store";
@@ -26,9 +30,18 @@ import {
26
30
  import {
27
31
  ConsumerStatuses,
28
32
  eventConsumerStateTable,
33
+ type PendingGapEntry,
29
34
  SHARED_INSTANCE_SENTINEL,
30
35
  } from "./event-consumer-state";
31
36
  import type { EventConsumer } from "./event-dispatcher";
37
+ import { parseWriteOrigin } from "./write-origin";
38
+
39
+ // Fails closed without throwing: a throw would poison the event for every consumer.
40
+ const UNPARSEABLE_STORED_WRITE_ORIGIN = {
41
+ rootHandler: "<unknown>",
42
+ anonymousRoot: true,
43
+ publicIntake: false,
44
+ } as const;
32
45
 
33
46
  // Per-consumer pass mechanics: acquire the state row, fetch pending events,
34
47
  // hand them to the consumer's handler in order, persist the outcome. Split
@@ -46,6 +59,7 @@ export type ConsumerStateRowShape = {
46
59
  readonly status: string;
47
60
  readonly attempts: number;
48
61
  readonly rearmCount: number;
62
+ readonly pendingGaps: readonly PendingGapEntry[];
49
63
  readonly lastError: string | null;
50
64
  readonly updatedAt: Temporal.Instant;
51
65
  };
@@ -186,17 +200,20 @@ export async function markProcessing(tx: DbTx, name: string, instanceId: string)
186
200
  await markConsumerProcessing(tx, name, instanceId);
187
201
  }
188
202
 
203
+ // `pendingRanges` are id ranges below `cursor` the consumer is still
204
+ // watching as gaps (invisible on an earlier turn — see event-dispatcher.ts's
205
+ // processConsumer). Fetching them alongside the plain `id > cursor` window
206
+ // means a row that committed late becomes visible and deliverable the next
207
+ // time this consumer's turn runs, instead of being permanently skipped.
189
208
  export async function fetchPendingEvents(
190
209
  tx: DbTx,
191
210
  cursor: bigint,
192
211
  batchSize: number,
212
+ pendingRanges: readonly PendingIdRange[] = [],
193
213
  ): 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
214
+ const rawRows = await selectPendingAndNewEventRows(tx, cursor, pendingRanges, batchSize);
215
+ const info = extractTableInfo(eventsTable);
216
+ return rawRows.map((row) => coerceRow(row, info) as StoredEventRow); // @cast-boundary db-row
200
217
  }
201
218
 
202
219
  export type DeliveryOutcome = {
@@ -206,6 +223,11 @@ export type DeliveryOutcome = {
206
223
  readonly deadLettered: boolean;
207
224
  readonly processed: number;
208
225
  readonly failed: number;
226
+ // Which of the *pending* ids in `events` (id <= the cursor this delivery
227
+ // started from) got resolved this pass — delivered or skip-applied. The
228
+ // caller (event-dispatcher.ts) splits exactly these out of pending_gaps;
229
+ // everything else in the input batch was a "new" row past the old cursor.
230
+ readonly resolvedPendingIds: readonly bigint[];
209
231
  };
210
232
 
211
233
  // Deliver events to the consumer's handler in events.id order. Halt-on-
@@ -221,83 +243,113 @@ export async function deliverEvents(
221
243
  maxAttempts: number,
222
244
  state: ConsumerStateRow,
223
245
  ): Promise<DeliveryOutcome> {
224
- let cursor = state.lastProcessedEventId;
246
+ const startCursor = state.lastProcessedEventId;
247
+ let cursor = startCursor;
225
248
  let attempts = state.attempts;
226
249
  let lastError: string | null = state.lastError ?? null;
227
250
  let deadLettered = false;
228
251
  const effectiveMaxAttempts = consumer.errorPolicy?.maxAttempts ?? maxAttempts;
229
252
  let processed = 0;
230
253
  let failed = 0;
254
+ const resolvedPendingIds: bigint[] = [];
255
+
256
+ // A pending row sits below startCursor: it resolves its gap but never moves
257
+ // the cursor backward. ORDER BY id walks all pending rows first.
258
+ const resolve = (id: bigint): void => {
259
+ if (id > cursor) cursor = id;
260
+ if (id <= startCursor) resolvedPendingIds.push(id);
261
+ attempts = 0;
262
+ lastError = null;
263
+ };
231
264
 
232
265
  for (const row of events) {
233
266
  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;
267
+ await applyEvent(consumer, row, context);
268
+ resolve(row.id);
261
269
  processed += 1;
262
270
  } catch (e) {
263
271
  const errMessage = e instanceof Error ? e.message : String(e);
272
+ failed += 1;
264
273
  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;
274
+ reportSkippedEvent(consumer, row.id, e, errMessage, context);
275
+ resolve(row.id);
283
276
  continue;
284
277
  }
285
278
  attempts += 1;
286
279
  lastError = errMessage;
287
- failed += 1;
288
280
  if (attempts >= effectiveMaxAttempts) deadLettered = true;
289
281
  break;
290
282
  }
291
283
  }
292
284
 
293
- return { cursor, attempts, lastError, deadLettered, processed, failed };
285
+ return { cursor, attempts, lastError, deadLettered, processed, failed, resolvedPendingIds };
286
+ }
287
+
288
+ async function applyEvent(
289
+ consumer: EventConsumer,
290
+ row: StoredEventRow,
291
+ context: AppContext,
292
+ ): Promise<void> {
293
+ // Propagate causation: if the handler calls ctx.appendEvent, the new
294
+ // event should record THIS event as its cause. correlationId is
295
+ // inherited unchanged — it survives the hop across streams by design.
296
+ // requestId falls back to a fresh id because the dispatcher runs
297
+ // outside any HTTP request (background poll), and a stable log-
298
+ // correlation handle is still useful for debugging.
299
+ const stored = rowToStoredEvent(row);
300
+ const correlationId = stored.metadata.correlationId ?? requestContext.generateId();
301
+ const causationId = String(stored.id);
302
+ const requestId = requestContext.generateId();
303
+ // The job-trigger consumer's handleEvent stamps event-triggered jobs from this.
304
+ const rawStoredWriteOrigin = stored.metadata.writeOrigin;
305
+ const writeOrigin =
306
+ rawStoredWriteOrigin === undefined
307
+ ? undefined
308
+ : (parseWriteOrigin(rawStoredWriteOrigin) ?? UNPARSEABLE_STORED_WRITE_ORIGIN);
309
+ // #3043 — an event this apply writes is attributed to the consumer, not
310
+ // to whatever wrote the triggering event; causationId already links back.
311
+ await requestContext.run(
312
+ {
313
+ requestId,
314
+ correlationId,
315
+ causationId,
316
+ handler: consumer.name,
317
+ feature: consumer.featureName ?? qnScope(consumer.name),
318
+ writeOrigin,
319
+ },
320
+ async () => {
321
+ await consumer.handler(stored, context);
322
+ },
323
+ );
324
+ }
325
+
326
+ // Best-effort mode: record the error on the skip counter so ops can alert on
327
+ // a spike of skipped events; the consumer stays "idle", not "dead". The
328
+ // warn-level log line tells them WHICH events — without it a
329
+ // poisoned-then-skipped event is invisible to forensic search.
330
+ function reportSkippedEvent(
331
+ consumer: EventConsumer,
332
+ eventId: bigint,
333
+ e: unknown,
334
+ errMessage: string,
335
+ context: AppContext,
336
+ ): void {
337
+ const errorClass = e instanceof Error ? e.constructor.name : "UnknownError";
338
+ emitDispatcherError(context.meter ?? getFallbackMeter(), { handler: consumer.name, errorClass });
339
+ context.log?.warn(
340
+ `event-dispatcher: ${consumer.name} skipped event ${eventId} (${errorClass}): ${errMessage}`,
341
+ );
294
342
  }
295
343
 
344
+ export type PersistedConsumerOutcome = DeliveryOutcome & {
345
+ readonly pendingGaps: readonly PendingGapEntry[];
346
+ };
347
+
296
348
  export async function persistConsumerOutcome(
297
349
  tx: DbTx,
298
350
  name: string,
299
351
  instanceId: string,
300
- outcome: DeliveryOutcome,
352
+ outcome: PersistedConsumerOutcome,
301
353
  ): Promise<void> {
302
354
  await updateConsumerDeliveryOutcome(tx, name, instanceId, outcome);
303
355
  }