@saga-bus/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,1097 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ ConcurrencyError: () => ConcurrencyError,
24
+ DEFAULT_RETRY_POLICY: () => DEFAULT_RETRY_POLICY,
25
+ DEFAULT_TIMEOUT_BOUNDS: () => DEFAULT_TIMEOUT_BOUNDS,
26
+ DefaultErrorHandler: () => DefaultErrorHandler,
27
+ DefaultLogger: () => DefaultLogger,
28
+ RETRY_HEADERS: () => RETRY_HEADERS,
29
+ SagaMachineBuilder: () => SagaMachineBuilder,
30
+ SagaProcessingError: () => SagaProcessingError,
31
+ TransientError: () => TransientError,
32
+ ValidationError: () => ValidationError,
33
+ createBus: () => createBus,
34
+ createErrorHandler: () => createErrorHandler,
35
+ createSagaMachine: () => createSagaMachine,
36
+ defaultDlqNaming: () => defaultDlqNaming
37
+ });
38
+ module.exports = __toCommonJS(index_exports);
39
+
40
+ // src/errors/index.ts
41
+ var ConcurrencyError = class extends Error {
42
+ sagaId;
43
+ expectedVersion;
44
+ actualVersion;
45
+ constructor(sagaId, expectedVersion, actualVersion) {
46
+ const message = actualVersion !== void 0 ? `Concurrency conflict for saga ${sagaId}: expected version ${expectedVersion}, got ${actualVersion}` : `Concurrency conflict for saga ${sagaId}: expected version ${expectedVersion}`;
47
+ super(message);
48
+ this.name = "ConcurrencyError";
49
+ this.sagaId = sagaId;
50
+ this.expectedVersion = expectedVersion;
51
+ this.actualVersion = actualVersion;
52
+ }
53
+ };
54
+ var TransientError = class _TransientError extends Error {
55
+ cause;
56
+ constructor(message, cause) {
57
+ super(message);
58
+ this.name = "TransientError";
59
+ this.cause = cause;
60
+ }
61
+ static wrap(error) {
62
+ return new _TransientError(error.message, error);
63
+ }
64
+ };
65
+ var ValidationError = class extends Error {
66
+ field;
67
+ value;
68
+ constructor(message, field, value) {
69
+ super(message);
70
+ this.name = "ValidationError";
71
+ this.field = field;
72
+ this.value = value;
73
+ }
74
+ };
75
+ var SagaProcessingError = class _SagaProcessingError extends Error {
76
+ context;
77
+ cause;
78
+ constructor(cause, context) {
79
+ super(`Error processing message in ${context.sagaName}: ${cause.message}`);
80
+ this.name = "SagaProcessingError";
81
+ this.cause = cause;
82
+ this.context = context;
83
+ }
84
+ /**
85
+ * Check if an error is a SagaProcessingError and extract context.
86
+ */
87
+ static extractContext(error) {
88
+ if (error instanceof _SagaProcessingError) {
89
+ return error.context;
90
+ }
91
+ return null;
92
+ }
93
+ };
94
+
95
+ // src/dsl/HandlerBuilder.ts
96
+ var HandlerBuilder = class {
97
+ constructor(parent, messageType) {
98
+ this.parent = parent;
99
+ this.messageType = messageType;
100
+ }
101
+ guard;
102
+ /**
103
+ * Add a state guard that must pass for the handler to execute.
104
+ * Multiple guards can be chained with multiple .when() calls.
105
+ */
106
+ when(guard) {
107
+ if (this.guard) {
108
+ const existingGuard = this.guard;
109
+ this.guard = (state) => existingGuard(state) && guard(state);
110
+ } else {
111
+ this.guard = guard;
112
+ }
113
+ return this;
114
+ }
115
+ /**
116
+ * Register the handler function and return to the parent builder.
117
+ */
118
+ handle(handler) {
119
+ const registration = {
120
+ messageType: this.messageType,
121
+ guard: this.guard,
122
+ handler
123
+ };
124
+ return this.parent._registerHandler(
125
+ registration
126
+ );
127
+ }
128
+ };
129
+
130
+ // src/dsl/SagaDefinitionImpl.ts
131
+ var SagaDefinitionImpl = class {
132
+ name;
133
+ handledMessageTypes;
134
+ correlations;
135
+ wildcardCorrelation;
136
+ handlers;
137
+ initialFactory;
138
+ constructor(config) {
139
+ this.name = config.name;
140
+ this.correlations = config.correlations;
141
+ this.wildcardCorrelation = config.wildcardCorrelation;
142
+ this.handlers = config.handlers;
143
+ this.initialFactory = config.initialFactory;
144
+ const types = /* @__PURE__ */ new Set();
145
+ for (const type of this.correlations.keys()) {
146
+ types.add(type);
147
+ }
148
+ for (const type of this.handlers.keys()) {
149
+ types.add(type);
150
+ }
151
+ this.handledMessageTypes = Array.from(types);
152
+ }
153
+ getCorrelation(message) {
154
+ const specific = this.correlations.get(message.type);
155
+ if (specific) {
156
+ return {
157
+ canStart: specific.canStart,
158
+ getCorrelationId: (msg) => specific.getCorrelationId(msg)
159
+ };
160
+ }
161
+ if (this.wildcardCorrelation) {
162
+ return {
163
+ canStart: this.wildcardCorrelation.canStart,
164
+ getCorrelationId: (msg) => this.wildcardCorrelation.getCorrelationId(msg)
165
+ };
166
+ }
167
+ return {
168
+ canStart: false,
169
+ getCorrelationId: () => null
170
+ };
171
+ }
172
+ async createInitialState(message, ctx) {
173
+ if (!this.initialFactory) {
174
+ throw new Error(
175
+ `No initial state factory defined for saga "${this.name}"`
176
+ );
177
+ }
178
+ return this.initialFactory(message, ctx);
179
+ }
180
+ async handle(message, state, ctx) {
181
+ const registrations = this.handlers.get(message.type);
182
+ if (!registrations || registrations.length === 0) {
183
+ return { newState: state };
184
+ }
185
+ for (const registration of registrations) {
186
+ if (!registration.guard || registration.guard(state)) {
187
+ return registration.handler(message, state, ctx);
188
+ }
189
+ }
190
+ return { newState: state };
191
+ }
192
+ };
193
+
194
+ // src/dsl/SagaMachineBuilder.ts
195
+ var SagaMachineBuilder = class {
196
+ sagaName;
197
+ correlations = /* @__PURE__ */ new Map();
198
+ wildcardCorrelation;
199
+ handlers = /* @__PURE__ */ new Map();
200
+ initialFactory;
201
+ /**
202
+ * Set the saga name.
203
+ */
204
+ name(sagaName) {
205
+ this.sagaName = sagaName;
206
+ return this;
207
+ }
208
+ /**
209
+ * Define how to correlate messages to saga instances.
210
+ *
211
+ * @param messageType - The message type to correlate, or "*" for wildcard
212
+ * @param getCorrelationId - Function to extract correlation ID from message
213
+ * @param options - Correlation options (e.g., canStart)
214
+ */
215
+ correlate(messageType, getCorrelationId, options = {}) {
216
+ const config = {
217
+ canStart: options.canStart ?? false,
218
+ getCorrelationId
219
+ };
220
+ if (messageType === "*") {
221
+ this.wildcardCorrelation = config;
222
+ } else {
223
+ this.correlations.set(messageType, config);
224
+ }
225
+ return this;
226
+ }
227
+ /**
228
+ * Define the initial state factory for new saga instances.
229
+ *
230
+ * @param factory - Function to create initial state from the starting message
231
+ */
232
+ initial(factory) {
233
+ this.initialFactory = factory;
234
+ return this;
235
+ }
236
+ /**
237
+ * Start defining a handler for a specific message type.
238
+ *
239
+ * @param messageType - The message type to handle
240
+ * @returns A HandlerBuilder for chaining .when() and .handle()
241
+ */
242
+ on(messageType) {
243
+ return new HandlerBuilder(this, messageType);
244
+ }
245
+ /**
246
+ * Internal method called by HandlerBuilder to register a handler.
247
+ * @internal
248
+ */
249
+ _registerHandler(registration) {
250
+ const existing = this.handlers.get(registration.messageType);
251
+ if (existing) {
252
+ existing.push(registration);
253
+ } else {
254
+ this.handlers.set(registration.messageType, [registration]);
255
+ }
256
+ return this;
257
+ }
258
+ /**
259
+ * Build the saga definition.
260
+ *
261
+ * @throws Error if required configuration is missing
262
+ */
263
+ build() {
264
+ if (!this.sagaName) {
265
+ throw new Error("Saga name is required. Call .name() before .build()");
266
+ }
267
+ let hasStartingCorrelation = false;
268
+ for (const config of this.correlations.values()) {
269
+ if (config.canStart) {
270
+ hasStartingCorrelation = true;
271
+ break;
272
+ }
273
+ }
274
+ if (this.wildcardCorrelation?.canStart) {
275
+ hasStartingCorrelation = true;
276
+ }
277
+ if (!hasStartingCorrelation) {
278
+ throw new Error(
279
+ `Saga "${this.sagaName}" has no correlation with canStart: true`
280
+ );
281
+ }
282
+ if (!this.initialFactory) {
283
+ throw new Error(
284
+ `Saga "${this.sagaName}" requires an initial state factory. Call .initial() before .build()`
285
+ );
286
+ }
287
+ return new SagaDefinitionImpl({
288
+ name: this.sagaName,
289
+ correlations: this.correlations,
290
+ wildcardCorrelation: this.wildcardCorrelation,
291
+ handlers: this.handlers,
292
+ initialFactory: this.initialFactory
293
+ });
294
+ }
295
+ };
296
+ function createSagaMachine() {
297
+ return new SagaMachineBuilder();
298
+ }
299
+
300
+ // src/types/messages.ts
301
+ var SAGA_TIMEOUT_MESSAGE_TYPE = "SagaTimeoutExpired";
302
+
303
+ // src/runtime/SagaContextImpl.ts
304
+ var DEFAULT_TIMEOUT_BOUNDS = {
305
+ minMs: 1e3,
306
+ // 1 second minimum
307
+ maxMs: 6048e5
308
+ // 7 days maximum
309
+ };
310
+ var SagaContextImpl = class {
311
+ sagaName;
312
+ sagaId;
313
+ correlationId;
314
+ envelope;
315
+ metadata = {};
316
+ transport;
317
+ defaultEndpoint;
318
+ timeoutBounds;
319
+ _isCompleted = false;
320
+ _currentMetadata;
321
+ _pendingTimeoutChange;
322
+ constructor(options) {
323
+ this.sagaName = options.sagaName;
324
+ this.sagaId = options.sagaId;
325
+ this.correlationId = options.correlationId;
326
+ this.envelope = options.envelope;
327
+ this.transport = options.transport;
328
+ this.defaultEndpoint = options.defaultEndpoint;
329
+ this._currentMetadata = options.currentMetadata;
330
+ this.timeoutBounds = {
331
+ minMs: options.timeoutBounds?.minMs ?? DEFAULT_TIMEOUT_BOUNDS.minMs,
332
+ maxMs: options.timeoutBounds?.maxMs ?? DEFAULT_TIMEOUT_BOUNDS.maxMs
333
+ };
334
+ }
335
+ async publish(message, options) {
336
+ const endpoint = options?.endpoint ?? this.defaultEndpoint ?? message.type;
337
+ await this.transport.publish(message, {
338
+ endpoint,
339
+ ...options
340
+ });
341
+ }
342
+ async schedule(message, delayMs, options) {
343
+ const endpoint = options?.endpoint ?? this.defaultEndpoint ?? message.type;
344
+ await this.transport.publish(message, {
345
+ endpoint,
346
+ delayMs,
347
+ ...options
348
+ });
349
+ }
350
+ complete() {
351
+ this._isCompleted = true;
352
+ }
353
+ setMetadata(key, value) {
354
+ this.metadata[key] = value;
355
+ }
356
+ getMetadata(key) {
357
+ return this.metadata[key];
358
+ }
359
+ /**
360
+ * Check if complete() was called.
361
+ */
362
+ get isCompleted() {
363
+ return this._isCompleted;
364
+ }
365
+ setTimeout(delayMs) {
366
+ if (delayMs <= 0) {
367
+ throw new Error("Timeout delay must be positive");
368
+ }
369
+ if (delayMs < this.timeoutBounds.minMs) {
370
+ throw new Error(
371
+ `Timeout delay ${delayMs}ms is below minimum allowed (${this.timeoutBounds.minMs}ms)`
372
+ );
373
+ }
374
+ if (delayMs > this.timeoutBounds.maxMs) {
375
+ throw new Error(
376
+ `Timeout delay ${delayMs}ms exceeds maximum allowed (${this.timeoutBounds.maxMs}ms)`
377
+ );
378
+ }
379
+ const now2 = /* @__PURE__ */ new Date();
380
+ this._pendingTimeoutChange = {
381
+ type: "set",
382
+ timeoutMs: delayMs,
383
+ timeoutExpiresAt: new Date(now2.getTime() + delayMs)
384
+ };
385
+ }
386
+ clearTimeout() {
387
+ this._pendingTimeoutChange = {
388
+ type: "clear"
389
+ };
390
+ }
391
+ getTimeoutRemaining() {
392
+ if (this._pendingTimeoutChange) {
393
+ if (this._pendingTimeoutChange.type === "clear") {
394
+ return null;
395
+ }
396
+ if (this._pendingTimeoutChange.timeoutExpiresAt) {
397
+ const remaining = this._pendingTimeoutChange.timeoutExpiresAt.getTime() - Date.now();
398
+ return Math.max(0, remaining);
399
+ }
400
+ }
401
+ if (this._currentMetadata?.timeoutExpiresAt) {
402
+ const remaining = this._currentMetadata.timeoutExpiresAt.getTime() - Date.now();
403
+ return Math.max(0, remaining);
404
+ }
405
+ return null;
406
+ }
407
+ /**
408
+ * Get any pending timeout change to be applied to saga state.
409
+ * Used by SagaOrchestrator when updating state.
410
+ */
411
+ get pendingTimeoutChange() {
412
+ return this._pendingTimeoutChange;
413
+ }
414
+ /**
415
+ * Update the current metadata reference (called after state is loaded/created).
416
+ */
417
+ updateCurrentMetadata(metadata) {
418
+ this._currentMetadata = metadata;
419
+ }
420
+ };
421
+
422
+ // src/runtime/utils.ts
423
+ var import_node_crypto = require("crypto");
424
+ function generateSagaId() {
425
+ return (0, import_node_crypto.randomUUID)();
426
+ }
427
+ function now() {
428
+ return /* @__PURE__ */ new Date();
429
+ }
430
+
431
+ // src/runtime/SagaOrchestrator.ts
432
+ var SagaOrchestrator = class {
433
+ definition;
434
+ store;
435
+ transport;
436
+ pipeline;
437
+ logger;
438
+ defaultEndpoint;
439
+ timeoutBounds;
440
+ onCorrelationFailure;
441
+ constructor(options) {
442
+ this.definition = options.definition;
443
+ this.store = options.store;
444
+ this.transport = options.transport;
445
+ this.pipeline = options.pipeline;
446
+ this.logger = options.logger;
447
+ this.defaultEndpoint = options.defaultEndpoint;
448
+ this.timeoutBounds = options.timeoutBounds;
449
+ this.onCorrelationFailure = options.onCorrelationFailure;
450
+ }
451
+ /**
452
+ * Process an incoming message.
453
+ * @returns CorrelationFailureResult if message failed correlation, undefined otherwise
454
+ */
455
+ async processMessage(envelope) {
456
+ const message = envelope.payload;
457
+ const correlation = this.definition.getCorrelation(message);
458
+ const correlationId = correlation.getCorrelationId(message);
459
+ if (!correlationId) {
460
+ this.logger.warn("Could not correlate message", {
461
+ sagaName: this.definition.name,
462
+ messageType: message.type,
463
+ messageId: envelope.id
464
+ });
465
+ let action = "drop";
466
+ if (this.onCorrelationFailure) {
467
+ action = await this.onCorrelationFailure({
468
+ envelope,
469
+ sagaName: this.definition.name,
470
+ messageType: message.type
471
+ });
472
+ }
473
+ return {
474
+ failed: true,
475
+ action,
476
+ messageType: message.type
477
+ };
478
+ }
479
+ const existingState = await this.store.getByCorrelationId(
480
+ this.definition.name,
481
+ correlationId
482
+ );
483
+ let traceContext;
484
+ const pipelineCtx = {
485
+ envelope,
486
+ sagaName: this.definition.name,
487
+ correlationId,
488
+ existingState,
489
+ // Provide existing state to middleware
490
+ metadata: {},
491
+ setTraceContext(traceParent, traceState) {
492
+ traceContext = { traceParent, traceState };
493
+ pipelineCtx.traceContext = traceContext;
494
+ }
495
+ };
496
+ try {
497
+ await this.pipeline.execute(pipelineCtx, async () => {
498
+ await this.handleMessage(envelope, correlationId, correlation.canStart, existingState, pipelineCtx);
499
+ });
500
+ } catch (error) {
501
+ pipelineCtx.error = error;
502
+ const wrappedError = new SagaProcessingError(
503
+ error instanceof Error ? error : new Error(String(error)),
504
+ {
505
+ sagaName: this.definition.name,
506
+ correlationId,
507
+ sagaId: pipelineCtx.sagaId,
508
+ messageType: message.type,
509
+ messageId: envelope.id
510
+ }
511
+ );
512
+ throw wrappedError;
513
+ }
514
+ }
515
+ async handleMessage(envelope, correlationId, canStart, existingState, pipelineCtx) {
516
+ const message = envelope.payload;
517
+ let state = existingState;
518
+ let sagaId;
519
+ if (!state) {
520
+ if (!canStart) {
521
+ this.logger.debug("Message cannot start saga and no existing saga found", {
522
+ sagaName: this.definition.name,
523
+ messageType: message.type,
524
+ correlationId
525
+ });
526
+ return;
527
+ }
528
+ sagaId = generateSagaId();
529
+ const ctx2 = new SagaContextImpl({
530
+ sagaName: this.definition.name,
531
+ sagaId,
532
+ correlationId,
533
+ envelope,
534
+ transport: this.transport,
535
+ timeoutBounds: this.timeoutBounds
536
+ });
537
+ state = await this.definition.createInitialState(message, ctx2);
538
+ state = {
539
+ ...state,
540
+ metadata: {
541
+ ...state.metadata,
542
+ sagaId,
543
+ version: 0,
544
+ createdAt: now(),
545
+ updatedAt: now(),
546
+ isCompleted: false,
547
+ traceParent: pipelineCtx.traceContext?.traceParent ?? null,
548
+ traceState: pipelineCtx.traceContext?.traceState ?? null
549
+ }
550
+ };
551
+ await this.store.insert(this.definition.name, correlationId, state);
552
+ this.logger.info("Created new saga instance", {
553
+ sagaName: this.definition.name,
554
+ sagaId,
555
+ correlationId,
556
+ messageType: message.type
557
+ });
558
+ } else {
559
+ sagaId = state.metadata.sagaId;
560
+ }
561
+ pipelineCtx.sagaId = sagaId;
562
+ pipelineCtx.preState = state;
563
+ if (state.metadata.isCompleted) {
564
+ this.logger.debug("Ignoring message for completed saga", {
565
+ sagaName: this.definition.name,
566
+ sagaId,
567
+ messageType: message.type
568
+ });
569
+ return;
570
+ }
571
+ const ctx = new SagaContextImpl({
572
+ sagaName: this.definition.name,
573
+ sagaId,
574
+ correlationId,
575
+ envelope,
576
+ transport: this.transport,
577
+ defaultEndpoint: this.defaultEndpoint,
578
+ currentMetadata: state.metadata,
579
+ timeoutBounds: this.timeoutBounds
580
+ });
581
+ const result = await this.definition.handle(message, state, ctx);
582
+ const isCompleted = result.isCompleted ?? ctx.isCompleted;
583
+ const expectedVersion = state.metadata.version;
584
+ const pendingTimeout = ctx.pendingTimeoutChange;
585
+ let timeoutMs = state.metadata.timeoutMs;
586
+ let timeoutExpiresAt = state.metadata.timeoutExpiresAt;
587
+ if (pendingTimeout) {
588
+ if (pendingTimeout.type === "clear") {
589
+ timeoutMs = null;
590
+ timeoutExpiresAt = null;
591
+ } else if (pendingTimeout.type === "set") {
592
+ timeoutMs = pendingTimeout.timeoutMs ?? null;
593
+ timeoutExpiresAt = pendingTimeout.timeoutExpiresAt ?? null;
594
+ }
595
+ }
596
+ const newState = {
597
+ ...result.newState,
598
+ metadata: {
599
+ ...result.newState.metadata,
600
+ version: expectedVersion + 1,
601
+ updatedAt: now(),
602
+ isCompleted,
603
+ timeoutMs,
604
+ timeoutExpiresAt
605
+ }
606
+ };
607
+ pipelineCtx.postState = newState;
608
+ pipelineCtx.handlerResult = result;
609
+ try {
610
+ await this.store.update(this.definition.name, newState, expectedVersion);
611
+ } catch (error) {
612
+ if (error instanceof ConcurrencyError) {
613
+ this.logger.warn("Concurrency conflict, message will be retried", {
614
+ sagaName: this.definition.name,
615
+ sagaId,
616
+ expectedVersion,
617
+ actualVersion: error.actualVersion
618
+ });
619
+ }
620
+ throw error;
621
+ }
622
+ if (pendingTimeout?.type === "set" && !isCompleted && pendingTimeout.timeoutMs) {
623
+ await this.scheduleTimeoutMessage(
624
+ sagaId,
625
+ correlationId,
626
+ pendingTimeout.timeoutMs,
627
+ pendingTimeout.timeoutExpiresAt ?? /* @__PURE__ */ new Date()
628
+ );
629
+ }
630
+ if (isCompleted) {
631
+ this.logger.info("Saga completed", {
632
+ sagaName: this.definition.name,
633
+ sagaId,
634
+ correlationId
635
+ });
636
+ } else {
637
+ this.logger.debug("Saga state updated", {
638
+ sagaName: this.definition.name,
639
+ sagaId,
640
+ version: newState.metadata.version
641
+ });
642
+ }
643
+ }
644
+ /**
645
+ * Schedule a timeout message for delayed delivery.
646
+ */
647
+ async scheduleTimeoutMessage(sagaId, correlationId, timeoutMs, timeoutExpiresAt) {
648
+ const timeoutMessage = {
649
+ type: SAGA_TIMEOUT_MESSAGE_TYPE,
650
+ sagaId,
651
+ sagaName: this.definition.name,
652
+ correlationId,
653
+ timeoutMs,
654
+ timeoutSetAt: new Date(timeoutExpiresAt.getTime() - timeoutMs)
655
+ };
656
+ const endpoint = this.defaultEndpoint ?? SAGA_TIMEOUT_MESSAGE_TYPE;
657
+ await this.transport.publish(timeoutMessage, {
658
+ endpoint,
659
+ delayMs: timeoutMs,
660
+ key: correlationId
661
+ // Use correlation ID for ordering
662
+ });
663
+ this.logger.debug("Scheduled timeout message", {
664
+ sagaName: this.definition.name,
665
+ sagaId,
666
+ correlationId,
667
+ timeoutMs,
668
+ timeoutExpiresAt: timeoutExpiresAt.toISOString()
669
+ });
670
+ }
671
+ /**
672
+ * Get the saga name.
673
+ */
674
+ get name() {
675
+ return this.definition.name;
676
+ }
677
+ /**
678
+ * Get the handled message types.
679
+ */
680
+ get handledMessageTypes() {
681
+ return this.definition.handledMessageTypes;
682
+ }
683
+ };
684
+
685
+ // src/runtime/MiddlewarePipeline.ts
686
+ var MiddlewarePipeline = class {
687
+ middleware;
688
+ constructor(middleware = []) {
689
+ this.middleware = middleware;
690
+ }
691
+ /**
692
+ * Execute the pipeline with a core handler at the end.
693
+ */
694
+ async execute(ctx, coreHandler) {
695
+ let index = 0;
696
+ const next = async () => {
697
+ if (index < this.middleware.length) {
698
+ const mw = this.middleware[index];
699
+ index++;
700
+ if (mw) {
701
+ await mw(ctx, next);
702
+ }
703
+ } else {
704
+ await coreHandler();
705
+ }
706
+ };
707
+ await next();
708
+ }
709
+ };
710
+
711
+ // src/runtime/DefaultLogger.ts
712
+ var DefaultLogger = class {
713
+ prefix;
714
+ constructor(prefix = "[saga-bus]") {
715
+ this.prefix = prefix;
716
+ }
717
+ debug(message, meta) {
718
+ if (meta) {
719
+ console.debug(`${this.prefix} ${message}`, meta);
720
+ } else {
721
+ console.debug(`${this.prefix} ${message}`);
722
+ }
723
+ }
724
+ info(message, meta) {
725
+ if (meta) {
726
+ console.info(`${this.prefix} ${message}`, meta);
727
+ } else {
728
+ console.info(`${this.prefix} ${message}`);
729
+ }
730
+ }
731
+ warn(message, meta) {
732
+ if (meta) {
733
+ console.warn(`${this.prefix} ${message}`, meta);
734
+ } else {
735
+ console.warn(`${this.prefix} ${message}`);
736
+ }
737
+ }
738
+ error(message, meta) {
739
+ if (meta) {
740
+ console.error(`${this.prefix} ${message}`, meta);
741
+ } else {
742
+ console.error(`${this.prefix} ${message}`);
743
+ }
744
+ }
745
+ };
746
+
747
+ // src/runtime/DefaultErrorHandler.ts
748
+ var TRANSIENT_ERROR_PATTERNS = [
749
+ /ECONNREFUSED/i,
750
+ /ECONNRESET/i,
751
+ /ETIMEDOUT/i,
752
+ /ENOTFOUND/i,
753
+ /timeout/i,
754
+ /connection.*refused/i,
755
+ /connection.*reset/i,
756
+ /network/i,
757
+ /socket hang up/i,
758
+ /EPIPE/i,
759
+ /EHOSTUNREACH/i
760
+ ];
761
+ function isTransientError(error) {
762
+ if (error instanceof TransientError) {
763
+ return true;
764
+ }
765
+ if (error instanceof ConcurrencyError) {
766
+ return true;
767
+ }
768
+ if (error instanceof Error) {
769
+ const message = error.message;
770
+ return TRANSIENT_ERROR_PATTERNS.some((pattern) => pattern.test(message));
771
+ }
772
+ return false;
773
+ }
774
+ var DefaultErrorHandler = class {
775
+ async handle(error, _ctx) {
776
+ if (isTransientError(error)) {
777
+ return "retry";
778
+ }
779
+ return "dlq";
780
+ }
781
+ };
782
+ function createErrorHandler(options) {
783
+ const additionalPatterns = options?.additionalTransientPatterns ?? [];
784
+ const customClassifier = options?.customClassifier;
785
+ return {
786
+ async handle(error, ctx) {
787
+ if (customClassifier) {
788
+ const result = customClassifier(error, ctx);
789
+ if (result !== null) {
790
+ return result;
791
+ }
792
+ }
793
+ if (isTransientError(error)) {
794
+ return "retry";
795
+ }
796
+ if (error instanceof Error) {
797
+ const message = error.message;
798
+ if (additionalPatterns.some((pattern) => pattern.test(message))) {
799
+ return "retry";
800
+ }
801
+ }
802
+ return "dlq";
803
+ }
804
+ };
805
+ }
806
+
807
+ // src/runtime/RetryHandler.ts
808
+ var RETRY_HEADERS = {
809
+ ATTEMPT: "x-saga-attempt",
810
+ FIRST_SEEN: "x-saga-first-seen",
811
+ ORIGINAL_ENDPOINT: "x-saga-original-endpoint",
812
+ ERROR_MESSAGE: "x-saga-error-message",
813
+ ERROR_TYPE: "x-saga-error-type"
814
+ };
815
+ var DEFAULT_RETRY_POLICY = {
816
+ maxAttempts: 3,
817
+ baseDelayMs: 1e3,
818
+ maxDelayMs: 3e4,
819
+ backoff: "exponential"
820
+ };
821
+ function defaultDlqNaming(endpoint) {
822
+ return `${endpoint}.dlq`;
823
+ }
824
+ function calculateDelay(policy, attempt) {
825
+ let delay;
826
+ if (policy.backoff === "linear") {
827
+ delay = policy.baseDelayMs * attempt;
828
+ } else {
829
+ delay = policy.baseDelayMs * Math.pow(2, attempt - 1);
830
+ }
831
+ return Math.min(delay, policy.maxDelayMs);
832
+ }
833
+ function getAttemptCount(envelope) {
834
+ const attempt = envelope.headers[RETRY_HEADERS.ATTEMPT];
835
+ if (attempt) {
836
+ const parsed = parseInt(attempt, 10);
837
+ return isNaN(parsed) ? 1 : parsed;
838
+ }
839
+ return 1;
840
+ }
841
+ function getFirstSeen(envelope) {
842
+ const firstSeen = envelope.headers[RETRY_HEADERS.FIRST_SEEN];
843
+ if (firstSeen) {
844
+ return new Date(firstSeen);
845
+ }
846
+ return envelope.timestamp;
847
+ }
848
+ var RetryHandler = class {
849
+ transport;
850
+ logger;
851
+ defaultPolicy;
852
+ dlqNaming;
853
+ constructor(options) {
854
+ this.transport = options.transport;
855
+ this.logger = options.logger;
856
+ this.defaultPolicy = options.defaultPolicy;
857
+ this.dlqNaming = options.dlqNaming;
858
+ }
859
+ /**
860
+ * Handle a failed message - either retry or send to DLQ.
861
+ *
862
+ * @returns true if message was retried, false if sent to DLQ
863
+ */
864
+ async handleFailure(envelope, endpoint, error, policy = this.defaultPolicy) {
865
+ const attempt = getAttemptCount(envelope);
866
+ const firstSeen = getFirstSeen(envelope);
867
+ if (attempt < policy.maxAttempts) {
868
+ const delay = calculateDelay(policy, attempt);
869
+ const nextAttempt = attempt + 1;
870
+ this.logger.info("Retrying message", {
871
+ messageId: envelope.id,
872
+ messageType: envelope.type,
873
+ endpoint,
874
+ attempt: nextAttempt,
875
+ maxAttempts: policy.maxAttempts,
876
+ delayMs: delay
877
+ });
878
+ const retryHeaders = {
879
+ ...envelope.headers,
880
+ [RETRY_HEADERS.ATTEMPT]: String(nextAttempt),
881
+ [RETRY_HEADERS.FIRST_SEEN]: firstSeen.toISOString(),
882
+ [RETRY_HEADERS.ORIGINAL_ENDPOINT]: envelope.headers[RETRY_HEADERS.ORIGINAL_ENDPOINT] ?? endpoint
883
+ };
884
+ await this.transport.publish(envelope.payload, {
885
+ endpoint,
886
+ headers: retryHeaders,
887
+ delayMs: delay,
888
+ key: envelope.partitionKey
889
+ });
890
+ return true;
891
+ }
892
+ await this.sendToDlq(envelope, endpoint, error);
893
+ return false;
894
+ }
895
+ /**
896
+ * Send a message to the dead-letter queue.
897
+ */
898
+ async sendToDlq(envelope, originalEndpoint, error) {
899
+ const dlqEndpoint = this.dlqNaming(originalEndpoint);
900
+ const attempt = getAttemptCount(envelope);
901
+ const firstSeen = getFirstSeen(envelope);
902
+ const errorMessage = error instanceof Error ? error.message : String(error);
903
+ const errorType = error instanceof Error ? error.name : "UnknownError";
904
+ this.logger.warn("Sending message to DLQ", {
905
+ messageId: envelope.id,
906
+ messageType: envelope.type,
907
+ originalEndpoint,
908
+ dlqEndpoint,
909
+ attempts: attempt,
910
+ firstSeen: firstSeen.toISOString(),
911
+ error: errorMessage
912
+ });
913
+ const dlqHeaders = {
914
+ ...envelope.headers,
915
+ [RETRY_HEADERS.ATTEMPT]: String(attempt),
916
+ [RETRY_HEADERS.FIRST_SEEN]: firstSeen.toISOString(),
917
+ [RETRY_HEADERS.ORIGINAL_ENDPOINT]: envelope.headers[RETRY_HEADERS.ORIGINAL_ENDPOINT] ?? originalEndpoint,
918
+ [RETRY_HEADERS.ERROR_MESSAGE]: errorMessage,
919
+ [RETRY_HEADERS.ERROR_TYPE]: errorType
920
+ };
921
+ await this.transport.publish(envelope.payload, {
922
+ endpoint: dlqEndpoint,
923
+ headers: dlqHeaders,
924
+ key: envelope.partitionKey
925
+ });
926
+ }
927
+ };
928
+
929
+ // src/runtime/BusImpl.ts
930
+ var BusImpl = class {
931
+ config;
932
+ logger;
933
+ errorHandler;
934
+ pipeline;
935
+ orchestrators;
936
+ retryHandler;
937
+ defaultRetryPolicy;
938
+ started = false;
939
+ constructor(config) {
940
+ this.config = config;
941
+ this.logger = config.logger ?? new DefaultLogger();
942
+ this.errorHandler = config.errorHandler ?? new DefaultErrorHandler();
943
+ this.pipeline = new MiddlewarePipeline(config.middleware ? [...config.middleware] : []);
944
+ this.defaultRetryPolicy = config.worker?.retryPolicy ?? DEFAULT_RETRY_POLICY;
945
+ const dlqNaming = config.worker?.dlqNaming ?? defaultDlqNaming;
946
+ this.retryHandler = new RetryHandler({
947
+ transport: config.transport,
948
+ logger: this.logger,
949
+ defaultPolicy: this.defaultRetryPolicy,
950
+ dlqNaming
951
+ });
952
+ this.orchestrators = config.sagas.map((registration) => {
953
+ const store = registration.store ?? config.store;
954
+ if (!store) {
955
+ throw new Error(
956
+ `Saga "${registration.definition.name}" has no store. Provide a store in the saga registration or set a default store in BusConfig.`
957
+ );
958
+ }
959
+ return new SagaOrchestrator({
960
+ definition: registration.definition,
961
+ store,
962
+ transport: config.transport,
963
+ pipeline: this.pipeline,
964
+ logger: this.logger,
965
+ timeoutBounds: config.worker?.timeoutBounds,
966
+ onCorrelationFailure: config.worker?.onCorrelationFailure
967
+ });
968
+ });
969
+ }
970
+ async start() {
971
+ if (this.started) {
972
+ return;
973
+ }
974
+ this.logger.info("Starting saga bus...");
975
+ await this.config.transport.start();
976
+ const subscribed = /* @__PURE__ */ new Set();
977
+ for (const orchestrator of this.orchestrators) {
978
+ for (const messageType of orchestrator.handledMessageTypes) {
979
+ const endpoint = messageType;
980
+ const subscriptionKey = endpoint;
981
+ if (subscribed.has(subscriptionKey)) {
982
+ continue;
983
+ }
984
+ subscribed.add(subscriptionKey);
985
+ const concurrency = this.config.worker?.sagas?.[orchestrator.name]?.concurrency ?? this.config.worker?.defaultConcurrency ?? 1;
986
+ await this.config.transport.subscribe(
987
+ { endpoint, concurrency },
988
+ async (envelope) => {
989
+ const handlers = this.orchestrators.filter(
990
+ (o) => o.handledMessageTypes.includes(envelope.type)
991
+ );
992
+ for (const handler of handlers) {
993
+ const retryPolicy = this.config.worker?.sagas?.[handler.name]?.retryPolicy ?? this.defaultRetryPolicy;
994
+ try {
995
+ const result = await handler.processMessage(envelope);
996
+ if (result?.failed) {
997
+ if (result.action === "dlq") {
998
+ await this.retryHandler.sendToDlq(
999
+ envelope,
1000
+ endpoint,
1001
+ new Error(`Correlation failed for message type ${result.messageType}`)
1002
+ );
1003
+ }
1004
+ continue;
1005
+ }
1006
+ } catch (error) {
1007
+ const errorContext = SagaProcessingError.extractContext(error);
1008
+ const originalError = error instanceof SagaProcessingError ? error.cause : error;
1009
+ this.logger.error("Error processing message", {
1010
+ sagaName: handler.name,
1011
+ messageType: envelope.type,
1012
+ messageId: envelope.id,
1013
+ correlationId: errorContext?.correlationId,
1014
+ sagaId: errorContext?.sagaId,
1015
+ attempt: getAttemptCount(envelope),
1016
+ error: originalError instanceof Error ? originalError.message : String(originalError)
1017
+ });
1018
+ const action = await this.errorHandler.handle(originalError, {
1019
+ envelope,
1020
+ sagaName: handler.name,
1021
+ correlationId: errorContext?.correlationId ?? "",
1022
+ metadata: {
1023
+ sagaId: errorContext?.sagaId
1024
+ },
1025
+ error: originalError,
1026
+ setTraceContext: () => {
1027
+ }
1028
+ // No-op for error context
1029
+ });
1030
+ if (action === "retry") {
1031
+ await this.retryHandler.handleFailure(
1032
+ envelope,
1033
+ endpoint,
1034
+ error,
1035
+ retryPolicy
1036
+ );
1037
+ } else if (action === "dlq") {
1038
+ await this.retryHandler.sendToDlq(envelope, endpoint, error);
1039
+ }
1040
+ }
1041
+ }
1042
+ }
1043
+ );
1044
+ this.logger.debug("Subscribed to endpoint", {
1045
+ endpoint,
1046
+ messageType,
1047
+ concurrency
1048
+ });
1049
+ }
1050
+ }
1051
+ this.started = true;
1052
+ this.logger.info("Saga bus started", {
1053
+ sagaCount: this.orchestrators.length,
1054
+ endpointCount: subscribed.size
1055
+ });
1056
+ }
1057
+ async stop() {
1058
+ if (!this.started) {
1059
+ return;
1060
+ }
1061
+ this.logger.info("Stopping saga bus...");
1062
+ await this.config.transport.stop();
1063
+ this.started = false;
1064
+ this.logger.info("Saga bus stopped");
1065
+ }
1066
+ isRunning() {
1067
+ return this.started;
1068
+ }
1069
+ async publish(message, options) {
1070
+ const endpoint = options?.endpoint ?? message.type;
1071
+ await this.config.transport.publish(message, {
1072
+ endpoint,
1073
+ ...options
1074
+ });
1075
+ }
1076
+ };
1077
+ function createBus(config) {
1078
+ return new BusImpl(config);
1079
+ }
1080
+ // Annotate the CommonJS export names for ESM import in node:
1081
+ 0 && (module.exports = {
1082
+ ConcurrencyError,
1083
+ DEFAULT_RETRY_POLICY,
1084
+ DEFAULT_TIMEOUT_BOUNDS,
1085
+ DefaultErrorHandler,
1086
+ DefaultLogger,
1087
+ RETRY_HEADERS,
1088
+ SagaMachineBuilder,
1089
+ SagaProcessingError,
1090
+ TransientError,
1091
+ ValidationError,
1092
+ createBus,
1093
+ createErrorHandler,
1094
+ createSagaMachine,
1095
+ defaultDlqNaming
1096
+ });
1097
+ //# sourceMappingURL=index.cjs.map