@fbsm/saga-core 0.1.0-beta.1 → 0.1.0-beta.3

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.
package/README.md CHANGED
@@ -10,20 +10,20 @@ npm install @fbsm/saga-core
10
10
 
11
11
  ## Overview
12
12
 
13
- | Export | Description |
14
- |--------|-------------|
15
- | `SagaPublisher` | Creates sagas and publishes events |
16
- | `SagaRunner` | Consumes events, dispatches to handlers with retry logic |
17
- | `SagaRegistry` | Stores participant and handler mappings |
18
- | `SagaParser` | Parses inbound messages (3-layer fallback) |
19
- | `SagaContext` | AsyncLocalStorage-based context propagation |
13
+ | Export | Description |
14
+ | --------------- | -------------------------------------------------------- |
15
+ | `SagaPublisher` | Creates sagas and publishes events |
16
+ | `SagaRunner` | Consumes events, dispatches to handlers with retry logic |
17
+ | `SagaRegistry` | Stores participant and handler mappings |
18
+ | `SagaParser` | Parses inbound messages (3-layer fallback) |
19
+ | `SagaContext` | AsyncLocalStorage-based context propagation |
20
20
 
21
21
  ## SagaContext
22
22
 
23
23
  Uses `AsyncLocalStorage` to propagate saga metadata through async call chains.
24
24
 
25
25
  ```typescript
26
- import { SagaContext } from '@fbsm/saga-core';
26
+ import { SagaContext } from "@fbsm/saga-core";
27
27
 
28
28
  // Read current context (or undefined)
29
29
  const ctx = SagaContext.current();
@@ -34,6 +34,7 @@ const ctx = SagaContext.require();
34
34
  ```
35
35
 
36
36
  **`SagaContextData`**:
37
+
37
38
  ```typescript
38
39
  interface SagaContextData {
39
40
  sagaId: string;
@@ -47,6 +48,7 @@ interface SagaContextData {
47
48
  ```
48
49
 
49
50
  Context is set automatically by:
51
+
50
52
  1. **`SagaRunner`** — wraps every handler execution with `SagaContext.run()`
51
53
  2. **`SagaPublisher.start(fn)` / `startChild(fn)` / `emitToParent(fn)`** — wraps callbacks with `SagaContext.run()`
52
54
 
@@ -55,20 +57,21 @@ Context is set automatically by:
55
57
  Creates sagas and publishes events. See [Core Functions](../doc/core-functions.md) for detailed usage of each method.
56
58
 
57
59
  ```typescript
58
- import { SagaPublisher } from '@fbsm/saga-core';
60
+ import { SagaPublisher } from "@fbsm/saga-core";
59
61
 
60
62
  const publisher = new SagaPublisher(transport, otelContext, topicPrefix);
61
63
  ```
62
64
 
63
- | Method | Description |
64
- |--------|-------------|
65
- | `start(fn, opts?)` | Create a root saga with ALS context |
66
- | `startChild(fn, opts?)` | Create a child saga linked to current |
67
- | `emit(params)` | Publish event in current context |
68
- | `emitToParent(params \| fn)` | Emit to parent saga |
69
- | `forSaga(sagaId, parentCtx?, causationId?, key?)` | Get bound `Emit` function (no ALS) |
65
+ | Method | Description |
66
+ | ------------------------------------------------- | ------------------------------------- |
67
+ | `start(fn, opts?)` | Create a root saga with ALS context |
68
+ | `startChild(fn, opts?)` | Create a child saga linked to current |
69
+ | `emit(params)` | Publish event in current context |
70
+ | `emitToParent(params \| fn)` | Emit to parent saga |
71
+ | `forSaga(sagaId, parentCtx?, causationId?, key?)` | Get bound `Emit` function (no ALS) |
70
72
 
71
73
  **`SagaStartOptions`**:
74
+
72
75
  ```typescript
73
76
  interface SagaStartOptions {
74
77
  sagaName?: string;
@@ -82,36 +85,42 @@ interface SagaStartOptions {
82
85
  Consumes events from transport, routes to handlers, and applies retry logic.
83
86
 
84
87
  ```typescript
85
- import { SagaRunner } from '@fbsm/saga-core';
88
+ import { SagaRunner } from "@fbsm/saga-core";
86
89
 
87
90
  const runner = new SagaRunner(
88
- registry, // SagaRegistry
89
- transport, // SagaTransport
90
- publisher, // SagaPublisher
91
- parser, // SagaParser
92
- options, // RunnerOptions
93
- otelContext, // OtelContext (optional)
94
- logger, // SagaLogger (optional)
91
+ registry, // SagaRegistry
92
+ transport, // SagaTransport
93
+ publisher, // SagaPublisher
94
+ parser, // SagaParser
95
+ options, // RunnerOptions
96
+ otelContext, // OtelContext (optional)
97
+ logger, // SagaLogger (optional)
95
98
  );
96
99
 
97
100
  await runner.start(); // Subscribe and begin consuming
98
- await runner.stop(); // Disconnect
101
+ await runner.stop(); // Disconnect
102
+
103
+ // Health check (delegates to transport if it implements HealthCheckable)
104
+ const health = await runner.healthCheck();
105
+ // { status: 'up' | 'down', details?: { consumerGroupState, groupId, memberCount } }
99
106
  ```
100
107
 
101
108
  **`RunnerOptions`**:
109
+
102
110
  ```typescript
103
111
  interface RunnerOptions {
104
- serviceName: string;
112
+ groupId: string;
105
113
  fromBeginning?: boolean;
106
114
  topicPrefix?: string;
107
115
  retryPolicy?: {
108
- maxRetries?: number; // default: 3
109
- initialDelayMs?: number; // default: 200
116
+ maxRetries?: number; // default: 3
117
+ initialDelayMs?: number; // default: 200
110
118
  };
111
119
  }
112
120
  ```
113
121
 
114
122
  **Handler execution flow**:
123
+
115
124
  1. Parse inbound message via `SagaParser`
116
125
  2. Look up handler in route map
117
126
  3. Wrap emit with `final` hint (if `{ final: true }`)
@@ -132,51 +141,56 @@ Parses inbound messages using a 3-layer fallback strategy:
132
141
 
133
142
  When using the header-based format (default with `@fbsm/saga-transport-kafka`):
134
143
 
135
- | Header | Description |
136
- |--------|-------------|
137
- | `saga-id` | Saga instance ID |
138
- | `saga-event-id` | Unique event ID |
139
- | `saga-causation-id` | ID of the event that caused this one |
140
- | `saga-step-name` | Logical step name |
141
- | `saga-published-at` | ISO timestamp of publication |
142
- | `saga-schema-version` | Schema version (currently `1`) |
143
- | `saga-root-id` | Root saga ID (top-level ancestor) |
144
- | `saga-parent-id` | Parent saga ID (for sub-sagas, optional) |
145
- | `saga-event-hint` | Event hint: `compensation`, `final`, `fork` (optional) |
146
- | `saga-name` | Saga name (optional) |
147
- | `saga-description` | Saga description (optional) |
148
- | `saga-step-description` | Step description (optional) |
149
- | `saga-key` | Partition key (optional) |
144
+ | Header | Description |
145
+ | ----------------------- | ------------------------------------------------------ |
146
+ | `saga-id` | Saga instance ID |
147
+ | `saga-event-id` | Unique event ID |
148
+ | `saga-causation-id` | ID of the event that caused this one |
149
+ | `saga-step-name` | Logical step name |
150
+ | `saga-published-at` | ISO timestamp of publication |
151
+ | `saga-schema-version` | Schema version (currently `1`) |
152
+ | `saga-root-id` | Root saga ID (top-level ancestor) |
153
+ | `saga-parent-id` | Parent saga ID (for sub-sagas, optional) |
154
+ | `saga-event-hint` | Event hint: `compensation`, `final`, `fork` (optional) |
155
+ | `saga-name` | Saga name (optional) |
156
+ | `saga-description` | Saga description (optional) |
157
+ | `saga-step-description` | Step description (optional) |
158
+ | `saga-key` | Partition key (optional) |
150
159
 
151
160
  ## Errors
152
161
 
153
- | Error | Description |
154
- |-------|-------------|
155
- | `SagaError` | Base error class for all saga errors |
156
- | `SagaRetryableError` | Throw in handlers to trigger retry with exponential backoff. `new SagaRetryableError(message, maxRetries?)` |
157
- | `SagaDuplicateHandlerError` | Two handlers registered for the same event type |
158
- | `SagaParseError` | Message parsing failed |
159
- | `SagaTransportNotConnectedError` | Publishing to a disconnected transport |
160
- | `SagaContextNotFoundError` | `emit()`/`startChild()`/`emitToParent()` called outside a saga context |
161
- | `SagaNoParentError` | `emitToParent()` called in a saga without `parentSagaId` |
162
- | `SagaInvalidHandlerConfigError` | Handler has conflicting options (e.g., both `final` and `fork`) |
162
+ | Error | Description |
163
+ | -------------------------------- | ----------------------------------------------------------------------------------------------------------- |
164
+ | `SagaError` | Base error class for all saga errors |
165
+ | `SagaRetryableError` | Throw in handlers to trigger retry with exponential backoff. `new SagaRetryableError(message, maxRetries?)` |
166
+ | `SagaDuplicateHandlerError` | Two handlers registered for the same event type |
167
+ | `SagaParseError` | Message parsing failed |
168
+ | `SagaTransportNotConnectedError` | Publishing to a disconnected transport |
169
+ | `SagaContextNotFoundError` | `emit()`/`startChild()`/`emitToParent()` called outside a saga context |
170
+ | `SagaNoParentError` | `emitToParent()` called in a saga without `parentSagaId` |
171
+ | `SagaInvalidHandlerConfigError` | Handler has conflicting options (e.g., both `final` and `fork`) |
163
172
 
164
173
  **Retry behavior**: `SagaRetryableError` triggers exponential backoff: `initialDelayMs * 2^attempt`. After `maxRetries` attempts, `onRetryExhausted()` is called if defined. Non-retryable errors are logged and skipped.
165
174
 
166
175
  ## OTel Integration
167
176
 
168
177
  ```typescript
169
- import { createOtelContext, W3cOtelContext, NoopOtelContext } from '@fbsm/saga-core';
178
+ import {
179
+ createOtelContext,
180
+ W3cOtelContext,
181
+ NoopOtelContext,
182
+ } from "@fbsm/saga-core";
170
183
 
171
184
  // Auto-detect: uses W3cOtelContext if @opentelemetry/api is available, NoopOtelContext otherwise
172
185
  const otelCtx = createOtelContext();
173
186
 
174
187
  // Or explicitly:
175
- const otelCtx = new W3cOtelContext(); // requires @opentelemetry/api
176
- const otelCtx = new NoopOtelContext(); // no-op (no tracing)
188
+ const otelCtx = new W3cOtelContext(); // requires @opentelemetry/api
189
+ const otelCtx = new NoopOtelContext(); // no-op (no tracing)
177
190
  ```
178
191
 
179
192
  The OTel context:
193
+
180
194
  - Injects W3C baggage with saga context into outgoing messages
181
195
  - Extracts trace context from incoming message headers
182
196
  - Creates spans for publish and handle operations
@@ -199,6 +213,27 @@ interface SagaTransport {
199
213
  }
200
214
  ```
201
215
 
216
+ ## Health Checks
217
+
218
+ Transports can optionally implement the `HealthCheckable` interface to support health checks.
219
+
220
+ ```typescript
221
+ import { isHealthCheckable } from "@fbsm/saga-core";
222
+ import type { HealthCheckable, TransportHealthResult } from "@fbsm/saga-core";
223
+
224
+ // Check if a transport supports health checks
225
+ if (isHealthCheckable(transport)) {
226
+ const result: TransportHealthResult = await transport.healthCheck();
227
+ // result.status: 'up' | 'down'
228
+ // result.details: transport-specific details
229
+ }
230
+
231
+ // Or use SagaRunner.healthCheck() which delegates automatically
232
+ const health = await runner.healthCheck();
233
+ ```
234
+
235
+ `KafkaTransport` from `@fbsm/saga-transport-kafka` implements `HealthCheckable` using `consumer.describeGroup()`. Healthy states: `Stable`, `CompletingRebalance`, `PreparingRebalance`.
236
+
202
237
  ## SagaLogger
203
238
 
204
239
  ```typescript
package/dist/index.cjs CHANGED
@@ -36,10 +36,16 @@ __export(index_exports, {
36
36
  SagaRunner: () => SagaRunner,
37
37
  SagaTransportNotConnectedError: () => SagaTransportNotConnectedError,
38
38
  W3cOtelContext: () => W3cOtelContext,
39
- createOtelContext: () => createOtelContext
39
+ createOtelContext: () => createOtelContext,
40
+ isHealthCheckable: () => isHealthCheckable
40
41
  });
41
42
  module.exports = __toCommonJS(index_exports);
42
43
 
44
+ // src/transport/transport.interface.ts
45
+ function isHealthCheckable(transport) {
46
+ return typeof transport.healthCheck === "function";
47
+ }
48
+
43
49
  // src/errors/saga.error.ts
44
50
  var SagaError = class extends Error {
45
51
  isSagaError = true;
@@ -118,26 +124,41 @@ var SagaRunner = class {
118
124
  async start() {
119
125
  this.routeMap = this.registry.buildRouteMap();
120
126
  const prefix = this.options.topicPrefix ?? "";
121
- const topics = Array.from(this.routeMap.keys()).map((et) => `${prefix}${et}`);
127
+ const topics = Array.from(this.routeMap.keys()).map(
128
+ (et) => `${prefix}${et}`
129
+ );
122
130
  await this.transport.connect();
123
131
  if (topics.length > 0) {
124
- this.logger.info(`[SagaRunner] Subscribing to ${topics.length} topic(s): [${topics.join(", ")}]`);
132
+ this.logger.info(
133
+ `[SagaRunner] Subscribing to ${topics.length} topic(s): [${topics.join(", ")}]`
134
+ );
125
135
  await this.transport.subscribe(
126
136
  topics,
127
137
  (message) => this.handleMessage(message),
128
138
  {
129
139
  fromBeginning: this.options.fromBeginning,
130
- groupId: `${this.options.serviceName}-group`
140
+ groupId: this.options.groupId
131
141
  }
132
142
  );
133
143
  this.logger.info("[SagaRunner] Consumer running");
134
144
  } else {
135
- this.logger.warn("[SagaRunner] No handlers registered \u2014 nothing to subscribe");
145
+ this.logger.warn(
146
+ "[SagaRunner] No handlers registered \u2014 nothing to subscribe"
147
+ );
136
148
  }
137
149
  }
138
150
  async stop() {
139
151
  await this.transport.disconnect();
140
152
  }
153
+ async healthCheck() {
154
+ if (isHealthCheckable(this.transport)) {
155
+ return this.transport.healthCheck();
156
+ }
157
+ return {
158
+ status: "up",
159
+ details: { reason: "Transport does not support health checks" }
160
+ };
161
+ }
141
162
  async handleMessage(message) {
142
163
  const event = this.parser.parse(message);
143
164
  if (!event) return;
@@ -159,10 +180,15 @@ var SagaRunner = class {
159
180
  sagaName: event.sagaName,
160
181
  sagaDescription: event.sagaDescription
161
182
  };
162
- const emit = this.publisher.forSaga(event.sagaId, {
163
- parentSagaId: event.parentSagaId,
164
- rootSagaId: event.rootSagaId
165
- }, event.eventId, event.key);
183
+ const emit = this.publisher.forSaga(
184
+ event.sagaId,
185
+ {
186
+ parentSagaId: event.parentSagaId,
187
+ rootSagaId: event.rootSagaId
188
+ },
189
+ event.eventId,
190
+ event.key
191
+ );
166
192
  const wrappedEmit = async (params) => {
167
193
  const finalParams = isFinalHandler ? { ...params, hint: "final" } : params;
168
194
  return emit(finalParams);
@@ -170,10 +196,15 @@ var SagaRunner = class {
170
196
  const forkConfig = route.options?.fork;
171
197
  const finalEmit = forkConfig ? async (params) => {
172
198
  const subSagaId = (0, import_uuid.v7)();
173
- const subEmit = this.publisher.forSaga(subSagaId, {
174
- parentSagaId: event.sagaId,
175
- rootSagaId: event.rootSagaId
176
- }, event.eventId, event.key);
199
+ const subEmit = this.publisher.forSaga(
200
+ subSagaId,
201
+ {
202
+ parentSagaId: event.sagaId,
203
+ rootSagaId: event.rootSagaId
204
+ },
205
+ event.eventId,
206
+ event.key
207
+ );
177
208
  const forkMeta = typeof forkConfig === "object" ? forkConfig : {};
178
209
  const forkCtx = {
179
210
  sagaId: subSagaId,
@@ -184,7 +215,10 @@ var SagaRunner = class {
184
215
  sagaName: forkMeta.sagaName,
185
216
  sagaDescription: forkMeta.sagaDescription
186
217
  };
187
- await SagaContext.run(forkCtx, () => subEmit({ ...params, hint: "fork" }));
218
+ await SagaContext.run(
219
+ forkCtx,
220
+ () => subEmit({ ...params, hint: "fork" })
221
+ );
188
222
  } : wrappedEmit;
189
223
  const spanAttrs = {
190
224
  "saga.id": event.sagaId,
@@ -195,8 +229,10 @@ var SagaRunner = class {
195
229
  "saga.handler.service": route.participant.serviceId
196
230
  };
197
231
  if (event.sagaName) spanAttrs["saga.name"] = event.sagaName;
198
- if (event.sagaDescription) spanAttrs["saga.description"] = event.sagaDescription;
199
- if (event.stepDescription) spanAttrs["saga.step.description"] = event.stepDescription;
232
+ if (event.sagaDescription)
233
+ spanAttrs["saga.description"] = event.sagaDescription;
234
+ if (event.stepDescription)
235
+ spanAttrs["saga.step.description"] = event.stepDescription;
200
236
  if (event.parentSagaId) spanAttrs["saga.parent.id"] = event.parentSagaId;
201
237
  const sagaCtxData = {
202
238
  sagaId: event.sagaId,
@@ -209,10 +245,20 @@ var SagaRunner = class {
209
245
  };
210
246
  const runHandler = () => SagaContext.run(
211
247
  sagaCtxData,
212
- () => this.runWithRetry(route.handler, route.participant, incoming, finalEmit)
248
+ () => this.runWithRetry(
249
+ route.handler,
250
+ route.participant,
251
+ incoming,
252
+ finalEmit
253
+ )
213
254
  );
214
255
  if (this.otelCtx) {
215
- await this.otelCtx.withExtractedSpan(`saga.handle ${event.eventType}`, spanAttrs, message.headers, runHandler);
256
+ await this.otelCtx.withExtractedSpan(
257
+ `saga.handle ${event.eventType}`,
258
+ spanAttrs,
259
+ message.headers,
260
+ runHandler
261
+ );
216
262
  } else {
217
263
  await runHandler();
218
264
  }
@@ -321,10 +367,15 @@ var SagaPublisher = class {
321
367
  }
322
368
  async emit(params) {
323
369
  const ctx = SagaContext.require();
324
- const boundEmit = this.forSaga(ctx.sagaId, {
325
- parentSagaId: ctx.parentSagaId,
326
- rootSagaId: ctx.rootSagaId
327
- }, ctx.causationId, ctx.key);
370
+ const boundEmit = this.forSaga(
371
+ ctx.sagaId,
372
+ {
373
+ parentSagaId: ctx.parentSagaId,
374
+ rootSagaId: ctx.rootSagaId
375
+ },
376
+ ctx.causationId,
377
+ ctx.key
378
+ );
328
379
  return boundEmit(params);
329
380
  }
330
381
  async startChild(fn, opts) {
@@ -358,10 +409,15 @@ var SagaPublisher = class {
358
409
  await SagaContext.run(parentCtx, paramsOrFn);
359
410
  return;
360
411
  }
361
- const parentEmit = this.forSaga(ctx.parentSagaId, {
362
- parentSagaId: ctx.parentSagaId,
363
- rootSagaId: ctx.rootSagaId
364
- }, ctx.causationId, ctx.key);
412
+ const parentEmit = this.forSaga(
413
+ ctx.parentSagaId,
414
+ {
415
+ parentSagaId: ctx.parentSagaId,
416
+ rootSagaId: ctx.rootSagaId
417
+ },
418
+ ctx.causationId,
419
+ ctx.key
420
+ );
365
421
  return parentEmit(paramsOrFn);
366
422
  }
367
423
  forSaga(sagaId, parentCtx, causationId, baseKey) {
@@ -401,7 +457,11 @@ var SagaPublisher = class {
401
457
  };
402
458
  }
403
459
  async publish(event) {
404
- this.otelCtx.injectBaggage(event.sagaId, event.rootSagaId, event.parentSagaId);
460
+ this.otelCtx.injectBaggage(
461
+ event.sagaId,
462
+ event.rootSagaId,
463
+ event.parentSagaId
464
+ );
405
465
  const attrs = {
406
466
  "saga.id": event.sagaId,
407
467
  "saga.event.type": event.eventType,
@@ -627,7 +687,10 @@ var W3cOtelContext = class {
627
687
  entries["saga.parent.id"] = { value: parentSagaId };
628
688
  }
629
689
  const baggage = this.api.propagation.createBaggage(entries);
630
- const ctx = this.api.propagation.setBaggage(this.api.context.active(), baggage);
690
+ const ctx = this.api.propagation.setBaggage(
691
+ this.api.context.active(),
692
+ baggage
693
+ );
631
694
  this.api.context.with(ctx, () => {
632
695
  });
633
696
  }
@@ -659,7 +722,9 @@ var W3cOtelContext = class {
659
722
  code: this.api.SpanStatusCode.ERROR,
660
723
  message: error instanceof Error ? error.message : String(error)
661
724
  });
662
- span.recordException(error instanceof Error ? error : new Error(String(error)));
725
+ span.recordException(
726
+ error instanceof Error ? error : new Error(String(error))
727
+ );
663
728
  throw error;
664
729
  } finally {
665
730
  span.end();
@@ -670,27 +735,36 @@ var W3cOtelContext = class {
670
735
  this.api.propagation.inject(this.api.context.active(), headers);
671
736
  }
672
737
  async withExtractedSpan(name, attrs, headers, fn) {
673
- const parentCtx = this.api.propagation.extract(this.api.ROOT_CONTEXT, headers);
738
+ const parentCtx = this.api.propagation.extract(
739
+ this.api.ROOT_CONTEXT,
740
+ headers
741
+ );
674
742
  const tracer = this.api.trace.getTracer("@fbsm/saga-core");
675
743
  return this.api.context.with(
676
744
  parentCtx,
677
- () => tracer.startActiveSpan(name, { kind: this.api.SpanKind.CONSUMER }, async (span) => {
678
- span.setAttributes(attrs);
679
- try {
680
- const result = await fn();
681
- span.setStatus({ code: this.api.SpanStatusCode.OK });
682
- return result;
683
- } catch (error) {
684
- span.setStatus({
685
- code: this.api.SpanStatusCode.ERROR,
686
- message: error instanceof Error ? error.message : String(error)
687
- });
688
- span.recordException(error instanceof Error ? error : new Error(String(error)));
689
- throw error;
690
- } finally {
691
- span.end();
745
+ () => tracer.startActiveSpan(
746
+ name,
747
+ { kind: this.api.SpanKind.CONSUMER },
748
+ async (span) => {
749
+ span.setAttributes(attrs);
750
+ try {
751
+ const result = await fn();
752
+ span.setStatus({ code: this.api.SpanStatusCode.OK });
753
+ return result;
754
+ } catch (error) {
755
+ span.setStatus({
756
+ code: this.api.SpanStatusCode.ERROR,
757
+ message: error instanceof Error ? error.message : String(error)
758
+ });
759
+ span.recordException(
760
+ error instanceof Error ? error : new Error(String(error))
761
+ );
762
+ throw error;
763
+ } finally {
764
+ span.end();
765
+ }
692
766
  }
693
- })
767
+ )
694
768
  );
695
769
  }
696
770
  };
@@ -721,6 +795,7 @@ function createOtelContext(enabled) {
721
795
  SagaRunner,
722
796
  SagaTransportNotConnectedError,
723
797
  W3cOtelContext,
724
- createOtelContext
798
+ createOtelContext,
799
+ isHealthCheckable
725
800
  });
726
801
  //# sourceMappingURL=index.cjs.map