@nimbus-cqrs/eventsourcingdb 2.4.0 → 3.0.0-beta.1

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 (88) hide show
  1. package/README.md +82 -46
  2. package/dist/index.d.ts +8 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +8 -0
  5. package/dist/index.js.map +1 -0
  6. package/{esm → dist}/lib/client.d.ts +28 -17
  7. package/{esm → dist}/lib/client.d.ts.map +1 -1
  8. package/{esm → dist}/lib/client.js +25 -19
  9. package/dist/lib/client.js.map +1 -0
  10. package/dist/lib/consumerRunner.d.ts +62 -0
  11. package/dist/lib/consumerRunner.d.ts.map +1 -0
  12. package/dist/lib/consumerRunner.js +386 -0
  13. package/dist/lib/consumerRunner.js.map +1 -0
  14. package/dist/lib/eventConsumer.d.ts +94 -0
  15. package/dist/lib/eventConsumer.d.ts.map +1 -0
  16. package/dist/lib/eventConsumer.js +2 -0
  17. package/dist/lib/eventConsumer.js.map +1 -0
  18. package/{esm → dist}/lib/eventMapping.d.ts +1 -1
  19. package/dist/lib/eventMapping.d.ts.map +1 -0
  20. package/{esm → dist}/lib/eventMapping.js +2 -1
  21. package/dist/lib/eventMapping.js.map +1 -0
  22. package/dist/lib/eventObserver.d.ts +158 -0
  23. package/dist/lib/eventObserver.d.ts.map +1 -0
  24. package/dist/lib/eventObserver.js +339 -0
  25. package/dist/lib/eventObserver.js.map +1 -0
  26. package/dist/lib/observerRegistry.d.ts +39 -0
  27. package/dist/lib/observerRegistry.d.ts.map +1 -0
  28. package/dist/lib/observerRegistry.js +56 -0
  29. package/dist/lib/observerRegistry.js.map +1 -0
  30. package/{esm → dist}/lib/readEvents.d.ts.map +1 -1
  31. package/{esm → dist}/lib/readEvents.js +3 -2
  32. package/dist/lib/readEvents.js.map +1 -0
  33. package/{esm → dist}/lib/tracing.d.ts +3 -0
  34. package/dist/lib/tracing.d.ts.map +1 -0
  35. package/{esm → dist}/lib/tracing.js +5 -1
  36. package/dist/lib/tracing.js.map +1 -0
  37. package/dist/lib/writeEvents.d.ts +29 -0
  38. package/dist/lib/writeEvents.d.ts.map +1 -0
  39. package/dist/lib/writeEvents.js +47 -0
  40. package/dist/lib/writeEvents.js.map +1 -0
  41. package/package.json +32 -13
  42. package/src/index.ts +7 -0
  43. package/src/lib/client.ts +166 -0
  44. package/src/lib/consumerRunner.ts +592 -0
  45. package/src/lib/eventConsumer.ts +95 -0
  46. package/src/lib/eventMapping.ts +115 -0
  47. package/src/lib/eventObserver.ts +620 -0
  48. package/src/lib/observerRegistry.ts +69 -0
  49. package/src/lib/readEvents.ts +28 -0
  50. package/src/lib/tracing.ts +295 -0
  51. package/src/lib/writeEvents.ts +72 -0
  52. package/esm/_dnt.polyfills.d.ts +0 -7
  53. package/esm/_dnt.polyfills.d.ts.map +0 -1
  54. package/esm/_dnt.polyfills.js +0 -1
  55. package/esm/_dnt.shims.d.ts +0 -2
  56. package/esm/_dnt.shims.d.ts.map +0 -1
  57. package/esm/_dnt.shims.js +0 -57
  58. package/esm/deps/jsr.io/@std/fmt/1.0.10/colors.d.ts +0 -700
  59. package/esm/deps/jsr.io/@std/fmt/1.0.10/colors.d.ts.map +0 -1
  60. package/esm/deps/jsr.io/@std/fmt/1.0.10/colors.js +0 -903
  61. package/esm/deps/jsr.io/@std/ulid/1.0.0/_util.d.ts +0 -14
  62. package/esm/deps/jsr.io/@std/ulid/1.0.0/_util.d.ts.map +0 -1
  63. package/esm/deps/jsr.io/@std/ulid/1.0.0/_util.js +0 -65
  64. package/esm/deps/jsr.io/@std/ulid/1.0.0/decode_time.d.ts +0 -20
  65. package/esm/deps/jsr.io/@std/ulid/1.0.0/decode_time.d.ts.map +0 -1
  66. package/esm/deps/jsr.io/@std/ulid/1.0.0/decode_time.js +0 -43
  67. package/esm/deps/jsr.io/@std/ulid/1.0.0/mod.d.ts +0 -44
  68. package/esm/deps/jsr.io/@std/ulid/1.0.0/mod.d.ts.map +0 -1
  69. package/esm/deps/jsr.io/@std/ulid/1.0.0/mod.js +0 -47
  70. package/esm/deps/jsr.io/@std/ulid/1.0.0/monotonic_ulid.d.ts +0 -44
  71. package/esm/deps/jsr.io/@std/ulid/1.0.0/monotonic_ulid.d.ts.map +0 -1
  72. package/esm/deps/jsr.io/@std/ulid/1.0.0/monotonic_ulid.js +0 -51
  73. package/esm/deps/jsr.io/@std/ulid/1.0.0/ulid.d.ts +0 -31
  74. package/esm/deps/jsr.io/@std/ulid/1.0.0/ulid.d.ts.map +0 -1
  75. package/esm/deps/jsr.io/@std/ulid/1.0.0/ulid.js +0 -37
  76. package/esm/index.d.ts +0 -7
  77. package/esm/index.d.ts.map +0 -1
  78. package/esm/index.js +0 -6
  79. package/esm/lib/eventMapping.d.ts.map +0 -1
  80. package/esm/lib/eventObserver.d.ts +0 -133
  81. package/esm/lib/eventObserver.d.ts.map +0 -1
  82. package/esm/lib/eventObserver.js +0 -291
  83. package/esm/lib/tracing.d.ts.map +0 -1
  84. package/esm/lib/writeEvents.d.ts +0 -11
  85. package/esm/lib/writeEvents.d.ts.map +0 -1
  86. package/esm/lib/writeEvents.js +0 -31
  87. package/esm/package.json +0 -3
  88. /package/{esm → dist}/lib/readEvents.d.ts +0 -0
@@ -0,0 +1,592 @@
1
+ import {
2
+ type CheckpointFailure,
3
+ type CheckpointStore,
4
+ type CheckpointUpdate,
5
+ type Event,
6
+ GenericException,
7
+ getLogger,
8
+ } from '@nimbus-cqrs/core';
9
+ import type { Event as EventSourcingDBEvent } from 'eventsourcingdb';
10
+ import type { ConsumerStatus, EventConsumer } from './eventConsumer.ts';
11
+ import { eventSourcingDBEventToNimbusEvent } from './eventMapping.ts';
12
+
13
+ /**
14
+ * Where an observer starts reading the stream.
15
+ */
16
+ export type Bound = {
17
+ id: string;
18
+ type: 'inclusive' | 'exclusive';
19
+ };
20
+
21
+ /**
22
+ * Runs the consumers of one observer and keeps their checkpoints.
23
+ */
24
+ export type ConsumerRunner = {
25
+ /**
26
+ * The lowest position of all consumers, from where the observer has
27
+ * to resume so that every consumer sees what it still misses.
28
+ */
29
+ readonly lowerBound: Bound;
30
+ /**
31
+ * Offers one event to every consumer in declaration order. Rejects
32
+ * when at least one consumer failed so the observer retries in place.
33
+ */
34
+ readonly handle: (raw: EventSourcingDBEvent) => Promise<void>;
35
+ /**
36
+ * Halts the consumers that failed on the most recent attempt and
37
+ * persists their failure. Called once handler retries are exhausted.
38
+ */
39
+ readonly onExhausted: (
40
+ error: Error,
41
+ raw: EventSourcingDBEvent,
42
+ ) => Promise<void>;
43
+ /**
44
+ * The current state of every consumer.
45
+ */
46
+ readonly status: () => ConsumerStatus[];
47
+ };
48
+
49
+ export type CreateConsumerRunnerOptions = {
50
+ observer: string;
51
+ consumers: readonly EventConsumer[];
52
+ checkpointStore: CheckpointStore;
53
+ head: string | null;
54
+ };
55
+
56
+ type ConsumerState = {
57
+ consumer: EventConsumer;
58
+ /** Last finished event id; null until the consumer finishes its first. */
59
+ position: bigint | null;
60
+ /** Position or failure changed since the last successful flush. */
61
+ pending: boolean;
62
+ halted: boolean;
63
+ failure?: CheckpointFailure;
64
+ };
65
+
66
+ /** How long the stream has to go quiet before a burst is considered over. */
67
+ const BURST_IDLE_MS = 2000;
68
+
69
+ const toError = (error: unknown): Error => {
70
+ return error instanceof Error ? error : new Error(String(error));
71
+ };
72
+
73
+ const describeError = (error: Error): Record<string, unknown> => {
74
+ return {
75
+ name: error.name,
76
+ message: error.message,
77
+ ...(error.stack !== undefined ? { stack: error.stack } : {}),
78
+ };
79
+ };
80
+
81
+ const describeFailures = (
82
+ failures: ReadonlyMap<string, Error>,
83
+ ): Record<string, unknown> => {
84
+ return Object.fromEntries(
85
+ [...failures.entries()].map(([name, error]) => {
86
+ return [name, describeError(error)];
87
+ }),
88
+ );
89
+ };
90
+
91
+ const failureSummary = (failures: ReadonlyMap<string, Error>): string => {
92
+ return [...failures.entries()]
93
+ .map(([name, error]) => {
94
+ return `${name}: ${error.name}: ${error.message}`;
95
+ })
96
+ .join('; ');
97
+ };
98
+
99
+ type Burst = {
100
+ startedAt: number;
101
+ finishedAt: number;
102
+ events: number;
103
+ eventsHandled: number;
104
+ firstEventId: string;
105
+ lastEventId: string;
106
+ };
107
+
108
+ /**
109
+ * Times contiguous runs of events and logs one summary per run, so a replay
110
+ * reports as a single line instead of one per event. A run ends once the
111
+ * stream has been quiet for {@link BURST_IDLE_MS}; the reported duration
112
+ * covers only the events themselves, never the idle wait.
113
+ */
114
+ const createBurstTimer = (observer: string) => {
115
+ let burst: Burst | null = null;
116
+ let timer: ReturnType<typeof setTimeout> | undefined;
117
+ let inFlight = false;
118
+
119
+ const log = () => {
120
+ if (burst === null) {
121
+ return;
122
+ }
123
+
124
+ const durationMs = burst.finishedAt - burst.startedAt;
125
+ const durationSeconds = Math.floor((durationMs / 1000) * 1000) / 1000;
126
+
127
+ const { events, eventsHandled, firstEventId, lastEventId } = burst;
128
+ burst = null;
129
+
130
+ getLogger().debug({
131
+ category: 'Nimbus',
132
+ message: `Observer "${observer}" processed ${events} event(s) in ${durationSeconds}s`,
133
+ data: {
134
+ observer,
135
+ events,
136
+ eventsHandled,
137
+ durationMs,
138
+ eventsPerSecond:
139
+ durationMs > 0
140
+ ? Math.round((events / durationMs) * 1000)
141
+ : events,
142
+ firstEventId,
143
+ lastEventId,
144
+ },
145
+ });
146
+ };
147
+
148
+ const schedule = () => {
149
+ if (timer !== undefined) {
150
+ clearTimeout(timer);
151
+ }
152
+
153
+ const scheduled = setTimeout(() => {
154
+ timer = undefined;
155
+
156
+ // A single event slower than the idle window must not split its
157
+ // own burst in two.
158
+ if (inFlight) {
159
+ schedule();
160
+ return;
161
+ }
162
+
163
+ log();
164
+ }, BURST_IDLE_MS);
165
+
166
+ timer = scheduled;
167
+
168
+ // Never hold the process open just to emit a timing line.
169
+ scheduled.unref();
170
+ };
171
+
172
+ return {
173
+ start: (eventId: string) => {
174
+ inFlight = true;
175
+ const now = Date.now();
176
+
177
+ burst ??= {
178
+ startedAt: now,
179
+ finishedAt: now,
180
+ events: 0,
181
+ eventsHandled: 0,
182
+ firstEventId: eventId,
183
+ lastEventId: eventId,
184
+ };
185
+
186
+ burst.events += 1;
187
+ burst.lastEventId = eventId;
188
+ },
189
+ finish: (handled: boolean) => {
190
+ inFlight = false;
191
+
192
+ if (burst !== null) {
193
+ burst.finishedAt = Date.now();
194
+
195
+ if (handled) {
196
+ burst.eventsHandled += 1;
197
+ }
198
+ }
199
+
200
+ schedule();
201
+ },
202
+ };
203
+ };
204
+
205
+ /**
206
+ * The lowest position of all consumers. Consumers ahead of it skip the
207
+ * replayed events in memory.
208
+ */
209
+ const lowerBoundFor = (states: readonly ConsumerState[]): Bound => {
210
+ let lowest: bigint | null = null;
211
+
212
+ for (const state of states) {
213
+ if (state.position === null) {
214
+ return { id: '0', type: 'inclusive' };
215
+ }
216
+
217
+ if (lowest === null || state.position < lowest) {
218
+ lowest = state.position;
219
+ }
220
+ }
221
+
222
+ return lowest === null
223
+ ? { id: '0', type: 'inclusive' }
224
+ : { id: lowest.toString(), type: 'exclusive' };
225
+ };
226
+
227
+ const startsFromHead = (consumer: EventConsumer): boolean => {
228
+ return consumer.startFrom === 'head';
229
+ };
230
+
231
+ /**
232
+ * Every consumer name has to be unique and every dependency has to name a
233
+ * consumer declared earlier on the same observer. A consumer that resumes
234
+ * from its checkpoint must not depend on one that starts from the head,
235
+ * because that dependency never built state for older events.
236
+ */
237
+ const validateConsumers = (
238
+ observer: string,
239
+ consumers: readonly EventConsumer[],
240
+ ): void => {
241
+ const declared = new Map<string, EventConsumer>();
242
+
243
+ for (const consumer of consumers) {
244
+ if (declared.has(consumer.name)) {
245
+ throw new GenericException(
246
+ `Observer "${observer}" declares consumer "${consumer.name}" more than once`,
247
+ { observer, consumer: consumer.name },
248
+ );
249
+ }
250
+
251
+ for (const dependency of consumer.dependsOn ?? []) {
252
+ const declaredDependency = declared.get(dependency);
253
+
254
+ if (declaredDependency === undefined) {
255
+ throw new GenericException(
256
+ `Consumer "${consumer.name}" of observer "${observer}" depends on "${dependency}", which is not declared before it`,
257
+ { observer, consumer: consumer.name, dependency },
258
+ );
259
+ }
260
+
261
+ if (
262
+ !startsFromHead(consumer) &&
263
+ startsFromHead(declaredDependency)
264
+ ) {
265
+ throw new GenericException(
266
+ `Consumer "${consumer.name}" of observer "${observer}" resumes from its checkpoint but depends on "${dependency}", which starts from the head and skips older events`,
267
+ { observer, consumer: consumer.name, dependency },
268
+ );
269
+ }
270
+ }
271
+
272
+ declared.set(consumer.name, consumer);
273
+ }
274
+ };
275
+
276
+ /**
277
+ * Wires the consumers of one observer onto a shared stream.
278
+ *
279
+ * Consumers run sequentially in declaration order. Every non-halted
280
+ * consumer advances its position on every event — handled or not — which
281
+ * keeps the position contiguous and lets a restart resume exactly where it
282
+ * stopped.
283
+ *
284
+ * A consumer that throws holds its position while its siblings advance, as
285
+ * does every consumer that depends on it. The handler then rejects so the
286
+ * observer retries in place. On the next retry the advanced consumers skip
287
+ * themselves, so only the failing ones run again. Once retries are
288
+ * exhausted those consumers are halted. They are skipped for the rest of
289
+ * the process lifetime and their checkpoint stays at the last event they
290
+ * finished, so a restart resumes at the event they could not handle.
291
+ *
292
+ * Consumers with `startFrom: 'head'` ignore their stored position and
293
+ * start at the head of the subject on every start. Their checkpoint is
294
+ * still written like any other, so operators see where they are.
295
+ */
296
+ export const createConsumerRunner = async ({
297
+ observer,
298
+ consumers,
299
+ checkpointStore,
300
+ head,
301
+ }: CreateConsumerRunnerOptions): Promise<ConsumerRunner> => {
302
+ validateConsumers(observer, consumers);
303
+
304
+ const names = consumers.map((consumer) => {
305
+ return consumer.name;
306
+ });
307
+
308
+ const checkpoints = await checkpointStore.load(names);
309
+
310
+ const positionFor = (consumer: EventConsumer): bigint | null => {
311
+ if (startsFromHead(consumer)) {
312
+ return head === null ? null : BigInt(head);
313
+ }
314
+
315
+ const position = checkpoints.get(consumer.name)?.position;
316
+
317
+ return position === undefined ? null : BigInt(position);
318
+ };
319
+
320
+ const states: ConsumerState[] = consumers.map((consumer) => {
321
+ const checkpoint = checkpoints.get(consumer.name);
322
+
323
+ return {
324
+ consumer,
325
+ position: positionFor(consumer),
326
+ pending: false,
327
+ halted: false,
328
+ ...(checkpoint?.failure !== undefined
329
+ ? { failure: checkpoint.failure }
330
+ : {}),
331
+ };
332
+ });
333
+
334
+ const statesByName = new Map(
335
+ states.map((state) => {
336
+ return [state.consumer.name, state];
337
+ }),
338
+ );
339
+
340
+ // Failures from the most recent handler attempt, read by onExhausted
341
+ // to know which consumers to halt.
342
+ let lastFailures = new Map<string, Error>();
343
+
344
+ const burstTimer = createBurstTimer(observer);
345
+
346
+ const flush = async (failed?: {
347
+ position: string;
348
+ errors: ReadonlyMap<string, Error>;
349
+ }) => {
350
+ const updates: CheckpointUpdate[] = [];
351
+ const now = new Date().toISOString();
352
+
353
+ for (const state of states) {
354
+ const error = failed?.errors.get(state.consumer.name);
355
+
356
+ if (failed !== undefined && error !== undefined) {
357
+ state.failure = {
358
+ position: failed.position,
359
+ error: `${error.name}: ${error.message}`,
360
+ at: now,
361
+ };
362
+ state.pending = true;
363
+ }
364
+
365
+ if (!state.pending) {
366
+ continue;
367
+ }
368
+
369
+ // A halted consumer carries its marker on every write until
370
+ // it is reset, so a marker whose first write failed is still
371
+ // persisted by the next successful flush. A running consumer
372
+ // only has a marker loaded from the store, which the save
373
+ // after its next success clears.
374
+ const failure =
375
+ state.halted && state.failure !== undefined
376
+ ? {
377
+ position: state.failure.position,
378
+ error: state.failure.error,
379
+ }
380
+ : undefined;
381
+
382
+ updates.push({
383
+ consumer: state.consumer.name,
384
+ observer,
385
+ ...(state.position !== null
386
+ ? { position: state.position.toString() }
387
+ : {}),
388
+ ...(failure !== undefined ? { failure } : {}),
389
+ });
390
+ }
391
+
392
+ if (updates.length === 0) {
393
+ return;
394
+ }
395
+
396
+ await checkpointStore.save(updates);
397
+
398
+ for (const state of states) {
399
+ state.pending = false;
400
+ }
401
+ };
402
+
403
+ /**
404
+ * The error that blocks the consumer on this event because one of its
405
+ * dependencies failed on it or is halted, or `undefined`.
406
+ */
407
+ const dependencyFailure = (
408
+ state: ConsumerState,
409
+ failures: ReadonlyMap<string, Error>,
410
+ eventId: string,
411
+ ): Error | undefined => {
412
+ const failedDependency = (state.consumer.dependsOn ?? []).find(
413
+ (dependency) => {
414
+ return (
415
+ failures.has(dependency) ||
416
+ statesByName.get(dependency)?.halted === true
417
+ );
418
+ },
419
+ );
420
+
421
+ return failedDependency === undefined
422
+ ? undefined
423
+ : new GenericException(
424
+ `Dependency "${failedDependency}" did not finish event ${eventId}`,
425
+ { dependency: failedDependency },
426
+ );
427
+ };
428
+
429
+ /**
430
+ * Offers the event to one consumer. Both the predicate and the handler
431
+ * run inside the error boundary, so a throwing predicate is a failure
432
+ * of this consumer alone and its siblings still receive the event.
433
+ */
434
+ const runConsumer = async (
435
+ state: ConsumerState,
436
+ event: Event,
437
+ raw: EventSourcingDBEvent,
438
+ ): Promise<{ handled: boolean; error?: Error }> => {
439
+ try {
440
+ if (!state.consumer.handles(event)) {
441
+ return { handled: false };
442
+ }
443
+
444
+ await state.consumer.handle(event, raw);
445
+
446
+ return { handled: true };
447
+ } catch (error) {
448
+ const failure = toError(error);
449
+
450
+ getLogger().error({
451
+ category: 'Nimbus',
452
+ message: `Consumer "${state.consumer.name}" failed to handle event ${raw.id} (${raw.type})`,
453
+ error: failure,
454
+ data: {
455
+ observer,
456
+ consumer: state.consumer.name,
457
+ eventId: raw.id,
458
+ eventType: raw.type,
459
+ },
460
+ correlationId: event.correlationid,
461
+ });
462
+
463
+ return { handled: true, error: failure };
464
+ }
465
+ };
466
+
467
+ const handle = async (raw: EventSourcingDBEvent) => {
468
+ const eventId = BigInt(raw.id);
469
+ const failures = new Map<string, Error>();
470
+ let handled = false;
471
+
472
+ // Before mapping: an event that cannot be mapped leaves an empty
473
+ // map behind, which tells onExhausted to halt every consumer.
474
+ lastFailures = failures;
475
+
476
+ const event = eventSourcingDBEventToNimbusEvent(raw);
477
+ burstTimer.start(raw.id);
478
+
479
+ try {
480
+ for (const state of states) {
481
+ if (
482
+ state.halted ||
483
+ (state.position !== null && eventId <= state.position)
484
+ ) {
485
+ continue;
486
+ }
487
+
488
+ const blocked = dependencyFailure(state, failures, raw.id);
489
+
490
+ if (blocked !== undefined) {
491
+ failures.set(state.consumer.name, blocked);
492
+ continue;
493
+ }
494
+
495
+ // Declaration order: a later consumer reads the earlier projection.
496
+ const result = await runConsumer(state, event, raw); // NOSONAR
497
+ handled ||= result.handled;
498
+
499
+ if (result.error !== undefined) {
500
+ failures.set(state.consumer.name, result.error);
501
+ continue;
502
+ }
503
+
504
+ state.position = eventId;
505
+ state.pending = true;
506
+ state.failure = undefined;
507
+ }
508
+
509
+ await flush();
510
+ } finally {
511
+ burstTimer.finish(handled);
512
+ }
513
+
514
+ if (failures.size > 0) {
515
+ const exception = new GenericException(
516
+ `Observer "${observer}" could not handle event ${raw.id} for ${failureSummary(
517
+ failures,
518
+ )}`,
519
+ {
520
+ eventId: raw.id,
521
+ eventType: raw.type,
522
+ consumers: describeFailures(failures),
523
+ },
524
+ );
525
+ const cause = [...failures.values()][0];
526
+
527
+ if (cause !== undefined) {
528
+ exception.cause = cause;
529
+ }
530
+
531
+ throw exception;
532
+ }
533
+ };
534
+
535
+ const onExhausted = async (error: Error, raw: EventSourcingDBEvent) => {
536
+ // An empty failure map means the handler broke outside a consumer
537
+ // (event mapping or the checkpoint flush), leaving the whole
538
+ // observer in an unknown state — halt all of it.
539
+ const errors =
540
+ lastFailures.size > 0
541
+ ? lastFailures
542
+ : new Map(
543
+ states
544
+ .filter((state) => {
545
+ return !state.halted;
546
+ })
547
+ .map((state) => {
548
+ return [state.consumer.name, error];
549
+ }),
550
+ );
551
+
552
+ for (const state of states) {
553
+ if (errors.has(state.consumer.name)) {
554
+ state.halted = true;
555
+ }
556
+ }
557
+
558
+ const cause = errors.size === 1 ? [...errors.values()][0] : error;
559
+
560
+ getLogger().critical({
561
+ category: 'Nimbus',
562
+ message: `Halted ${errors.size} consumer(s) of observer "${observer}" at event ${raw.id}. They stay at their checkpoint until the process restarts. ${failureSummary(
563
+ errors,
564
+ )}`,
565
+ error: cause ?? error,
566
+ data: {
567
+ observer,
568
+ eventId: raw.id,
569
+ eventType: raw.type,
570
+ consumers: describeFailures(errors),
571
+ },
572
+ });
573
+
574
+ await flush({ position: raw.id, errors });
575
+ };
576
+
577
+ const status = (): ConsumerStatus[] => {
578
+ return states.map((state) => {
579
+ return {
580
+ name: state.consumer.name,
581
+ position:
582
+ state.position === null ? null : state.position.toString(),
583
+ halted: state.halted,
584
+ ...(state.failure !== undefined
585
+ ? { failure: { ...state.failure } }
586
+ : {}),
587
+ };
588
+ });
589
+ };
590
+
591
+ return { lowerBound: lowerBoundFor(states), handle, onExhausted, status };
592
+ };
@@ -0,0 +1,95 @@
1
+ import type { CheckpointFailure, Event } from '@nimbus-cqrs/core';
2
+ import type { Event as EventSourcingDBEvent } from 'eventsourcingdb';
3
+
4
+ /**
5
+ * One stateful consumer of an observed event stream.
6
+ *
7
+ * A read-model projection, a policy, an event handler and a process manager
8
+ * all share this shape. Each consumer owns a checkpoint keyed by its
9
+ * {@link name}, so one of them can be rebuilt or halted without moving the
10
+ * others on the same observer.
11
+ *
12
+ * Consumers run sequentially in the order they are declared on the
13
+ * observer. A consumer that reads what another one writes should declare
14
+ * it in {@link dependsOn} instead of relying on the order alone.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import type { EventConsumer } from '@nimbus-cqrs/eventsourcingdb';
19
+ *
20
+ * export const ticketsProjection: EventConsumer = {
21
+ * name: 'ticketing.tickets',
22
+ * handles: (event) => event.type.startsWith('at.example.ticketing.ticket.'),
23
+ * handle: async (event) => {
24
+ * const ticket = ticketEventSchema.parse(event);
25
+ * await ticketsRepository.apply(ticket);
26
+ * },
27
+ * };
28
+ * ```
29
+ */
30
+ export type EventConsumer = {
31
+ /**
32
+ * The checkpoint key. It has to be unique across the whole application,
33
+ * not just within one observer, because checkpoint stores key on the
34
+ * consumer name alone. It also has to stay stable: renaming a consumer
35
+ * makes it replay the subject from the beginning.
36
+ */
37
+ readonly name: string;
38
+ /**
39
+ * Whether the consumer handles the given event. Events it does not
40
+ * handle still advance its checkpoint, so the position stays
41
+ * contiguous.
42
+ *
43
+ * @param event - The observed event, mapped to a Nimbus event.
44
+ */
45
+ readonly handles: (event: Event) => boolean;
46
+ /**
47
+ * Handles one event. A thrown error holds the consumer's checkpoint
48
+ * while its siblings advance; the observer retries in place and halts
49
+ * the consumer once the handler retries are exhausted.
50
+ *
51
+ * @param event - The observed event, mapped to a Nimbus event.
52
+ * @param raw - The original EventSourcingDB event.
53
+ */
54
+ readonly handle: (event: Event, raw: EventSourcingDBEvent) => Promise<void>;
55
+ /**
56
+ * Names of consumers on the same observer this one reads from. They
57
+ * have to be declared before this consumer. When a dependency fails on
58
+ * an event, this consumer holds its position as well, so it never
59
+ * checkpoints past data its dependency has not written yet. When a
60
+ * dependency is halted, this consumer is halted too.
61
+ *
62
+ * A consumer that starts from its checkpoint must not depend on one
63
+ * that starts from the head, since the latter never built state for
64
+ * older events.
65
+ */
66
+ readonly dependsOn?: readonly string[];
67
+ /**
68
+ * Where the consumer starts on every start of the observer.
69
+ *
70
+ * - `checkpoint` (default): resumes after its stored checkpoint, or
71
+ * from the beginning of the subject when it has none.
72
+ * - `head`: starts at the newest event of the subject, ignoring its
73
+ * stored checkpoint. Events written while the process was down are
74
+ * never delivered. Use this for live-only consumers such as pushing
75
+ * to connected clients or invalidating in-memory caches. The
76
+ * checkpoint is still persisted for visibility, but it has no effect
77
+ * on where the consumer resumes, so resetting it does nothing.
78
+ */
79
+ readonly startFrom?: 'checkpoint' | 'head';
80
+ };
81
+
82
+ /**
83
+ * The runtime state of one {@link EventConsumer}.
84
+ *
85
+ * @property {string} name - The consumer name.
86
+ * @property {string | null} position - The id of the last event the consumer finished, `null` before its first.
87
+ * @property {boolean} halted - Whether the consumer gave up for the rest of the process lifetime.
88
+ * @property {CheckpointFailure} [failure] - The most recent failure, from this run or loaded from the checkpoint store.
89
+ */
90
+ export type ConsumerStatus = {
91
+ name: string;
92
+ position: string | null;
93
+ halted: boolean;
94
+ failure?: CheckpointFailure;
95
+ };