@signaltree/events 7.3.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/nestjs.esm.js ADDED
@@ -0,0 +1,944 @@
1
+ import { __decorate, __param, __metadata } from 'tslib';
2
+ import { Injectable, Inject, Logger, Module, SetMetadata } from '@nestjs/common';
3
+ import { Queue, QueueEvents, Worker } from 'bullmq';
4
+ import { g as generateEventId, b as generateCorrelationId } from './factory.esm.js';
5
+ import { E as EventRegistry, c as createEventRegistry, e as createInMemoryIdempotencyStore, b as createErrorClassifier } from './idempotency.esm.js';
6
+ import 'reflect-metadata';
7
+
8
+ /**
9
+ * Injection tokens for the EventBus module
10
+ */
11
+ const EVENT_BUS_CONFIG = Symbol('EVENT_BUS_CONFIG');
12
+ const EVENT_REGISTRY = Symbol('EVENT_REGISTRY');
13
+ const IDEMPOTENCY_STORE = Symbol('IDEMPOTENCY_STORE');
14
+ const ERROR_CLASSIFIER = Symbol('ERROR_CLASSIFIER');
15
+
16
+ var DlqService_1;
17
+ let DlqService = DlqService_1 = class DlqService {
18
+ config;
19
+ logger = new Logger(DlqService_1.name);
20
+ queue;
21
+ queueName;
22
+ constructor(config) {
23
+ this.config = config;
24
+ this.queueName = config.dlqQueueName ?? 'dead-letter';
25
+ }
26
+ async onModuleInit() {
27
+ if (!this.config.enableDlq) {
28
+ this.logger.log('DLQ is disabled');
29
+ return;
30
+ }
31
+ this.queue = new Queue(this.queueName, {
32
+ connection: {
33
+ host: this.config.redis.host,
34
+ port: this.config.redis.port,
35
+ password: this.config.redis.password,
36
+ db: this.config.redis.db
37
+ },
38
+ defaultJobOptions: {
39
+ removeOnComplete: false,
40
+ // Keep for inspection
41
+ removeOnFail: false,
42
+ attempts: 1 // No retries in DLQ
43
+ }
44
+ });
45
+ this.logger.log(`DLQ "${this.queueName}" initialized`);
46
+ }
47
+ async onModuleDestroy() {
48
+ if (this.queue) {
49
+ await this.queue.close();
50
+ }
51
+ }
52
+ /**
53
+ * Send an event to the DLQ
54
+ */
55
+ async send(entry) {
56
+ if (!this.queue) {
57
+ throw new Error('DLQ is not initialized');
58
+ }
59
+ const job = await this.queue.add(`dlq:${entry.event.type}`, entry, {
60
+ jobId: `dlq:${entry.event.id}:${entry.subscriber}`
61
+ });
62
+ this.logger.debug(`Event ${entry.event.id} sent to DLQ (job: ${job.id})`);
63
+ return job.id ?? `dlq:${entry.event.id}:${entry.subscriber}`;
64
+ }
65
+ /**
66
+ * Get entries from DLQ
67
+ */
68
+ async getEntries(options = {}) {
69
+ if (!this.queue) {
70
+ return [];
71
+ }
72
+ const start = options.start ?? 0;
73
+ const end = options.end ?? 100;
74
+ const jobs = await this.queue.getJobs(['waiting', 'active', 'delayed', 'failed'], start, end);
75
+ let entries = jobs.map(job => job.data);
76
+ // Apply filters
77
+ if (options.eventType) {
78
+ entries = entries.filter(e => e.event.type === options.eventType);
79
+ }
80
+ if (options.subscriber) {
81
+ entries = entries.filter(e => e.subscriber === options.subscriber);
82
+ }
83
+ if (options.classification) {
84
+ entries = entries.filter(e => e.error.classification === options.classification);
85
+ }
86
+ if (options.from) {
87
+ const fromDate = options.from;
88
+ entries = entries.filter(e => new Date(e.failedAt) >= fromDate);
89
+ }
90
+ if (options.to) {
91
+ const toDate = options.to;
92
+ entries = entries.filter(e => new Date(e.failedAt) <= toDate);
93
+ }
94
+ return entries;
95
+ }
96
+ /**
97
+ * Get a specific entry by event ID
98
+ */
99
+ async getEntry(eventId, subscriber) {
100
+ if (!this.queue) {
101
+ return null;
102
+ }
103
+ // Try with subscriber suffix first
104
+ if (subscriber) {
105
+ const job = await this.queue.getJob(`dlq:${eventId}:${subscriber}`);
106
+ if (job) {
107
+ return job.data;
108
+ }
109
+ }
110
+ // Search through jobs
111
+ const jobs = await this.queue.getJobs(['waiting', 'active', 'delayed', 'failed']);
112
+ const job = jobs.find(j => j.data.event.id === eventId);
113
+ return job ? job.data : null;
114
+ }
115
+ /**
116
+ * Get DLQ statistics
117
+ */
118
+ async getStats() {
119
+ const entries = await this.getEntries({
120
+ start: 0,
121
+ end: 10000
122
+ });
123
+ const stats = {
124
+ total: entries.length,
125
+ byEventType: {},
126
+ bySubscriber: {},
127
+ byClassification: {
128
+ transient: 0,
129
+ permanent: 0,
130
+ poison: 0,
131
+ unknown: 0
132
+ }
133
+ };
134
+ for (const entry of entries) {
135
+ // By event type
136
+ stats.byEventType[entry.event.type] = (stats.byEventType[entry.event.type] ?? 0) + 1;
137
+ // By subscriber
138
+ stats.bySubscriber[entry.subscriber] = (stats.bySubscriber[entry.subscriber] ?? 0) + 1;
139
+ // By classification
140
+ stats.byClassification[entry.error.classification]++;
141
+ // Date tracking
142
+ const failedDate = new Date(entry.failedAt);
143
+ if (!stats.oldestEntry || failedDate < stats.oldestEntry) {
144
+ stats.oldestEntry = failedDate;
145
+ }
146
+ if (!stats.newestEntry || failedDate > stats.newestEntry) {
147
+ stats.newestEntry = failedDate;
148
+ }
149
+ }
150
+ return stats;
151
+ }
152
+ /**
153
+ * Replay an event from DLQ
154
+ *
155
+ * This removes the event from DLQ and republishes to original queue
156
+ */
157
+ async replay(eventId, subscriber, targetQueue) {
158
+ if (!this.queue) {
159
+ throw new Error('DLQ is not initialized');
160
+ }
161
+ const jobId = `dlq:${eventId}:${subscriber}`;
162
+ const job = await this.queue.getJob(jobId);
163
+ if (!job) {
164
+ this.logger.warn(`DLQ entry not found: ${jobId}`);
165
+ return false;
166
+ }
167
+ const entry = job.data;
168
+ // Get target queue
169
+ const targetQueueInstance = new Queue(targetQueue, {
170
+ connection: {
171
+ host: this.config.redis.host,
172
+ port: this.config.redis.port,
173
+ password: this.config.redis.password,
174
+ db: this.config.redis.db
175
+ }
176
+ });
177
+ try {
178
+ // Republish to original queue
179
+ await targetQueueInstance.add(entry.event.type, entry.event, {
180
+ jobId: `replay:${entry.event.id}`
181
+ });
182
+ // Remove from DLQ
183
+ await job.remove();
184
+ this.logger.log(`Replayed event ${eventId} to queue ${targetQueue}`);
185
+ return true;
186
+ } finally {
187
+ await targetQueueInstance.close();
188
+ }
189
+ }
190
+ /**
191
+ * Replay all events matching criteria
192
+ */
193
+ async replayBatch(options) {
194
+ const entries = await this.getEntries(options);
195
+ let replayed = 0;
196
+ let failed = 0;
197
+ for (const entry of entries) {
198
+ try {
199
+ const success = await this.replay(entry.event.id, entry.subscriber, options.targetQueue);
200
+ if (success) replayed++;else failed++;
201
+ } catch {
202
+ failed++;
203
+ }
204
+ }
205
+ return {
206
+ replayed,
207
+ failed
208
+ };
209
+ }
210
+ /**
211
+ * Remove an entry from DLQ
212
+ */
213
+ async remove(eventId, subscriber) {
214
+ if (!this.queue) {
215
+ return false;
216
+ }
217
+ const job = await this.queue.getJob(`dlq:${eventId}:${subscriber}`);
218
+ if (job) {
219
+ await job.remove();
220
+ return true;
221
+ }
222
+ return false;
223
+ }
224
+ /**
225
+ * Purge old entries from DLQ
226
+ */
227
+ async purge(olderThan) {
228
+ const entries = await this.getEntries({
229
+ to: olderThan,
230
+ start: 0,
231
+ end: 10000
232
+ });
233
+ let purged = 0;
234
+ for (const entry of entries) {
235
+ const removed = await this.remove(entry.event.id, entry.subscriber);
236
+ if (removed) purged++;
237
+ }
238
+ this.logger.log(`Purged ${purged} entries from DLQ older than ${olderThan.toISOString()}`);
239
+ return purged;
240
+ }
241
+ /**
242
+ * Clear all entries from DLQ
243
+ */
244
+ async clear() {
245
+ if (!this.queue) {
246
+ return 0;
247
+ }
248
+ const count = await this.queue.getJobCounts();
249
+ const total = count.waiting + count.active + count.delayed + count.failed;
250
+ await this.queue.obliterate({
251
+ force: true
252
+ });
253
+ this.logger.warn(`Cleared all ${total} entries from DLQ`);
254
+ return total;
255
+ }
256
+ };
257
+ DlqService = DlqService_1 = __decorate([Injectable(), __param(0, Inject(EVENT_BUS_CONFIG)), __metadata("design:paramtypes", [Object])], DlqService);
258
+
259
+ var EventBusService_1;
260
+ let EventBusService = EventBusService_1 = class EventBusService {
261
+ config;
262
+ registry;
263
+ logger = new Logger(EventBusService_1.name);
264
+ queues = new Map();
265
+ priorityToQueue = new Map();
266
+ connection;
267
+ isReady = false;
268
+ constructor(config, registry) {
269
+ this.config = config;
270
+ this.registry = registry;
271
+ this.connection = {
272
+ host: config.redis.host,
273
+ port: config.redis.port,
274
+ password: config.redis.password,
275
+ db: config.redis.db,
276
+ maxRetriesPerRequest: config.redis.maxRetriesPerRequest ?? 3
277
+ };
278
+ }
279
+ async onModuleInit() {
280
+ this.logger.log('Initializing EventBus queues...');
281
+ // Create queues
282
+ for (const queueConfig of this.config.queues ?? []) {
283
+ const queue = new Queue(queueConfig.name, {
284
+ connection: this.connection,
285
+ defaultJobOptions: {
286
+ removeOnComplete: 1000,
287
+ // Keep last 1000 completed jobs
288
+ removeOnFail: 5000,
289
+ // Keep last 5000 failed jobs
290
+ attempts: 5,
291
+ backoff: {
292
+ type: 'exponential',
293
+ delay: 1000
294
+ }
295
+ }
296
+ });
297
+ // Map priorities to this queue
298
+ for (const priority of queueConfig.priorities) {
299
+ this.priorityToQueue.set(priority, queueConfig.name);
300
+ }
301
+ const instance = {
302
+ config: queueConfig,
303
+ queue
304
+ };
305
+ // Optionally create queue events listener for monitoring
306
+ if (this.config.enableMetrics) {
307
+ instance.events = new QueueEvents(queueConfig.name, {
308
+ connection: this.connection
309
+ });
310
+ this.setupQueueEventListeners(instance);
311
+ }
312
+ this.queues.set(queueConfig.name, instance);
313
+ this.logger.log(`Queue "${queueConfig.name}" initialized for priorities: ${queueConfig.priorities.join(', ')}`);
314
+ }
315
+ this.isReady = true;
316
+ this.logger.log(`EventBus ready with ${this.queues.size} queues`);
317
+ }
318
+ async onModuleDestroy() {
319
+ this.logger.log('Shutting down EventBus...');
320
+ const closePromises = [];
321
+ for (const [name, instance] of this.queues) {
322
+ this.logger.debug(`Closing queue "${name}"...`);
323
+ closePromises.push(instance.queue.close());
324
+ if (instance.events) {
325
+ closePromises.push(instance.events.close());
326
+ }
327
+ }
328
+ await Promise.all(closePromises);
329
+ this.queues.clear();
330
+ this.isReady = false;
331
+ this.logger.log('EventBus shutdown complete');
332
+ }
333
+ /**
334
+ * Publish an event
335
+ *
336
+ * @example
337
+ * ```typescript
338
+ * await eventBus.publish({
339
+ * type: 'TradeProposalCreated',
340
+ * data: {
341
+ * tradeId: '123',
342
+ * initiatorId: 'user-1',
343
+ * recipientId: 'user-2',
344
+ * },
345
+ * });
346
+ * ```
347
+ */
348
+ async publish(event, options = {}) {
349
+ if (!this.isReady) {
350
+ throw new Error('EventBus is not ready. Wait for module initialization.');
351
+ }
352
+ // Build complete event
353
+ const eventId = options.id ?? event.id ?? generateEventId();
354
+ const correlationId = options.correlationId ?? event.correlationId ?? generateCorrelationId();
355
+ const timestamp = event.timestamp ?? new Date().toISOString();
356
+ const fullEvent = {
357
+ ...event,
358
+ id: eventId,
359
+ correlationId,
360
+ causationId: options.causationId ?? event.causationId,
361
+ timestamp,
362
+ priority: options.priority ?? event.priority ?? 'normal'
363
+ };
364
+ // Validate event against registry
365
+ const validated = this.registry.validate(fullEvent);
366
+ // Determine queue based on priority
367
+ const priority = validated.priority ?? 'normal';
368
+ const queueName = options.queue ?? this.getQueueForPriority(priority);
369
+ const instance = this.queues.get(queueName);
370
+ if (!instance) {
371
+ throw new Error(`Queue "${queueName}" not found. Available: ${Array.from(this.queues.keys()).join(', ')}`);
372
+ }
373
+ // Publish to BullMQ
374
+ const jobId = options.jobId ?? eventId;
375
+ const job = await instance.queue.add(validated.type,
376
+ // Job name = event type
377
+ validated, {
378
+ jobId,
379
+ delay: options.delay,
380
+ priority: this.getPriorityNumber(priority),
381
+ ...options.jobOptions
382
+ });
383
+ this.logger.debug(`Published event ${validated.type}:${eventId} to queue ${queueName} (job: ${job.id})`);
384
+ return {
385
+ eventId,
386
+ jobId: job.id ?? eventId,
387
+ queue: queueName,
388
+ correlationId
389
+ };
390
+ }
391
+ /**
392
+ * Publish multiple events in a batch
393
+ */
394
+ async publishBatch(events, options = {}) {
395
+ // Use same correlation ID for all events in batch
396
+ const correlationId = options.correlationId ?? generateCorrelationId();
397
+ const results = await Promise.all(events.map((event, index) => this.publish(event, {
398
+ ...options,
399
+ correlationId,
400
+ causationId: index > 0 ? events[index - 1].id : options.causationId
401
+ })));
402
+ return results;
403
+ }
404
+ /**
405
+ * Get queue for a given priority
406
+ */
407
+ getQueueForPriority(priority) {
408
+ const queueName = this.priorityToQueue.get(priority);
409
+ if (!queueName) {
410
+ // Fall back to normal queue
411
+ return this.priorityToQueue.get('normal') ?? 'events-normal';
412
+ }
413
+ return queueName;
414
+ }
415
+ /**
416
+ * Get queue stats
417
+ */
418
+ async getQueueStats(queueName) {
419
+ const instance = this.queues.get(queueName);
420
+ if (!instance) {
421
+ throw new Error(`Queue "${queueName}" not found`);
422
+ }
423
+ const [waiting, active, completed, failed, delayed] = await Promise.all([instance.queue.getWaitingCount(), instance.queue.getActiveCount(), instance.queue.getCompletedCount(), instance.queue.getFailedCount(), instance.queue.getDelayedCount()]);
424
+ return {
425
+ waiting,
426
+ active,
427
+ completed,
428
+ failed,
429
+ delayed
430
+ };
431
+ }
432
+ /**
433
+ * Get all queue names
434
+ */
435
+ getQueueNames() {
436
+ return Array.from(this.queues.keys());
437
+ }
438
+ /**
439
+ * Get underlying BullMQ queue for advanced operations
440
+ */
441
+ getQueue(name) {
442
+ return this.queues.get(name)?.queue;
443
+ }
444
+ /**
445
+ * Check if service is ready
446
+ */
447
+ isServiceReady() {
448
+ return this.isReady;
449
+ }
450
+ /**
451
+ * Convert priority string to number for BullMQ (lower = higher priority)
452
+ */
453
+ getPriorityNumber(priority) {
454
+ switch (priority) {
455
+ case 'critical':
456
+ return 1;
457
+ case 'high':
458
+ return 2;
459
+ case 'normal':
460
+ return 3;
461
+ case 'low':
462
+ return 4;
463
+ case 'bulk':
464
+ return 5;
465
+ default:
466
+ return 3;
467
+ }
468
+ }
469
+ /**
470
+ * Setup event listeners for monitoring
471
+ */
472
+ setupQueueEventListeners(instance) {
473
+ if (!instance.events) return;
474
+ instance.events.on('completed', ({
475
+ jobId
476
+ }) => {
477
+ this.logger.debug(`Job ${jobId} completed in queue ${instance.config.name}`);
478
+ });
479
+ instance.events.on('failed', ({
480
+ jobId,
481
+ failedReason
482
+ }) => {
483
+ this.logger.warn(`Job ${jobId} failed in queue ${instance.config.name}: ${failedReason}`);
484
+ });
485
+ instance.events.on('stalled', ({
486
+ jobId
487
+ }) => {
488
+ this.logger.warn(`Job ${jobId} stalled in queue ${instance.config.name}`);
489
+ });
490
+ }
491
+ };
492
+ EventBusService = EventBusService_1 = __decorate([Injectable(), __param(0, Inject(EVENT_BUS_CONFIG)), __param(1, Inject(EVENT_REGISTRY)), __metadata("design:paramtypes", [Object, EventRegistry])], EventBusService);
493
+
494
+ var EventBusModule_1;
495
+ /**
496
+ * Default queue configuration based on priorities
497
+ */
498
+ const DEFAULT_QUEUES = [{
499
+ name: 'events-critical',
500
+ priorities: ['critical'],
501
+ concurrency: 10
502
+ }, {
503
+ name: 'events-high',
504
+ priorities: ['high'],
505
+ concurrency: 8
506
+ }, {
507
+ name: 'events-normal',
508
+ priorities: ['normal'],
509
+ concurrency: 5
510
+ }, {
511
+ name: 'events-low',
512
+ priorities: ['low'],
513
+ concurrency: 3
514
+ }, {
515
+ name: 'events-bulk',
516
+ priorities: ['bulk'],
517
+ concurrency: 2,
518
+ rateLimit: {
519
+ max: 100,
520
+ duration: 1000
521
+ }
522
+ }];
523
+ let EventBusModule = EventBusModule_1 = class EventBusModule {
524
+ /**
525
+ * Register the EventBus module with configuration
526
+ *
527
+ * @example
528
+ * ```typescript
529
+ * @Module({
530
+ * imports: [
531
+ * EventBusModule.forRoot({
532
+ * redis: { host: 'localhost', port: 6379 },
533
+ * queues: [
534
+ * { name: 'critical', priorities: ['critical'], concurrency: 10 },
535
+ * { name: 'normal', priorities: ['high', 'normal'], concurrency: 5 },
536
+ * ],
537
+ * }),
538
+ * ],
539
+ * })
540
+ * export class AppModule {}
541
+ * ```
542
+ */
543
+ static forRoot(config) {
544
+ const providers = EventBusModule_1.createProviders(config);
545
+ return {
546
+ module: EventBusModule_1,
547
+ global: true,
548
+ providers,
549
+ exports: [EventBusService, DlqService, EVENT_BUS_CONFIG, EVENT_REGISTRY, IDEMPOTENCY_STORE, ERROR_CLASSIFIER]
550
+ };
551
+ }
552
+ /**
553
+ * Register the EventBus module with async configuration
554
+ *
555
+ * @example
556
+ * ```typescript
557
+ * @Module({
558
+ * imports: [
559
+ * EventBusModule.forRootAsync({
560
+ * imports: [ConfigModule],
561
+ * useFactory: (configService: ConfigService) => ({
562
+ * redis: {
563
+ * host: configService.get('REDIS_HOST'),
564
+ * port: configService.get('REDIS_PORT'),
565
+ * },
566
+ * }),
567
+ * inject: [ConfigService],
568
+ * }),
569
+ * ],
570
+ * })
571
+ * export class AppModule {}
572
+ * ```
573
+ */
574
+ static forRootAsync(asyncConfig) {
575
+ const configProvider = {
576
+ provide: EVENT_BUS_CONFIG,
577
+ useFactory: asyncConfig.useFactory,
578
+ inject: asyncConfig.inject ?? []
579
+ };
580
+ const registryProvider = {
581
+ provide: EVENT_REGISTRY,
582
+ useFactory: config => {
583
+ return createEventRegistry(config.registry);
584
+ },
585
+ inject: [EVENT_BUS_CONFIG]
586
+ };
587
+ const idempotencyProvider = {
588
+ provide: IDEMPOTENCY_STORE,
589
+ useFactory: config => {
590
+ return config.idempotencyStore ?? createInMemoryIdempotencyStore();
591
+ },
592
+ inject: [EVENT_BUS_CONFIG]
593
+ };
594
+ const errorClassifierProvider = {
595
+ provide: ERROR_CLASSIFIER,
596
+ useFactory: config => {
597
+ return createErrorClassifier(config.errorClassifier);
598
+ },
599
+ inject: [EVENT_BUS_CONFIG]
600
+ };
601
+ return {
602
+ module: EventBusModule_1,
603
+ global: true,
604
+ imports: asyncConfig.imports ?? [],
605
+ providers: [configProvider, registryProvider, idempotencyProvider, errorClassifierProvider, EventBusService, DlqService],
606
+ exports: [EventBusService, DlqService, EVENT_BUS_CONFIG, EVENT_REGISTRY, IDEMPOTENCY_STORE, ERROR_CLASSIFIER]
607
+ };
608
+ }
609
+ static createProviders(config) {
610
+ const queues = config.queues ?? DEFAULT_QUEUES;
611
+ const fullConfig = {
612
+ ...config,
613
+ queues,
614
+ enableDlq: config.enableDlq ?? true,
615
+ dlqQueueName: config.dlqQueueName ?? 'dead-letter',
616
+ enableMetrics: config.enableMetrics ?? true,
617
+ metricsPrefix: config.metricsPrefix ?? 'signaltree_events'
618
+ };
619
+ return [{
620
+ provide: EVENT_BUS_CONFIG,
621
+ useValue: fullConfig
622
+ }, {
623
+ provide: EVENT_REGISTRY,
624
+ useFactory: () => createEventRegistry(config.registry)
625
+ }, {
626
+ provide: IDEMPOTENCY_STORE,
627
+ useValue: config.idempotencyStore ?? createInMemoryIdempotencyStore()
628
+ }, {
629
+ provide: ERROR_CLASSIFIER,
630
+ useFactory: () => createErrorClassifier(config.errorClassifier)
631
+ }, EventBusService, DlqService];
632
+ }
633
+ };
634
+ EventBusModule = EventBusModule_1 = __decorate([Module({})], EventBusModule);
635
+
636
+ /**
637
+ * Base class for event subscribers
638
+ *
639
+ * @example
640
+ * ```typescript
641
+ * @Injectable()
642
+ * export class TradeSubscriber extends BaseSubscriber {
643
+ * protected readonly config: SubscriberConfig = {
644
+ * name: 'trade-subscriber',
645
+ * eventTypes: ['TradeProposalCreated', 'TradeAccepted'],
646
+ * priority: 'high',
647
+ * concurrency: 5,
648
+ * };
649
+ *
650
+ * async handle(event: TradeProposalCreated | TradeAccepted): Promise<ProcessingResult> {
651
+ * switch (event.type) {
652
+ * case 'TradeProposalCreated':
653
+ * await this.handleTradeCreated(event);
654
+ * break;
655
+ * case 'TradeAccepted':
656
+ * await this.handleTradeAccepted(event);
657
+ * break;
658
+ * }
659
+ * return { success: true };
660
+ * }
661
+ * }
662
+ * ```
663
+ */
664
+ let BaseSubscriber = class BaseSubscriber {
665
+ busConfig;
666
+ idempotencyStore;
667
+ errorClassifier;
668
+ dlqService;
669
+ logger;
670
+ worker;
671
+ connection;
672
+ metrics = {
673
+ processed: 0,
674
+ succeeded: 0,
675
+ failed: 0,
676
+ retried: 0,
677
+ dlqSent: 0,
678
+ duplicatesSkipped: 0,
679
+ avgProcessingTimeMs: 0
680
+ };
681
+ constructor(busConfig, idempotencyStore, errorClassifier, dlqService) {
682
+ this.busConfig = busConfig;
683
+ this.idempotencyStore = idempotencyStore;
684
+ this.errorClassifier = errorClassifier;
685
+ this.dlqService = dlqService;
686
+ this.logger = new Logger(this.constructor.name);
687
+ this.connection = {
688
+ host: busConfig.redis.host,
689
+ port: busConfig.redis.port,
690
+ password: busConfig.redis.password,
691
+ db: busConfig.redis.db
692
+ };
693
+ }
694
+ /**
695
+ * Called before processing starts (for setup/validation)
696
+ */
697
+ async beforeProcess(
698
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
699
+ _event) {
700
+ // Override in subclass if needed
701
+ }
702
+ /**
703
+ * Called after processing completes (for cleanup)
704
+ */
705
+ async afterProcess(
706
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
707
+ _event,
708
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
709
+ _result) {
710
+ // Override in subclass if needed
711
+ }
712
+ async onModuleInit() {
713
+ const queueName = this.getQueueName();
714
+ this.logger.log(`Initializing subscriber "${this.config.name}" on queue "${queueName}" ` + `for events: ${this.config.eventTypes.join(', ')}`);
715
+ this.worker = new Worker(queueName, async job => this.processJob(job), {
716
+ connection: this.connection,
717
+ concurrency: this.config.concurrency ?? 5,
718
+ lockDuration: this.config.lockDuration ?? 30000,
719
+ stalledInterval: this.config.stalledInterval ?? 30000
720
+ });
721
+ this.setupWorkerListeners();
722
+ this.logger.log(`Subscriber "${this.config.name}" started`);
723
+ }
724
+ async onModuleDestroy() {
725
+ this.logger.log(`Shutting down subscriber "${this.config.name}"...`);
726
+ if (this.worker) {
727
+ await this.worker.close();
728
+ }
729
+ this.logger.log(`Subscriber "${this.config.name}" stopped`);
730
+ }
731
+ /**
732
+ * Get queue name based on config
733
+ */
734
+ getQueueName() {
735
+ if (this.config.queue) {
736
+ return this.config.queue;
737
+ }
738
+ // Find queue for priority
739
+ const priority = this.config.priority ?? 'normal';
740
+ const queueConfig = this.busConfig.queues?.find(q => q.priorities.includes(priority));
741
+ return queueConfig?.name ?? `events-${priority}`;
742
+ }
743
+ /**
744
+ * Process a job with idempotency and error handling
745
+ */
746
+ async processJob(job) {
747
+ const event = job.data;
748
+ const startTime = Date.now();
749
+ // Filter by event type
750
+ if (!this.config.eventTypes.includes(event.type)) {
751
+ // Not our event type, skip silently
752
+ return {
753
+ skipped: true,
754
+ reason: 'event_type_not_handled'
755
+ };
756
+ }
757
+ this.logger.debug(`Processing event ${event.type}:${event.id} (attempt ${job.attemptsMade + 1})`);
758
+ try {
759
+ // Check idempotency
760
+ if (this.config.idempotency !== false) {
761
+ const idempotencyResult = await this.checkIdempotency(event);
762
+ if (idempotencyResult.isDuplicate) {
763
+ this.metrics.duplicatesSkipped++;
764
+ this.logger.debug(`Skipping duplicate event ${event.id}`);
765
+ return idempotencyResult.result ?? {
766
+ skipped: true,
767
+ reason: 'duplicate'
768
+ };
769
+ }
770
+ }
771
+ // Before hook
772
+ await this.beforeProcess(event);
773
+ // Process event
774
+ const result = await this.handle(event);
775
+ // After hook
776
+ await this.afterProcess(event, result);
777
+ // Update idempotency store
778
+ if (this.config.idempotency !== false) {
779
+ await this.idempotencyStore.markCompleted(event, this.config.name, result.result);
780
+ }
781
+ // Update metrics
782
+ this.updateMetrics(startTime, true);
783
+ return result.result;
784
+ } catch (error) {
785
+ return this.handleError(job, event, error, startTime);
786
+ }
787
+ }
788
+ /**
789
+ * Handle processing error
790
+ */
791
+ async handleError(job, event, error, startTime) {
792
+ const errorObj = error instanceof Error ? error : new Error(String(error));
793
+ const classification = this.errorClassifier.classify(error);
794
+ this.logger.error(`Error processing event ${event.type}:${event.id}: ${errorObj.message}`, errorObj.stack);
795
+ // Update idempotency store with failure
796
+ if (this.config.idempotency !== false) {
797
+ await this.idempotencyStore.markFailed(event, this.config.name, error);
798
+ }
799
+ // Check if we should retry
800
+ const maxAttempts = classification.retryConfig?.maxAttempts ?? 5;
801
+ const shouldRetry = job.attemptsMade < maxAttempts && classification.classification !== 'poison';
802
+ if (shouldRetry) {
803
+ this.metrics.retried++;
804
+ this.logger.debug(`Will retry event ${event.id}, attempt ${job.attemptsMade + 1}/${maxAttempts}`);
805
+ throw errorObj; // BullMQ will retry
806
+ }
807
+ // Send to DLQ
808
+ if (this.busConfig.enableDlq && !this.config.skipDlq) {
809
+ await this.sendToDlq(event, errorObj, classification, job.attemptsMade + 1);
810
+ }
811
+ // Update metrics
812
+ this.updateMetrics(startTime, false);
813
+ // Don't throw - we've handled it by sending to DLQ
814
+ return {
815
+ failed: true,
816
+ error: errorObj.message,
817
+ sentToDlq: true
818
+ };
819
+ }
820
+ /**
821
+ * Check idempotency
822
+ */
823
+ async checkIdempotency(event) {
824
+ return this.idempotencyStore.check(event, this.config.name, {
825
+ acquireLock: true,
826
+ lockTtlMs: this.config.lockDuration ?? 30000
827
+ });
828
+ }
829
+ /**
830
+ * Send failed event to DLQ
831
+ */
832
+ async sendToDlq(event, error, classification, attempts) {
833
+ await this.dlqService.send({
834
+ event,
835
+ error: {
836
+ message: error.message,
837
+ stack: error.stack,
838
+ classification: classification.classification,
839
+ reason: classification.reason
840
+ },
841
+ subscriber: this.config.name,
842
+ attempts,
843
+ failedAt: new Date().toISOString()
844
+ });
845
+ this.metrics.dlqSent++;
846
+ this.logger.warn(`Event ${event.id} sent to DLQ after ${attempts} attempts. ` + `Classification: ${classification.classification}`);
847
+ }
848
+ /**
849
+ * Update metrics
850
+ */
851
+ updateMetrics(startTime, success) {
852
+ const duration = Date.now() - startTime;
853
+ this.metrics.processed++;
854
+ if (success) {
855
+ this.metrics.succeeded++;
856
+ } else {
857
+ this.metrics.failed++;
858
+ }
859
+ // Rolling average
860
+ this.metrics.avgProcessingTimeMs = (this.metrics.avgProcessingTimeMs * (this.metrics.processed - 1) + duration) / this.metrics.processed;
861
+ this.metrics.lastProcessedAt = new Date();
862
+ }
863
+ /**
864
+ * Setup worker event listeners
865
+ */
866
+ setupWorkerListeners() {
867
+ if (!this.worker) return;
868
+ this.worker.on('completed', job => {
869
+ this.logger.debug(`Job ${job.id} completed`);
870
+ });
871
+ this.worker.on('failed', (job, error) => {
872
+ this.logger.warn(`Job ${job?.id} failed: ${error.message}`);
873
+ });
874
+ this.worker.on('error', error => {
875
+ this.logger.error(`Worker error: ${error.message}`, error.stack);
876
+ });
877
+ this.worker.on('stalled', jobId => {
878
+ this.logger.warn(`Job ${jobId} stalled`);
879
+ });
880
+ }
881
+ /**
882
+ * Get current metrics
883
+ */
884
+ getMetrics() {
885
+ return {
886
+ ...this.metrics
887
+ };
888
+ }
889
+ /**
890
+ * Pause processing
891
+ */
892
+ async pause() {
893
+ if (this.worker) {
894
+ await this.worker.pause();
895
+ this.logger.log(`Subscriber "${this.config.name}" paused`);
896
+ }
897
+ }
898
+ /**
899
+ * Resume processing
900
+ */
901
+ async resume() {
902
+ if (this.worker) {
903
+ this.worker.resume();
904
+ this.logger.log(`Subscriber "${this.config.name}" resumed`);
905
+ }
906
+ }
907
+ /**
908
+ * Check if worker is running
909
+ */
910
+ isRunning() {
911
+ return this.worker?.isRunning() ?? false;
912
+ }
913
+ };
914
+ BaseSubscriber = __decorate([Injectable(), __param(0, Inject(EVENT_BUS_CONFIG)), __param(1, Inject(IDEMPOTENCY_STORE)), __param(2, Inject(ERROR_CLASSIFIER)), __metadata("design:paramtypes", [Object, Object, Object, DlqService])], BaseSubscriber);
915
+
916
+ /**
917
+ * Decorators for event handling
918
+ */
919
+ /**
920
+ * Metadata key for event handlers
921
+ */
922
+ const EVENT_HANDLER_METADATA = 'EVENT_HANDLER_METADATA';
923
+ /**
924
+ * Decorator to mark a method as an event handler
925
+ *
926
+ * @example
927
+ * ```typescript
928
+ * @Injectable()
929
+ * export class TradeSubscriber extends BaseSubscriber {
930
+ * @OnEvent('TradeProposalCreated', { priority: 'high' })
931
+ * async handleTradeCreated(event: TradeProposalCreated) {
932
+ * // Handle the event
933
+ * }
934
+ * }
935
+ * ```
936
+ */
937
+ function OnEvent(eventType, options) {
938
+ return SetMetadata(EVENT_HANDLER_METADATA, {
939
+ eventType,
940
+ ...options
941
+ });
942
+ }
943
+
944
+ export { BaseSubscriber, DlqService, ERROR_CLASSIFIER, EVENT_BUS_CONFIG, EVENT_HANDLER_METADATA, EVENT_REGISTRY, EventBusModule, EventBusService, IDEMPOTENCY_STORE, OnEvent };