@fbsm/saga-core 0.1.0-beta.3 → 0.2.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.
package/README.md CHANGED
@@ -121,19 +121,19 @@ interface RunnerOptions {
121
121
 
122
122
  **Handler execution flow**:
123
123
 
124
- 1. Parse inbound message via `SagaParser`
125
- 2. Look up handler in route map
126
- 3. Wrap emit with `final` hint (if `{ final: true }`)
127
- 4. Wrap emit with fork logic (if `{ fork: true }`) — creates sub-saga per emit
128
- 5. Set `SagaContext` via `AsyncLocalStorage`
129
- 6. Execute handler with retry on `SagaRetryableError`
130
- 7. On retry exhaustion, call `participant.onRetryExhausted()` if defined
124
+ 1. Parse inbound message via `SagaParser` — if parsed (saga metadata found), route to saga handler; if null (no metadata), route to plain handler
125
+ 2. Look up handler in route map (`RouteEntry` supports both `sagaHandler` and `plainHandler` per topic)
126
+ 3. **Saga path**: Wrap emit with `final` hint (if `{ final: true }`), fork logic (if `{ fork: true }`), set `SagaContext` via `AsyncLocalStorage`
127
+ 4. Execute handler with retry on `SagaRetryableError`
128
+ 5. On non-retryable error: call `participant.onFail()` if defined (with independent retry)
129
+ 6. On retry exhaustion (from handle or onFail): call `participant.onRetryExhausted()` if defined
130
+ 7. **Plain path**: Parse payload, call `plainHandler(PlainMessage)` no context, no emit, no retry
131
131
 
132
132
  ## SagaParser
133
133
 
134
134
  Parses inbound messages using a 3-layer fallback strategy:
135
135
 
136
- 1. **Headers** — `saga-id` header present → metadata from headers, payload from body
136
+ 1. **Headers** — `saga-id` header present → metadata from headers (including `saga-occurred-at`), body is the raw user payload. Topic is derived from the message's topic field
137
137
  2. **W3C Baggage** — OpenTelemetry baggage present → extract saga context from baggage items
138
138
  3. **Legacy envelope** — Body contains `sagaId` field → full envelope in message body
139
139
 
@@ -146,6 +146,7 @@ When using the header-based format (default with `@fbsm/saga-transport-kafka`):
146
146
  | `saga-id` | Saga instance ID |
147
147
  | `saga-event-id` | Unique event ID |
148
148
  | `saga-causation-id` | ID of the event that caused this one |
149
+ | `saga-occurred-at` | ISO timestamp of when the event occurred |
149
150
  | `saga-step-name` | Logical step name |
150
151
  | `saga-published-at` | ISO timestamp of publication |
151
152
  | `saga-schema-version` | Schema version (currently `1`) |
@@ -157,20 +158,36 @@ When using the header-based format (default with `@fbsm/saga-transport-kafka`):
157
158
  | `saga-step-description` | Step description (optional) |
158
159
  | `saga-key` | Partition key (optional) |
159
160
 
161
+ **Message body**: The Kafka message body contains **only the user's payload** (e.g., `{"orderId":"456"}`). Event metadata such as `occurredAt` is transmitted via the headers listed above, not in the body. The topic is derived from the Kafka message topic (i.e., `message.topic`), not from a header.
162
+
160
163
  ## Errors
161
164
 
162
165
  | Error | Description |
163
166
  | -------------------------------- | ----------------------------------------------------------------------------------------------------------- |
164
167
  | `SagaError` | Base error class for all saga errors |
165
168
  | `SagaRetryableError` | Throw in handlers to trigger retry with exponential backoff. `new SagaRetryableError(message, maxRetries?)` |
166
- | `SagaDuplicateHandlerError` | Two handlers registered for the same event type |
169
+ | `SagaDuplicateHandlerError` | Two handlers registered for the same topic |
167
170
  | `SagaParseError` | Message parsing failed |
168
171
  | `SagaTransportNotConnectedError` | Publishing to a disconnected transport |
169
172
  | `SagaContextNotFoundError` | `emit()`/`startChild()`/`emitToParent()` called outside a saga context |
170
173
  | `SagaNoParentError` | `emitToParent()` called in a saga without `parentSagaId` |
171
174
  | `SagaInvalidHandlerConfigError` | Handler has conflicting options (e.g., both `final` and `fork`) |
172
175
 
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.
176
+ **Retry behavior**: `SagaRetryableError` triggers exponential backoff: `initialDelayMs * 2^attempt`. After `maxRetries` attempts, `onRetryExhausted()` is called if defined. Non-retryable errors are routed to `onFail()` if defined; otherwise logged and skipped.
177
+
178
+ **Error flow**:
179
+
180
+ ```
181
+ handle(event, emit)
182
+ ├── SUCCESS → done
183
+ ├── throws SagaRetryableError → retry → exhausted → onRetryExhausted?
184
+ └── throws non-retryable Error
185
+ ├── onFail defined → call onFail (retries independently)
186
+ │ ├── onFail succeeds → done
187
+ │ ├── onFail retries exhausted → onRetryExhausted?
188
+ │ └── onFail throws non-retryable → log → done
189
+ └── onFail not defined → log → done
190
+ ```
174
191
 
175
192
  ## OTel Integration
176
193
 
@@ -250,7 +267,7 @@ Default: `ConsoleSagaLogger` (wraps `console.log/warn/error`).
250
267
 
251
268
  ## Further Reading
252
269
 
253
- - [Concepts](../doc/concepts.md) — sagaId, hint, eventType, and other domain terms
270
+ - [Concepts](../doc/concepts.md) — sagaId, hint, topic, and other domain terms
254
271
  - [Core Functions](../doc/core-functions.md) — emit, emitToParent, start, startChild, forSaga
255
272
  - [@fbsm/saga-nestjs](../saga-nestjs/README.md) — NestJS decorators and auto-discovery
256
273
  - [@fbsm/saga-transport-kafka](../saga-transport-kafka/README.md) — Kafka transport
package/dist/index.cjs CHANGED
@@ -122,11 +122,13 @@ var SagaRunner = class {
122
122
  }
123
123
  routeMap;
124
124
  async start() {
125
- this.routeMap = this.registry.buildRouteMap();
125
+ const baseRouteMap = this.registry.buildRouteMap();
126
126
  const prefix = this.options.topicPrefix ?? "";
127
- const topics = Array.from(this.routeMap.keys()).map(
128
- (et) => `${prefix}${et}`
129
- );
127
+ this.routeMap = /* @__PURE__ */ new Map();
128
+ for (const [topic, entry] of baseRouteMap) {
129
+ this.routeMap.set(`${prefix}${topic}`, entry);
130
+ }
131
+ const topics = Array.from(this.routeMap.keys());
130
132
  await this.transport.connect();
131
133
  if (topics.length > 0) {
132
134
  this.logger.info(
@@ -160,16 +162,27 @@ var SagaRunner = class {
160
162
  };
161
163
  }
162
164
  async handleMessage(message) {
165
+ const route = this.routeMap.get(message.topic);
166
+ if (!route) {
167
+ return;
168
+ }
163
169
  const event = this.parser.parse(message);
164
- if (!event) return;
165
- const route = this.routeMap.get(event.eventType);
166
- if (!route) return;
167
- const isFinalHandler = route.options?.final === true;
170
+ if (event && route.sagaHandler) {
171
+ await this.handleSagaMessage(message, event, route);
172
+ return;
173
+ }
174
+ if (!event && route.plainHandler) {
175
+ await this.handlePlainMessage(message, route);
176
+ return;
177
+ }
178
+ }
179
+ async handleSagaMessage(message, event, route) {
180
+ const isFinalHandler = route.sagaOptions?.final === true;
168
181
  const incoming = {
169
182
  sagaId: event.sagaId,
170
183
  eventId: event.eventId,
171
184
  causationId: event.causationId,
172
- eventType: event.eventType,
185
+ topic: event.topic,
173
186
  stepName: event.stepName,
174
187
  stepDescription: event.stepDescription,
175
188
  occurredAt: event.occurredAt,
@@ -193,7 +206,7 @@ var SagaRunner = class {
193
206
  const finalParams = isFinalHandler ? { ...params, hint: "final" } : params;
194
207
  return emit(finalParams);
195
208
  };
196
- const forkConfig = route.options?.fork;
209
+ const forkConfig = route.sagaOptions?.fork;
197
210
  const finalEmit = forkConfig ? async (params) => {
198
211
  const subSagaId = (0, import_uuid.v7)();
199
212
  const subEmit = this.publisher.forSaga(
@@ -222,11 +235,11 @@ var SagaRunner = class {
222
235
  } : wrappedEmit;
223
236
  const spanAttrs = {
224
237
  "saga.id": event.sagaId,
225
- "saga.event.type": event.eventType,
238
+ "saga.topic": event.topic,
226
239
  "saga.step.name": event.stepName,
227
240
  "saga.event.id": event.eventId,
228
241
  "saga.root.id": event.rootSagaId,
229
- "saga.handler.service": route.participant.serviceId
242
+ "saga.handler.service": route.sagaParticipant.serviceId
230
243
  };
231
244
  if (event.sagaName) spanAttrs["saga.name"] = event.sagaName;
232
245
  if (event.sagaDescription)
@@ -246,15 +259,15 @@ var SagaRunner = class {
246
259
  const runHandler = () => SagaContext.run(
247
260
  sagaCtxData,
248
261
  () => this.runWithRetry(
249
- route.handler,
250
- route.participant,
262
+ route.sagaHandler,
263
+ route.sagaParticipant,
251
264
  incoming,
252
265
  finalEmit
253
266
  )
254
267
  );
255
268
  if (this.otelCtx) {
256
269
  await this.otelCtx.withExtractedSpan(
257
- `saga.handle ${event.eventType}`,
270
+ `saga.handle ${event.topic}`,
258
271
  spanAttrs,
259
272
  message.headers,
260
273
  runHandler
@@ -263,12 +276,65 @@ var SagaRunner = class {
263
276
  await runHandler();
264
277
  }
265
278
  }
279
+ async handlePlainMessage(message, route) {
280
+ let payload;
281
+ try {
282
+ payload = JSON.parse(message.value);
283
+ } catch {
284
+ payload = message.value;
285
+ }
286
+ const plainMessage = {
287
+ topic: message.topic,
288
+ key: message.key,
289
+ payload,
290
+ headers: message.headers
291
+ };
292
+ await route.plainHandler(plainMessage);
293
+ }
266
294
  async runWithRetry(handler, participant, event, emit) {
295
+ try {
296
+ await this.executeWithRetry(handler, event, emit);
297
+ } catch (error) {
298
+ if (error instanceof SagaRetryableError) {
299
+ await this.callOnRetryExhausted(participant, event, error, emit);
300
+ return;
301
+ }
302
+ if (participant.onFail) {
303
+ try {
304
+ await this.executeWithRetry(
305
+ (ev, em) => participant.onFail(ev, error, em),
306
+ event,
307
+ emit
308
+ );
309
+ } catch (failError) {
310
+ if (failError instanceof SagaRetryableError) {
311
+ await this.callOnRetryExhausted(
312
+ participant,
313
+ event,
314
+ failError,
315
+ emit
316
+ );
317
+ } else {
318
+ this.logger.error(
319
+ `[SagaRunner] onFail threw non-retryable error for ${event.topic}:`,
320
+ failError
321
+ );
322
+ }
323
+ }
324
+ return;
325
+ }
326
+ this.logger.error(
327
+ `[SagaRunner] Non-retryable error in handler for ${event.topic}:`,
328
+ error
329
+ );
330
+ }
331
+ }
332
+ async executeWithRetry(fn, event, emit) {
267
333
  const maxRetries = this.options.retryPolicy?.maxRetries ?? 3;
268
334
  const initialDelayMs = this.options.retryPolicy?.initialDelayMs ?? 200;
269
335
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
270
336
  try {
271
- await handler(event, emit);
337
+ await fn(event, emit);
272
338
  return;
273
339
  } catch (error) {
274
340
  if (error instanceof SagaRetryableError) {
@@ -277,19 +343,17 @@ var SagaRunner = class {
277
343
  await this.sleep(delay);
278
344
  continue;
279
345
  }
280
- if (participant.onRetryExhausted) {
281
- await participant.onRetryExhausted(event, error, emit);
282
- }
283
- return;
346
+ throw error;
284
347
  }
285
- this.logger.error(
286
- `[SagaRunner] Non-retryable error in handler for ${event.eventType}:`,
287
- error
288
- );
289
- return;
348
+ throw error;
290
349
  }
291
350
  }
292
351
  }
352
+ async callOnRetryExhausted(participant, event, error, emit) {
353
+ if (participant.onRetryExhausted) {
354
+ await participant.onRetryExhausted(event, error, emit);
355
+ }
356
+ }
293
357
  sleep(ms) {
294
358
  return new Promise((resolve) => setTimeout(resolve, ms));
295
359
  }
@@ -308,7 +372,7 @@ var SagaNoParentError = class extends SagaError {
308
372
 
309
373
  // src/publisher/message-builder.ts
310
374
  function buildOutboundMessage(event, topicPrefix = "") {
311
- const topic = `${topicPrefix}${event.eventType}`;
375
+ const topic = `${topicPrefix}${event.topic}`;
312
376
  const key = event.key ?? event.rootSagaId;
313
377
  const headers = {
314
378
  "saga-id": event.sagaId,
@@ -337,11 +401,8 @@ function buildOutboundMessage(event, topicPrefix = "") {
337
401
  if (event.key) {
338
402
  headers["saga-key"] = event.key;
339
403
  }
340
- const value = JSON.stringify({
341
- eventType: event.eventType,
342
- occurredAt: event.occurredAt,
343
- payload: event.payload
344
- });
404
+ headers["saga-occurred-at"] = event.occurredAt;
405
+ const value = JSON.stringify(event.payload);
345
406
  return { topic, key, value, headers };
346
407
  }
347
408
 
@@ -425,7 +486,7 @@ var SagaPublisher = class {
425
486
  const parentSagaId = parentCtx?.parentSagaId;
426
487
  const baseCausationId = causationId ?? sagaId;
427
488
  return async ({
428
- eventType,
489
+ topic,
429
490
  stepName,
430
491
  stepDescription,
431
492
  payload,
@@ -439,7 +500,7 @@ var SagaPublisher = class {
439
500
  sagaId,
440
501
  causationId: baseCausationId,
441
502
  eventId: (0, import_uuid2.v7)(),
442
- eventType,
503
+ topic,
443
504
  stepName,
444
505
  stepDescription,
445
506
  occurredAt: now,
@@ -464,7 +525,7 @@ var SagaPublisher = class {
464
525
  );
465
526
  const attrs = {
466
527
  "saga.id": event.sagaId,
467
- "saga.event.type": event.eventType,
528
+ "saga.topic": event.topic,
468
529
  "saga.step.name": event.stepName,
469
530
  "saga.root.id": event.rootSagaId
470
531
  };
@@ -484,7 +545,7 @@ var SagaPublisher = class {
484
545
  const message = buildOutboundMessage(event, this.topicPrefix);
485
546
  this.otelCtx.injectTraceContext(message.headers);
486
547
  await this.otelCtx.withSpan(
487
- `saga.publish ${event.eventType}`,
548
+ `saga.publish ${event.topic}`,
488
549
  attrs,
489
550
  () => this.transport.publish(message)
490
551
  );
@@ -515,7 +576,7 @@ var SagaParser = class {
515
576
  }
516
577
  parseFromHeaders(message) {
517
578
  const headers = message.headers;
518
- const body = JSON.parse(message.value);
579
+ const payload = JSON.parse(message.value);
519
580
  const sagaId = headers["saga-id"];
520
581
  if (!sagaId) {
521
582
  return null;
@@ -524,14 +585,14 @@ var SagaParser = class {
524
585
  sagaId,
525
586
  causationId: headers["saga-causation-id"] ?? sagaId,
526
587
  eventId: headers["saga-event-id"] ?? (0, import_uuid3.v7)(),
527
- eventType: body.eventType,
588
+ topic: message.topic,
528
589
  stepName: headers["saga-step-name"] ?? "",
529
- occurredAt: body.occurredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
590
+ occurredAt: headers["saga-occurred-at"] ?? (/* @__PURE__ */ new Date()).toISOString(),
530
591
  publishedAt: headers["saga-published-at"] ?? (/* @__PURE__ */ new Date()).toISOString(),
531
592
  schemaVersion: 1,
532
593
  rootSagaId: headers["saga-root-id"] ?? sagaId,
533
594
  parentSagaId: headers["saga-parent-id"] || void 0,
534
- payload: body.payload,
595
+ payload,
535
596
  sagaName: headers["saga-name"] || void 0,
536
597
  sagaDescription: headers["saga-description"] || void 0,
537
598
  stepDescription: headers["saga-step-description"] || void 0,
@@ -561,7 +622,7 @@ var SagaParser = class {
561
622
  sagaId,
562
623
  causationId: sagaId,
563
624
  eventId: (0, import_uuid3.v7)(),
564
- eventType: body.eventType,
625
+ topic: body.topic,
565
626
  stepName: "",
566
627
  occurredAt: body.occurredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
567
628
  publishedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -585,9 +646,9 @@ var SagaParser = class {
585
646
 
586
647
  // src/errors/saga-duplicate-handler.error.ts
587
648
  var SagaDuplicateHandlerError = class extends SagaError {
588
- constructor(eventType, existingServiceId, newServiceId) {
649
+ constructor(topic, existingServiceId, newServiceId) {
589
650
  super(
590
- `Duplicate handler for event type "${eventType}": registered by "${existingServiceId}" and "${newServiceId}"`
651
+ `Duplicate handler for event type "${topic}": registered by "${existingServiceId}" and "${newServiceId}"`
591
652
  );
592
653
  this.name = "SagaDuplicateHandlerError";
593
654
  }
@@ -595,9 +656,9 @@ var SagaDuplicateHandlerError = class extends SagaError {
595
656
 
596
657
  // src/errors/saga-invalid-handler-config.error.ts
597
658
  var SagaInvalidHandlerConfigError = class extends SagaError {
598
- constructor(eventType, serviceId, reason) {
659
+ constructor(topic, serviceId, reason) {
599
660
  super(
600
- `Invalid handler config for "${eventType}" in "${serviceId}": ${reason}`
661
+ `Invalid handler config for "${topic}" in "${serviceId}": ${reason}`
601
662
  );
602
663
  this.name = "SagaInvalidHandlerConfigError";
603
664
  }
@@ -615,24 +676,46 @@ var SagaRegistry = class {
615
676
  buildRouteMap() {
616
677
  const map = /* @__PURE__ */ new Map();
617
678
  for (const participant of this.participants) {
618
- for (const [eventType, handler] of Object.entries(participant.on)) {
619
- if (map.has(eventType)) {
620
- const existing = map.get(eventType);
679
+ for (const [topic, handler] of Object.entries(participant.on)) {
680
+ const existing = map.get(topic) ?? {};
681
+ if (existing.sagaHandler) {
621
682
  throw new SagaDuplicateHandlerError(
622
- eventType,
623
- existing.participant.serviceId,
683
+ topic,
684
+ existing.sagaParticipant.serviceId,
624
685
  participant.serviceId
625
686
  );
626
687
  }
627
- const options = participant.handlerOptions?.[eventType];
688
+ const options = participant.handlerOptions?.[topic];
628
689
  if (options?.final && options?.fork) {
629
690
  throw new SagaInvalidHandlerConfigError(
630
- eventType,
691
+ topic,
631
692
  participant.serviceId,
632
693
  "cannot have both final and fork options"
633
694
  );
634
695
  }
635
- map.set(eventType, { participant, handler, options });
696
+ map.set(topic, {
697
+ ...existing,
698
+ sagaParticipant: participant,
699
+ sagaHandler: handler,
700
+ sagaOptions: options
701
+ });
702
+ }
703
+ if (participant.onPlain) {
704
+ for (const [topic, handler] of Object.entries(participant.onPlain)) {
705
+ const existing = map.get(topic) ?? {};
706
+ if (existing.plainHandler) {
707
+ throw new SagaDuplicateHandlerError(
708
+ topic,
709
+ existing.plainParticipant.serviceId,
710
+ participant.serviceId
711
+ );
712
+ }
713
+ map.set(topic, {
714
+ ...existing,
715
+ plainParticipant: participant,
716
+ plainHandler: handler
717
+ });
718
+ }
636
719
  }
637
720
  }
638
721
  return map;