@signaltree/events 7.6.1 → 8.0.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.
@@ -1,287 +0,0 @@
1
- 'use strict';
2
-
3
- var tslib = require('tslib');
4
- var common = require('@nestjs/common');
5
- var bullmq = require('bullmq');
6
- var dlq_service = require('./dlq.service.cjs');
7
- var tokens = require('./tokens.cjs');
8
-
9
- /**
10
- * Base class for event subscribers
11
- *
12
- * @example
13
- * ```typescript
14
- * @Injectable()
15
- * export class TradeSubscriber extends BaseSubscriber {
16
- * protected readonly config: SubscriberConfig = {
17
- * name: 'trade-subscriber',
18
- * eventTypes: ['TradeProposalCreated', 'TradeAccepted'],
19
- * priority: 'high',
20
- * concurrency: 5,
21
- * };
22
- *
23
- * async handle(event: TradeProposalCreated | TradeAccepted): Promise<ProcessingResult> {
24
- * switch (event.type) {
25
- * case 'TradeProposalCreated':
26
- * await this.handleTradeCreated(event);
27
- * break;
28
- * case 'TradeAccepted':
29
- * await this.handleTradeAccepted(event);
30
- * break;
31
- * }
32
- * return { success: true };
33
- * }
34
- * }
35
- * ```
36
- */
37
- exports.BaseSubscriber = class BaseSubscriber {
38
- busConfig;
39
- idempotencyStore;
40
- errorClassifier;
41
- dlqService;
42
- logger;
43
- worker;
44
- connection;
45
- metrics = {
46
- processed: 0,
47
- succeeded: 0,
48
- failed: 0,
49
- retried: 0,
50
- dlqSent: 0,
51
- duplicatesSkipped: 0,
52
- avgProcessingTimeMs: 0
53
- };
54
- constructor(busConfig, idempotencyStore, errorClassifier, dlqService) {
55
- this.busConfig = busConfig;
56
- this.idempotencyStore = idempotencyStore;
57
- this.errorClassifier = errorClassifier;
58
- this.dlqService = dlqService;
59
- this.logger = new common.Logger(this.constructor.name);
60
- this.connection = {
61
- host: busConfig.redis.host,
62
- port: busConfig.redis.port,
63
- password: busConfig.redis.password,
64
- db: busConfig.redis.db
65
- };
66
- }
67
- /**
68
- * Called before processing starts (for setup/validation)
69
- */
70
- async beforeProcess(
71
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
72
- _event) {
73
- // Override in subclass if needed
74
- }
75
- /**
76
- * Called after processing completes (for cleanup)
77
- */
78
- async afterProcess(
79
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
80
- _event,
81
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
82
- _result) {
83
- // Override in subclass if needed
84
- }
85
- async onModuleInit() {
86
- const queueName = this.getQueueName();
87
- this.logger.log(`Initializing subscriber "${this.config.name}" on queue "${queueName}" ` + `for events: ${this.config.eventTypes.join(', ')}`);
88
- this.worker = new bullmq.Worker(queueName, async job => this.processJob(job), {
89
- connection: this.connection,
90
- concurrency: this.config.concurrency ?? 5,
91
- lockDuration: this.config.lockDuration ?? 30000,
92
- stalledInterval: this.config.stalledInterval ?? 30000
93
- });
94
- this.setupWorkerListeners();
95
- this.logger.log(`Subscriber "${this.config.name}" started`);
96
- }
97
- async onModuleDestroy() {
98
- this.logger.log(`Shutting down subscriber "${this.config.name}"...`);
99
- if (this.worker) {
100
- await this.worker.close();
101
- }
102
- this.logger.log(`Subscriber "${this.config.name}" stopped`);
103
- }
104
- /**
105
- * Get queue name based on config
106
- */
107
- getQueueName() {
108
- if (this.config.queue) {
109
- return this.config.queue;
110
- }
111
- // Find queue for priority
112
- const priority = this.config.priority ?? 'normal';
113
- const queueConfig = this.busConfig.queues?.find(q => q.priorities.includes(priority));
114
- return queueConfig?.name ?? `events-${priority}`;
115
- }
116
- /**
117
- * Process a job with idempotency and error handling
118
- */
119
- async processJob(job) {
120
- const event = job.data;
121
- const startTime = Date.now();
122
- // Filter by event type
123
- if (!this.config.eventTypes.includes(event.type)) {
124
- // Not our event type, skip silently
125
- return {
126
- skipped: true,
127
- reason: 'event_type_not_handled'
128
- };
129
- }
130
- this.logger.debug(`Processing event ${event.type}:${event.id} (attempt ${job.attemptsMade + 1})`);
131
- try {
132
- // Check idempotency
133
- if (this.config.idempotency !== false) {
134
- const idempotencyResult = await this.checkIdempotency(event);
135
- if (idempotencyResult.isDuplicate) {
136
- this.metrics.duplicatesSkipped++;
137
- this.logger.debug(`Skipping duplicate event ${event.id}`);
138
- return idempotencyResult.result ?? {
139
- skipped: true,
140
- reason: 'duplicate'
141
- };
142
- }
143
- }
144
- // Before hook
145
- await this.beforeProcess(event);
146
- // Process event
147
- const result = await this.handle(event);
148
- // After hook
149
- await this.afterProcess(event, result);
150
- // Update idempotency store
151
- if (this.config.idempotency !== false) {
152
- await this.idempotencyStore.markCompleted(event, this.config.name, result.result);
153
- }
154
- // Update metrics
155
- this.updateMetrics(startTime, true);
156
- return result.result;
157
- } catch (error) {
158
- return this.handleError(job, event, error, startTime);
159
- }
160
- }
161
- /**
162
- * Handle processing error
163
- */
164
- async handleError(job, event, error, startTime) {
165
- const errorObj = error instanceof Error ? error : new Error(String(error));
166
- const classification = this.errorClassifier.classify(error);
167
- this.logger.error(`Error processing event ${event.type}:${event.id}: ${errorObj.message}`, errorObj.stack);
168
- // Update idempotency store with failure
169
- if (this.config.idempotency !== false) {
170
- await this.idempotencyStore.markFailed(event, this.config.name, error);
171
- }
172
- // Check if we should retry
173
- const maxAttempts = classification.retryConfig?.maxAttempts ?? 5;
174
- const shouldRetry = job.attemptsMade < maxAttempts && classification.classification !== 'poison';
175
- if (shouldRetry) {
176
- this.metrics.retried++;
177
- this.logger.debug(`Will retry event ${event.id}, attempt ${job.attemptsMade + 1}/${maxAttempts}`);
178
- throw errorObj; // BullMQ will retry
179
- }
180
- // Send to DLQ
181
- if (this.busConfig.enableDlq && !this.config.skipDlq) {
182
- await this.sendToDlq(event, errorObj, classification, job.attemptsMade + 1);
183
- }
184
- // Update metrics
185
- this.updateMetrics(startTime, false);
186
- // Don't throw - we've handled it by sending to DLQ
187
- return {
188
- failed: true,
189
- error: errorObj.message,
190
- sentToDlq: true
191
- };
192
- }
193
- /**
194
- * Check idempotency
195
- */
196
- async checkIdempotency(event) {
197
- return this.idempotencyStore.check(event, this.config.name, {
198
- acquireLock: true,
199
- lockTtlMs: this.config.lockDuration ?? 30000
200
- });
201
- }
202
- /**
203
- * Send failed event to DLQ
204
- */
205
- async sendToDlq(event, error, classification, attempts) {
206
- await this.dlqService.send({
207
- event,
208
- error: {
209
- message: error.message,
210
- stack: error.stack,
211
- classification: classification.classification,
212
- reason: classification.reason
213
- },
214
- subscriber: this.config.name,
215
- attempts,
216
- failedAt: new Date().toISOString()
217
- });
218
- this.metrics.dlqSent++;
219
- this.logger.warn(`Event ${event.id} sent to DLQ after ${attempts} attempts. ` + `Classification: ${classification.classification}`);
220
- }
221
- /**
222
- * Update metrics
223
- */
224
- updateMetrics(startTime, success) {
225
- const duration = Date.now() - startTime;
226
- this.metrics.processed++;
227
- if (success) {
228
- this.metrics.succeeded++;
229
- } else {
230
- this.metrics.failed++;
231
- }
232
- // Rolling average
233
- this.metrics.avgProcessingTimeMs = (this.metrics.avgProcessingTimeMs * (this.metrics.processed - 1) + duration) / this.metrics.processed;
234
- this.metrics.lastProcessedAt = new Date();
235
- }
236
- /**
237
- * Setup worker event listeners
238
- */
239
- setupWorkerListeners() {
240
- if (!this.worker) return;
241
- this.worker.on('completed', job => {
242
- this.logger.debug(`Job ${job.id} completed`);
243
- });
244
- this.worker.on('failed', (job, error) => {
245
- this.logger.warn(`Job ${job?.id} failed: ${error.message}`);
246
- });
247
- this.worker.on('error', error => {
248
- this.logger.error(`Worker error: ${error.message}`, error.stack);
249
- });
250
- this.worker.on('stalled', jobId => {
251
- this.logger.warn(`Job ${jobId} stalled`);
252
- });
253
- }
254
- /**
255
- * Get current metrics
256
- */
257
- getMetrics() {
258
- return {
259
- ...this.metrics
260
- };
261
- }
262
- /**
263
- * Pause processing
264
- */
265
- async pause() {
266
- if (this.worker) {
267
- await this.worker.pause();
268
- this.logger.log(`Subscriber "${this.config.name}" paused`);
269
- }
270
- }
271
- /**
272
- * Resume processing
273
- */
274
- async resume() {
275
- if (this.worker) {
276
- this.worker.resume();
277
- this.logger.log(`Subscriber "${this.config.name}" resumed`);
278
- }
279
- }
280
- /**
281
- * Check if worker is running
282
- */
283
- isRunning() {
284
- return this.worker?.isRunning() ?? false;
285
- }
286
- };
287
- exports.BaseSubscriber = tslib.__decorate([common.Injectable(), tslib.__param(0, common.Inject(tokens.EVENT_BUS_CONFIG)), tslib.__param(1, common.Inject(tokens.IDEMPOTENCY_STORE)), tslib.__param(2, common.Inject(tokens.ERROR_CLASSIFIER)), tslib.__metadata("design:paramtypes", [Object, Object, Object, dlq_service.DlqService])], exports.BaseSubscriber);
@@ -1,35 +0,0 @@
1
- 'use strict';
2
-
3
- require('reflect-metadata');
4
- var common = require('@nestjs/common');
5
-
6
- /**
7
- * Decorators for event handling
8
- */
9
- /**
10
- * Metadata key for event handlers
11
- */
12
- const EVENT_HANDLER_METADATA = 'EVENT_HANDLER_METADATA';
13
- /**
14
- * Decorator to mark a method as an event handler
15
- *
16
- * @example
17
- * ```typescript
18
- * @Injectable()
19
- * export class TradeSubscriber extends BaseSubscriber {
20
- * @OnEvent('TradeProposalCreated', { priority: 'high' })
21
- * async handleTradeCreated(event: TradeProposalCreated) {
22
- * // Handle the event
23
- * }
24
- * }
25
- * ```
26
- */
27
- function OnEvent(eventType, options) {
28
- return common.SetMetadata(EVENT_HANDLER_METADATA, {
29
- eventType,
30
- ...options
31
- });
32
- }
33
-
34
- exports.EVENT_HANDLER_METADATA = EVENT_HANDLER_METADATA;
35
- exports.OnEvent = OnEvent;
@@ -1,249 +0,0 @@
1
- 'use strict';
2
-
3
- var tslib = require('tslib');
4
- var common = require('@nestjs/common');
5
- var bullmq = require('bullmq');
6
- var tokens = require('./tokens.cjs');
7
-
8
- var DlqService_1;
9
- exports.DlqService = DlqService_1 = class DlqService {
10
- config;
11
- logger = new common.Logger(DlqService_1.name);
12
- queue;
13
- queueName;
14
- constructor(config) {
15
- this.config = config;
16
- this.queueName = config.dlqQueueName ?? 'dead-letter';
17
- }
18
- async onModuleInit() {
19
- if (!this.config.enableDlq) {
20
- this.logger.log('DLQ is disabled');
21
- return;
22
- }
23
- this.queue = new bullmq.Queue(this.queueName, {
24
- connection: {
25
- host: this.config.redis.host,
26
- port: this.config.redis.port,
27
- password: this.config.redis.password,
28
- db: this.config.redis.db
29
- },
30
- defaultJobOptions: {
31
- removeOnComplete: false,
32
- // Keep for inspection
33
- removeOnFail: false,
34
- attempts: 1 // No retries in DLQ
35
- }
36
- });
37
- this.logger.log(`DLQ "${this.queueName}" initialized`);
38
- }
39
- async onModuleDestroy() {
40
- if (this.queue) {
41
- await this.queue.close();
42
- }
43
- }
44
- /**
45
- * Send an event to the DLQ
46
- */
47
- async send(entry) {
48
- if (!this.queue) {
49
- throw new Error('DLQ is not initialized');
50
- }
51
- const job = await this.queue.add(`dlq:${entry.event.type}`, entry, {
52
- jobId: `dlq:${entry.event.id}:${entry.subscriber}`
53
- });
54
- this.logger.debug(`Event ${entry.event.id} sent to DLQ (job: ${job.id})`);
55
- return job.id ?? `dlq:${entry.event.id}:${entry.subscriber}`;
56
- }
57
- /**
58
- * Get entries from DLQ
59
- */
60
- async getEntries(options = {}) {
61
- if (!this.queue) {
62
- return [];
63
- }
64
- const start = options.start ?? 0;
65
- const end = options.end ?? 100;
66
- const jobs = await this.queue.getJobs(['waiting', 'active', 'delayed', 'failed'], start, end);
67
- let entries = jobs.map(job => job.data);
68
- // Apply filters
69
- if (options.eventType) {
70
- entries = entries.filter(e => e.event.type === options.eventType);
71
- }
72
- if (options.subscriber) {
73
- entries = entries.filter(e => e.subscriber === options.subscriber);
74
- }
75
- if (options.classification) {
76
- entries = entries.filter(e => e.error.classification === options.classification);
77
- }
78
- if (options.from) {
79
- const fromDate = options.from;
80
- entries = entries.filter(e => new Date(e.failedAt) >= fromDate);
81
- }
82
- if (options.to) {
83
- const toDate = options.to;
84
- entries = entries.filter(e => new Date(e.failedAt) <= toDate);
85
- }
86
- return entries;
87
- }
88
- /**
89
- * Get a specific entry by event ID
90
- */
91
- async getEntry(eventId, subscriber) {
92
- if (!this.queue) {
93
- return null;
94
- }
95
- // Try with subscriber suffix first
96
- if (subscriber) {
97
- const job = await this.queue.getJob(`dlq:${eventId}:${subscriber}`);
98
- if (job) {
99
- return job.data;
100
- }
101
- }
102
- // Search through jobs
103
- const jobs = await this.queue.getJobs(['waiting', 'active', 'delayed', 'failed']);
104
- const job = jobs.find(j => j.data.event.id === eventId);
105
- return job ? job.data : null;
106
- }
107
- /**
108
- * Get DLQ statistics
109
- */
110
- async getStats() {
111
- const entries = await this.getEntries({
112
- start: 0,
113
- end: 10000
114
- });
115
- const stats = {
116
- total: entries.length,
117
- byEventType: {},
118
- bySubscriber: {},
119
- byClassification: {
120
- transient: 0,
121
- permanent: 0,
122
- poison: 0,
123
- unknown: 0
124
- }
125
- };
126
- for (const entry of entries) {
127
- // By event type
128
- stats.byEventType[entry.event.type] = (stats.byEventType[entry.event.type] ?? 0) + 1;
129
- // By subscriber
130
- stats.bySubscriber[entry.subscriber] = (stats.bySubscriber[entry.subscriber] ?? 0) + 1;
131
- // By classification
132
- stats.byClassification[entry.error.classification]++;
133
- // Date tracking
134
- const failedDate = new Date(entry.failedAt);
135
- if (!stats.oldestEntry || failedDate < stats.oldestEntry) {
136
- stats.oldestEntry = failedDate;
137
- }
138
- if (!stats.newestEntry || failedDate > stats.newestEntry) {
139
- stats.newestEntry = failedDate;
140
- }
141
- }
142
- return stats;
143
- }
144
- /**
145
- * Replay an event from DLQ
146
- *
147
- * This removes the event from DLQ and republishes to original queue
148
- */
149
- async replay(eventId, subscriber, targetQueue) {
150
- if (!this.queue) {
151
- throw new Error('DLQ is not initialized');
152
- }
153
- const jobId = `dlq:${eventId}:${subscriber}`;
154
- const job = await this.queue.getJob(jobId);
155
- if (!job) {
156
- this.logger.warn(`DLQ entry not found: ${jobId}`);
157
- return false;
158
- }
159
- const entry = job.data;
160
- // Get target queue
161
- const targetQueueInstance = new bullmq.Queue(targetQueue, {
162
- connection: {
163
- host: this.config.redis.host,
164
- port: this.config.redis.port,
165
- password: this.config.redis.password,
166
- db: this.config.redis.db
167
- }
168
- });
169
- try {
170
- // Republish to original queue
171
- await targetQueueInstance.add(entry.event.type, entry.event, {
172
- jobId: `replay:${entry.event.id}`
173
- });
174
- // Remove from DLQ
175
- await job.remove();
176
- this.logger.log(`Replayed event ${eventId} to queue ${targetQueue}`);
177
- return true;
178
- } finally {
179
- await targetQueueInstance.close();
180
- }
181
- }
182
- /**
183
- * Replay all events matching criteria
184
- */
185
- async replayBatch(options) {
186
- const entries = await this.getEntries(options);
187
- let replayed = 0;
188
- let failed = 0;
189
- for (const entry of entries) {
190
- try {
191
- const success = await this.replay(entry.event.id, entry.subscriber, options.targetQueue);
192
- if (success) replayed++;else failed++;
193
- } catch {
194
- failed++;
195
- }
196
- }
197
- return {
198
- replayed,
199
- failed
200
- };
201
- }
202
- /**
203
- * Remove an entry from DLQ
204
- */
205
- async remove(eventId, subscriber) {
206
- if (!this.queue) {
207
- return false;
208
- }
209
- const job = await this.queue.getJob(`dlq:${eventId}:${subscriber}`);
210
- if (job) {
211
- await job.remove();
212
- return true;
213
- }
214
- return false;
215
- }
216
- /**
217
- * Purge old entries from DLQ
218
- */
219
- async purge(olderThan) {
220
- const entries = await this.getEntries({
221
- to: olderThan,
222
- start: 0,
223
- end: 10000
224
- });
225
- let purged = 0;
226
- for (const entry of entries) {
227
- const removed = await this.remove(entry.event.id, entry.subscriber);
228
- if (removed) purged++;
229
- }
230
- this.logger.log(`Purged ${purged} entries from DLQ older than ${olderThan.toISOString()}`);
231
- return purged;
232
- }
233
- /**
234
- * Clear all entries from DLQ
235
- */
236
- async clear() {
237
- if (!this.queue) {
238
- return 0;
239
- }
240
- const count = await this.queue.getJobCounts();
241
- const total = count.waiting + count.active + count.delayed + count.failed;
242
- await this.queue.obliterate({
243
- force: true
244
- });
245
- this.logger.warn(`Cleared all ${total} entries from DLQ`);
246
- return total;
247
- }
248
- };
249
- exports.DlqService = DlqService_1 = tslib.__decorate([common.Injectable(), tslib.__param(0, common.Inject(tokens.EVENT_BUS_CONFIG)), tslib.__metadata("design:paramtypes", [Object])], exports.DlqService);