@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,620 @@
1
+ import {
2
+ type CheckpointStore,
3
+ calculateBackoffDelay as coreCalculateBackoffDelay,
4
+ getLogger,
5
+ withRetry,
6
+ } from '@nimbus-cqrs/core';
7
+ import { type Span, SpanStatusCode } from '@opentelemetry/api';
8
+ import type { Event as EventSourcingDBEvent } from 'eventsourcingdb';
9
+ import { getEventSourcingDBClient } from './client.ts';
10
+ import {
11
+ type Bound,
12
+ type ConsumerRunner,
13
+ createConsumerRunner,
14
+ } from './consumerRunner.ts';
15
+ import type { ConsumerStatus, EventConsumer } from './eventConsumer.ts';
16
+ import { isEventData } from './eventMapping.ts';
17
+ import { registerEventObserver } from './observerRegistry.ts';
18
+ import { readEvents } from './readEvents.ts';
19
+ import {
20
+ observerConnectionReconnectsCounter,
21
+ observerConnectionRetryAttemptsCounter,
22
+ observerEventsHandledCounter,
23
+ observerHandlerRetryAttemptsCounter,
24
+ observerHandlingDuration,
25
+ type TraceContext,
26
+ withObserveEventSpan,
27
+ } from './tracing.ts';
28
+
29
+ export type RetryOptions = {
30
+ /**
31
+ * The maximum number of retries after the initial attempt
32
+ * before giving up. Defaults to 3 (4 failed attempts total).
33
+ */
34
+ maxRetries: number;
35
+ /**
36
+ * The initial delay in milliseconds before the first retry.
37
+ * Subsequent retries will use exponential backoff with jitter.
38
+ * Defaults to 3000ms.
39
+ */
40
+ initialRetryDelayMs: number;
41
+ };
42
+
43
+ const DEFAULT_RETRY_OPTIONS: RetryOptions = {
44
+ maxRetries: 3,
45
+ initialRetryDelayMs: 3000,
46
+ };
47
+
48
+ /**
49
+ * An event observer watches one EventSourcingDB subject and feeds every
50
+ * event to its {@link EventConsumer}s. The consumers share the stream, each
51
+ * keeps its own checkpoint in the {@link checkpointStore}.
52
+ *
53
+ * On startup the observer resumes from the lowest checkpoint of its
54
+ * consumers, replays what they miss and then stays live for new events.
55
+ *
56
+ * See https://docs.eventsourcingdb.io/getting-started/observing-events for more information.
57
+ */
58
+ export type EventObserver = {
59
+ /**
60
+ * The unique name of the observer. It is stored on every checkpoint
61
+ * and used to look the observer up with {@link getEventObserver}.
62
+ */
63
+ name: string;
64
+ /**
65
+ * The subject of the events to observe.
66
+ */
67
+ subject: string;
68
+ /**
69
+ * Whether to observe events recursively.
70
+ * Defaults to false.
71
+ */
72
+ recursive?: boolean;
73
+ /**
74
+ * The consumers of the observed events, run in declaration order.
75
+ */
76
+ consumers: readonly EventConsumer[];
77
+ /**
78
+ * Where the consumers' checkpoints are persisted.
79
+ */
80
+ checkpointStore: CheckpointStore;
81
+ /**
82
+ * Options for retry behavior when the observe stream / connection fails.
83
+ * This includes a stream that the server closes (e.g. during a restart)
84
+ * and a stalled connection. Uses exponential backoff with jitter
85
+ * between reconnects.
86
+ * Defaults to { maxRetries: 3, initialRetryDelayMs: 3000 }.
87
+ */
88
+ connectionRetryOptions?: RetryOptions;
89
+ /**
90
+ * Options for retry behavior when a consumer throws. Handler retries
91
+ * happen in-place without reconnecting the stream. Once they are
92
+ * exhausted the failing consumers are halted.
93
+ * Defaults to { maxRetries: 3, initialRetryDelayMs: 3000 }.
94
+ */
95
+ handlerRetryOptions?: RetryOptions;
96
+ };
97
+
98
+ /**
99
+ * Where an observer currently is in the stream.
100
+ *
101
+ * - `catching-up`: replaying events that were already stored when the
102
+ * observer started.
103
+ * - `live`: every event stored at startup has been processed; the
104
+ * observer now receives new events as they are written.
105
+ * - `stopped`: connection retries are exhausted and the observer gave up.
106
+ */
107
+ export type ObserverPhase = 'catching-up' | 'live' | 'stopped';
108
+
109
+ /**
110
+ * The current status of an observer and its consumers.
111
+ *
112
+ * @property {string} name - The observer name.
113
+ * @property {string} subject - The observed subject.
114
+ * @property {ObserverPhase} phase - Where the observer is in the stream.
115
+ * @property {ConsumerStatus[]} consumers - The state of every consumer in declaration order.
116
+ */
117
+ export type ObserverStatus = {
118
+ name: string;
119
+ subject: string;
120
+ phase: ObserverPhase;
121
+ consumers: ConsumerStatus[];
122
+ };
123
+
124
+ /**
125
+ * The result of {@link EventObserverHandle.healthCheck}.
126
+ */
127
+ export type ObserverHealth = {
128
+ status: 'healthy' | 'error';
129
+ details?: string;
130
+ };
131
+
132
+ /**
133
+ * The handle returned by {@link initEventObserver} to inspect a running
134
+ * observer.
135
+ */
136
+ export type EventObserverHandle = {
137
+ /**
138
+ * The observer name.
139
+ */
140
+ readonly name: string;
141
+ /**
142
+ * The observed subject.
143
+ */
144
+ readonly subject: string;
145
+ /**
146
+ * A snapshot of the observer and its consumers.
147
+ */
148
+ readonly status: () => ObserverStatus;
149
+ /**
150
+ * `error` when the observer stopped or any consumer is halted,
151
+ * `healthy` otherwise. A consumer that is still catching up is
152
+ * healthy.
153
+ */
154
+ readonly healthCheck: () => ObserverHealth;
155
+ };
156
+
157
+ /**
158
+ * Returns a promise that resolves after the given number of milliseconds.
159
+ */
160
+ const delay = (ms: number): Promise<void> => {
161
+ return new Promise((resolve) => setTimeout(resolve, ms));
162
+ };
163
+
164
+ const OBSERVER_BACKOFF_OPTIONS = {
165
+ jitterFactor: 0.3,
166
+ maxDelayMs: Infinity,
167
+ } as const;
168
+
169
+ /**
170
+ * Calculates an exponential backoff delay with jitter for a given
171
+ * retry attempt. The jitter adds up to 30% of the base delay to
172
+ * avoid thundering-herd effects.
173
+ *
174
+ * @param initialDelayMs - The base delay in milliseconds before
175
+ * exponential scaling.
176
+ * @param attempt - The zero-based retry attempt number.
177
+ * @returns The backoff delay in milliseconds.
178
+ *
179
+ * @deprecated Import {@link calculateBackoffDelay} from
180
+ * `@nimbus-cqrs/core` instead. This wrapper keeps the historical
181
+ * 30% jitter default used by EventSourcingDB observers.
182
+ */
183
+ export const calculateBackoffDelay = (
184
+ initialDelayMs: number,
185
+ attempt: number,
186
+ ): number =>
187
+ coreCalculateBackoffDelay(
188
+ initialDelayMs,
189
+ attempt,
190
+ OBSERVER_BACKOFF_OPTIONS,
191
+ );
192
+
193
+ const resolveRetryOptions = (options?: RetryOptions): RetryOptions => ({
194
+ maxRetries: options?.maxRetries ?? DEFAULT_RETRY_OPTIONS.maxRetries,
195
+ initialRetryDelayMs:
196
+ options?.initialRetryDelayMs ??
197
+ DEFAULT_RETRY_OPTIONS.initialRetryDelayMs,
198
+ });
199
+
200
+ /**
201
+ * Logs an informational message when an event observer connects or
202
+ * reconnects to EventSourcingDB. When {@link retryCount} is greater
203
+ * than zero the message indicates a successful reconnection.
204
+ */
205
+ const logObserverConnection = (
206
+ observer: EventObserver,
207
+ retryCount: number,
208
+ data: Record<string, unknown>,
209
+ ): void => {
210
+ const retryLabel = retryCount === 1 ? 'retry' : 'retries';
211
+ const message =
212
+ retryCount > 0
213
+ ? `Reconnected event observer "${observer.name}" for subject "${observer.subject}" after ${retryCount} ${retryLabel}`
214
+ : `Observer "${observer.name}" observing events for subject "${observer.subject}"`;
215
+
216
+ getLogger().info({ category: 'Nimbus', message, data });
217
+ };
218
+
219
+ /**
220
+ * Handles a connection / stream error by logging it and waiting with
221
+ * exponential backoff before the next reconnect attempt. When the
222
+ * maximum number of retries is exceeded a critical log entry is
223
+ * emitted and no further retries are attempted.
224
+ *
225
+ * @returns `true` if the observer should retry, `false` if retries
226
+ * are exhausted.
227
+ */
228
+ const handleConnectionError = async (
229
+ error: unknown,
230
+ observer: EventObserver,
231
+ retryCount: number,
232
+ maxRetries: number,
233
+ initialRetryDelayMs: number,
234
+ ): Promise<boolean> => {
235
+ if (retryCount > maxRetries) {
236
+ getLogger().critical({
237
+ category: 'Nimbus',
238
+ message: `Observer "${observer.name}" failed to observe events for subject "${observer.subject}" after ${maxRetries} ${
239
+ maxRetries === 1 ? 'retry' : 'retries'
240
+ }.`,
241
+ });
242
+ return false;
243
+ }
244
+
245
+ observerConnectionRetryAttemptsCounter.add(1, {
246
+ subject: observer.subject,
247
+ });
248
+
249
+ const backoffDelay = coreCalculateBackoffDelay(
250
+ initialRetryDelayMs,
251
+ retryCount - 1,
252
+ OBSERVER_BACKOFF_OPTIONS,
253
+ );
254
+
255
+ getLogger().error({
256
+ category: 'Nimbus',
257
+ message: `Error observing events for subject "${observer.subject}" (retry ${retryCount}/${maxRetries}), retrying in ${backoffDelay}ms`,
258
+ error: error as Error,
259
+ });
260
+
261
+ await delay(backoffDelay);
262
+ return true;
263
+ };
264
+
265
+ const recordHandlerMetrics = (
266
+ metricLabels: { subject: string; event_type: string },
267
+ status: 'success' | 'skipped',
268
+ startTime: number,
269
+ ): void => {
270
+ observerEventsHandledCounter.add(1, { ...metricLabels, status });
271
+ observerHandlingDuration.record(
272
+ (performance.now() - startTime) / 1000,
273
+ metricLabels,
274
+ );
275
+ };
276
+
277
+ const correlationIdOf = (
278
+ event: EventSourcingDBEvent,
279
+ ): { correlationId: string } | Record<never, never> => {
280
+ return isEventData(event.data)
281
+ ? { correlationId: event.data.nimbusMeta.correlationid }
282
+ : {};
283
+ };
284
+
285
+ /**
286
+ * Offers the event to the consumers with in-place retries. Does not
287
+ * reconnect the observe stream. When all retries are exhausted, halts the
288
+ * failing consumers and returns without throwing so the observer can
289
+ * move on.
290
+ */
291
+ const handleEventWithRetry = async (
292
+ observer: EventObserver,
293
+ runner: ConsumerRunner,
294
+ event: EventSourcingDBEvent,
295
+ handlerRetryOptions: RetryOptions,
296
+ span: Span,
297
+ startTime: number,
298
+ ): Promise<void> => {
299
+ const { maxRetries, initialRetryDelayMs } = handlerRetryOptions;
300
+ const metricLabels = {
301
+ subject: observer.subject,
302
+ event_type: event.type,
303
+ };
304
+
305
+ try {
306
+ await withRetry(() => runner.handle(event), {
307
+ maxRetries,
308
+ initialDelayMs: initialRetryDelayMs,
309
+ maxDelayMs: Infinity,
310
+ jitterFactor: 0.3,
311
+ onRetry: ({ error, attempt, delayMs }) => {
312
+ observerHandlerRetryAttemptsCounter.add(1, metricLabels);
313
+ span.addEvent('retry', {
314
+ attempt,
315
+ delay_ms: delayMs,
316
+ });
317
+
318
+ getLogger().error({
319
+ category: 'Nimbus',
320
+ message: `Error handling event "${event.id}" (${event.type}) on observer "${observer.name}" (retry ${attempt}/${maxRetries}), retrying in ${delayMs}ms`,
321
+ error,
322
+ ...correlationIdOf(event),
323
+ });
324
+ },
325
+ });
326
+
327
+ recordHandlerMetrics(metricLabels, 'success', startTime);
328
+ } catch (error: unknown) {
329
+ const handlerError =
330
+ error instanceof Error ? error : new Error(String(error));
331
+
332
+ recordHandlerMetrics(metricLabels, 'skipped', startTime);
333
+ span.setStatus({
334
+ code: SpanStatusCode.ERROR,
335
+ message: handlerError.message,
336
+ });
337
+ span.recordException(handlerError);
338
+
339
+ try {
340
+ await runner.onExhausted(handlerError, event);
341
+ } catch (err: unknown) {
342
+ // The consumers are halted in memory either way. The failure
343
+ // marker is written with the next successful checkpoint save.
344
+ getLogger().critical({
345
+ category: 'Nimbus',
346
+ message: `Could not persist the failure of observer "${observer.name}" at event "${event.id}" (${event.type}).`,
347
+ error: err instanceof Error ? err : new Error(String(err)),
348
+ ...correlationIdOf(event),
349
+ });
350
+ }
351
+ }
352
+ };
353
+
354
+ /**
355
+ * The id of the newest event on the subject, or `null` when the subject
356
+ * has no events yet. Used to tell catch-up from live.
357
+ */
358
+ const readHeadEventId = async (
359
+ observer: EventObserver,
360
+ ): Promise<string | null> => {
361
+ const controller = new AbortController();
362
+ // Antichronological, so the first event is the newest. Read only that one.
363
+ const iterator = readEvents(
364
+ observer.subject,
365
+ {
366
+ recursive: observer.recursive ?? false,
367
+ order: 'antichronological',
368
+ },
369
+ controller.signal,
370
+ )[Symbol.asyncIterator]();
371
+
372
+ let head: string | null = null;
373
+ let failure: unknown;
374
+
375
+ try {
376
+ const first = await iterator.next();
377
+
378
+ if (!first.done) {
379
+ head = first.value.id;
380
+ controller.abort();
381
+ }
382
+ } catch (error) {
383
+ failure = error;
384
+ }
385
+
386
+ try {
387
+ await iterator.return();
388
+ } catch (error) {
389
+ if (!controller.signal.aborted) {
390
+ failure = error;
391
+ }
392
+ }
393
+
394
+ if (failure !== undefined && !controller.signal.aborted) {
395
+ throw failure;
396
+ }
397
+
398
+ return head;
399
+ };
400
+
401
+ type ObserverRuntime = {
402
+ runner: ConsumerRunner;
403
+ phase: ObserverPhase;
404
+ /** The newest event id at startup. Set to live once it has been processed. */
405
+ head: bigint | null;
406
+ };
407
+
408
+ /**
409
+ * Observes the subject with automatic reconnection on stream failure and
410
+ * in-place retries for consumer failures. Resolves once connection retries
411
+ * are exhausted.
412
+ */
413
+ const observeWithRetry = async (
414
+ observer: EventObserver,
415
+ runtime: ObserverRuntime,
416
+ ): Promise<void> => {
417
+ const connectionRetryOptions = resolveRetryOptions(
418
+ observer.connectionRetryOptions,
419
+ );
420
+ const handlerRetryOptions = resolveRetryOptions(
421
+ observer.handlerRetryOptions,
422
+ );
423
+
424
+ let connectionRetryCount = 0;
425
+ let lastProcessedEventId: string | undefined;
426
+
427
+ while (true) {
428
+ try {
429
+ // Resolve the client on every attempt so a re-initialized
430
+ // singleton is picked up after reconnecting to EventSourcingDB.
431
+ const eventSourcingDBClient = getEventSourcingDBClient();
432
+
433
+ const lowerBound: Bound = lastProcessedEventId
434
+ ? { id: lastProcessedEventId, type: 'exclusive' }
435
+ : runtime.runner.lowerBound;
436
+
437
+ logObserverConnection(observer, connectionRetryCount, {
438
+ observer: observer.name,
439
+ recursive: observer.recursive ?? false,
440
+ lowerBound,
441
+ });
442
+
443
+ for await (const event of eventSourcingDBClient.observeEvents(
444
+ observer.subject,
445
+ {
446
+ recursive: observer.recursive ?? false,
447
+ lowerBound,
448
+ },
449
+ )) {
450
+ // Stream is healthy once events flow again. Count a successful
451
+ // reconnect only here so failed reconnect attempts are excluded.
452
+ if (connectionRetryCount > 0) {
453
+ observerConnectionReconnectsCounter.add(1, {
454
+ subject: observer.subject,
455
+ });
456
+ }
457
+ connectionRetryCount = 0;
458
+
459
+ const traceContext: TraceContext | undefined = event.traceparent
460
+ ? {
461
+ traceparent: event.traceparent,
462
+ tracestate: event.tracestate,
463
+ }
464
+ : undefined;
465
+
466
+ const startTime = performance.now();
467
+
468
+ await withObserveEventSpan(
469
+ event,
470
+ observer.subject,
471
+ traceContext,
472
+ async (span) => {
473
+ await handleEventWithRetry(
474
+ observer,
475
+ runtime.runner,
476
+ event,
477
+ handlerRetryOptions,
478
+ span,
479
+ startTime,
480
+ );
481
+ },
482
+ );
483
+
484
+ // Advance after success or halt so a halted consumer does not
485
+ // block the stream and reconnects resume past the last event.
486
+ lastProcessedEventId = event.id;
487
+
488
+ if (
489
+ runtime.phase === 'catching-up' &&
490
+ (runtime.head === null || BigInt(event.id) >= runtime.head)
491
+ ) {
492
+ runtime.phase = 'live';
493
+ }
494
+ }
495
+
496
+ // Observing never ends on its own: the SDK throws when the
497
+ // server closes the stream, so this is only reached if the
498
+ // generator finishes without an error (e.g. an aborted
499
+ // stream). Treat it as the end of observation.
500
+ runtime.phase = 'stopped';
501
+ return;
502
+ } catch (error) {
503
+ connectionRetryCount++;
504
+
505
+ const shouldRetry = await handleConnectionError(
506
+ error,
507
+ observer,
508
+ connectionRetryCount,
509
+ connectionRetryOptions.maxRetries,
510
+ connectionRetryOptions.initialRetryDelayMs,
511
+ );
512
+
513
+ if (!shouldRetry) {
514
+ runtime.phase = 'stopped';
515
+ return;
516
+ }
517
+ }
518
+ }
519
+ };
520
+
521
+ const createHandle = (
522
+ observer: EventObserver,
523
+ runtime: ObserverRuntime,
524
+ ): EventObserverHandle => {
525
+ const status = (): ObserverStatus => {
526
+ return {
527
+ name: observer.name,
528
+ subject: observer.subject,
529
+ phase: runtime.phase,
530
+ consumers: runtime.runner.status(),
531
+ };
532
+ };
533
+
534
+ const healthCheck = (): ObserverHealth => {
535
+ const current = status();
536
+ const halted = current.consumers
537
+ .filter((consumer) => {
538
+ return consumer.halted;
539
+ })
540
+ .map((consumer) => {
541
+ return consumer.name;
542
+ });
543
+
544
+ if (current.phase === 'stopped') {
545
+ return {
546
+ status: 'error',
547
+ details: `Observer "${observer.name}" stopped after exhausting its connection retries`,
548
+ };
549
+ }
550
+
551
+ if (halted.length > 0) {
552
+ return {
553
+ status: 'error',
554
+ details: `Halted consumer(s): ${halted.join(', ')}`,
555
+ };
556
+ }
557
+
558
+ return { status: 'healthy' };
559
+ };
560
+
561
+ return {
562
+ name: observer.name,
563
+ subject: observer.subject,
564
+ status,
565
+ healthCheck,
566
+ };
567
+ };
568
+
569
+ /**
570
+ * Starts an event observer.
571
+ *
572
+ * Reads the newest event of the subject to know when the catch-up is over
573
+ * and where consumers with `startFrom: 'head'` begin, loads the consumers'
574
+ * checkpoints, registers the observer under its name and starts the
575
+ * observation loop in the background. The returned
576
+ * promise resolves once the loop has been started, not once the observer
577
+ * has caught up, so a checkpoint store that cannot be reached fails the
578
+ * application start instead of silently replaying everything.
579
+ *
580
+ * The loop keeps running and reconnecting according to the connection
581
+ * retry options until those are exhausted. Consumer failures are retried
582
+ * in place. Once handler retries are exhausted the failing consumers are
583
+ * halted and the observer moves on.
584
+ *
585
+ * @param observer - The event observer configuration.
586
+ * @returns A handle to inspect the running observer.
587
+ *
588
+ * @throws {GenericException} If an observer with the same name is already
589
+ * registered, or the consumers are declared inconsistently.
590
+ */
591
+ export const initEventObserver = async (
592
+ observer: EventObserver,
593
+ ): Promise<EventObserverHandle> => {
594
+ const headEventId = await readHeadEventId(observer);
595
+ const head = headEventId === null ? null : BigInt(headEventId);
596
+ const runner = await createConsumerRunner({
597
+ observer: observer.name,
598
+ consumers: observer.consumers,
599
+ checkpointStore: observer.checkpointStore,
600
+ head: headEventId,
601
+ });
602
+
603
+ const alreadyLive =
604
+ head === null ||
605
+ (runner.lowerBound.type === 'exclusive' &&
606
+ BigInt(runner.lowerBound.id) >= head);
607
+
608
+ const runtime: ObserverRuntime = {
609
+ runner,
610
+ phase: alreadyLive ? 'live' : 'catching-up',
611
+ head,
612
+ };
613
+
614
+ const handle = createHandle(observer, runtime);
615
+ registerEventObserver(handle);
616
+
617
+ void observeWithRetry(observer, runtime);
618
+
619
+ return handle;
620
+ };
@@ -0,0 +1,69 @@
1
+ import { GenericException } from '@nimbus-cqrs/core';
2
+ import type { EventObserverHandle } from './eventObserver.ts';
3
+
4
+ /**
5
+ * Registry of the observers started with {@link initEventObserver}.
6
+ */
7
+ const eventObserverRegistry = new Map<string, EventObserverHandle>();
8
+
9
+ /**
10
+ * Registers a started observer under its name.
11
+ *
12
+ * @param handle - The observer handle to register.
13
+ *
14
+ * @throws {GenericException} If an observer with the same name is already registered.
15
+ */
16
+ export const registerEventObserver = (handle: EventObserverHandle): void => {
17
+ if (eventObserverRegistry.has(handle.name)) {
18
+ throw new GenericException(
19
+ `An event observer named "${handle.name}" is already running`,
20
+ { observer: handle.name },
21
+ );
22
+ }
23
+
24
+ eventObserverRegistry.set(handle.name, handle);
25
+ };
26
+
27
+ /**
28
+ * Get a running event observer by name.
29
+ *
30
+ * @param name - The name the observer was started with.
31
+ * @returns The handle to inspect the observer.
32
+ *
33
+ * @throws {GenericException} If no observer with that name has been started.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * import { getEventObserver } from '@nimbus-cqrs/eventsourcingdb';
38
+ *
39
+ * const { phase } = getEventObserver('ticketing').status();
40
+ * ```
41
+ */
42
+ export const getEventObserver = (name: string): EventObserverHandle => {
43
+ const handle = eventObserverRegistry.get(name);
44
+
45
+ if (handle === undefined) {
46
+ throw new GenericException(
47
+ `Event observer "${name}" has not been started. Pass it to setupEventSourcingDBClient({ eventObservers }) or initEventObserver() first.`,
48
+ { observer: name },
49
+ );
50
+ }
51
+
52
+ return handle;
53
+ };
54
+
55
+ /**
56
+ * All running event observers, in the order they were started.
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * import { listEventObservers } from '@nimbus-cqrs/eventsourcingdb';
61
+ *
62
+ * const unhealthy = listEventObservers()
63
+ * .map((observer) => observer.healthCheck())
64
+ * .filter((health) => health.status !== 'healthy');
65
+ * ```
66
+ */
67
+ export const listEventObservers = (): EventObserverHandle[] => {
68
+ return [...eventObserverRegistry.values()];
69
+ };