notifkit 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.
@@ -0,0 +1,629 @@
1
+ import { J as PendingMessageScanner, Jt as buildStreamEvent, L as CircuitBreaker, Q as metrics, Qt as OUTBOUND_STREAMS, R as BatchProcessor, W as getPriorityBucket, X as StreamProducer, Y as StreamConsumer, Yt as CONSUMER_GROUPS, an as registry, c as BaseWorker, dn as baseConfigSchema, en as STREAMS, et as createLogger, it as createDatabase, k as RedisClient, l as NonRetryableError, mn as parseConfig, ot as deliveryOutbox, pn as loadEnv, q as globalEmitter, rt as IdempotencyGuard, u as startHealthReporter, ut as scheduledPayloads, x as ContactRepository } from "./src-DrSN2wCg.mjs";
2
+ import { transportRegistry } from "./index.mjs";
3
+ //#region src/services/delivery/throttle.ts
4
+ const LUA_THROTTLE = `
5
+ local key = KEYS[1]
6
+ local now = tonumber(ARGV[1])
7
+ local windowSeconds = tonumber(ARGV[2])
8
+ local limit = tonumber(ARGV[3])
9
+ local member = ARGV[4]
10
+
11
+ local clearBefore = now - (windowSeconds * 1000)
12
+
13
+ -- Cleanup expired scores
14
+ redis.call('ZREMRANGEBYSCORE', key, 0, clearBefore)
15
+
16
+ -- Get current count
17
+ local count = redis.call('ZCARD', key)
18
+
19
+ if count >= limit then
20
+ -- Find the oldest score
21
+ local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
22
+ if oldest and oldest[2] then
23
+ return {0, tonumber(oldest[2])}
24
+ end
25
+ return {0, now}
26
+ end
27
+
28
+ -- Add new request
29
+ redis.call('ZADD', key, now, member)
30
+ redis.call('EXPIRE', key, windowSeconds * 2)
31
+ return {1, 0}
32
+ `;
33
+ async function throttleProvider(redis, channel, config, logger) {
34
+ const key = `rate-limit:provider:${channel}`;
35
+ const now = Date.now();
36
+ const zmember = `${now}:${Math.random()}`;
37
+ const result = await redis.eval(LUA_THROTTLE, 1, key, now.toString(), config.windowSeconds.toString(), config.limit.toString(), zmember);
38
+ const allowed = result[0] === 1;
39
+ const oldestTimestamp = result[1];
40
+ let retryAfterMs = 0;
41
+ if (!allowed) {
42
+ retryAfterMs = Math.max(0, oldestTimestamp + config.windowSeconds * 1e3 - now);
43
+ logger.warn({
44
+ channel,
45
+ limit: config.limit,
46
+ windowSeconds: config.windowSeconds,
47
+ retryAfterMs
48
+ }, "Provider rate limit hit — task must be rescheduled");
49
+ }
50
+ return {
51
+ allowed,
52
+ retryAfterMs
53
+ };
54
+ }
55
+ //#endregion
56
+ //#region src/services/delivery/main.ts
57
+ const deliveryConfigSchema = baseConfigSchema.extend({});
58
+ loadEnv();
59
+ const config = parseConfig(deliveryConfigSchema, process.env);
60
+ let logger;
61
+ let redis;
62
+ let sql;
63
+ let db;
64
+ let consumer;
65
+ let pendingScanner;
66
+ let worker;
67
+ let scheduledProducer;
68
+ let enrichedProducers;
69
+ let healthInterval = null;
70
+ global._telemetry = global._telemetry || {
71
+ count: 0,
72
+ insert: 0,
73
+ provider: 0,
74
+ flush: 0,
75
+ ack: 0,
76
+ dequeue: 0,
77
+ dbupdate: 0,
78
+ flushCount: 0
79
+ };
80
+ var DeliveryWorker = class extends BaseWorker {
81
+ transportRegistry;
82
+ idempotency;
83
+ redisCli;
84
+ scheduledProducer;
85
+ enrichedProducers;
86
+ contactRepo;
87
+ eventsProducer;
88
+ globalEmitter;
89
+ db;
90
+ eventProcessor;
91
+ outboxUpdateProcessor;
92
+ outboxInsertProcessor;
93
+ breakers = /* @__PURE__ */ new Map();
94
+ constructor(options) {
95
+ super(options);
96
+ this.transportRegistry = options.transportRegistry;
97
+ this.idempotency = options.idempotency;
98
+ this.redisCli = options.redis;
99
+ this.scheduledProducer = options.scheduledProducer;
100
+ this.enrichedProducers = options.enrichedProducers;
101
+ this.contactRepo = options.contactRepo;
102
+ this.eventsProducer = options.eventsProducer;
103
+ this.globalEmitter = options.globalEmitter;
104
+ this.db = options.db;
105
+ this.eventProcessor = new BatchProcessor(1e3, 100, async (events) => {
106
+ await this.eventsProducer.publishBatch(events);
107
+ return events.map(() => void 0);
108
+ });
109
+ this.outboxUpdateProcessor = new BatchProcessor(500, 100, async (updates) => {
110
+ const { sql } = await import("drizzle-orm");
111
+ const values = updates.map((update) => ({
112
+ taskId: update.taskId,
113
+ channel: update.channel,
114
+ destination: update.destination,
115
+ providerMessageId: update.providerMessageId
116
+ }));
117
+ const tDbUpdateStart = Date.now();
118
+ await this.db.insert(deliveryOutbox).values(values).onConflictDoUpdate({
119
+ target: [
120
+ deliveryOutbox.taskId,
121
+ deliveryOutbox.channel,
122
+ deliveryOutbox.destination
123
+ ],
124
+ set: { providerMessageId: sql`EXCLUDED.provider_message_id` }
125
+ }).catch((e) => this.logger.error({ err: e }, "background update failed"));
126
+ global._telemetry.dbupdate += Date.now() - tDbUpdateStart;
127
+ global._telemetry.flushCount++;
128
+ return updates.map(() => void 0);
129
+ });
130
+ this.outboxInsertProcessor = new BatchProcessor(500, 10, async (tasks) => {
131
+ const values = tasks.map((task) => ({
132
+ taskId: task.taskId,
133
+ channel: task.channel,
134
+ destination: task.destination
135
+ }));
136
+ await this.db.insert(deliveryOutbox).values(values).onConflictDoNothing();
137
+ return tasks.map(() => true);
138
+ });
139
+ }
140
+ async stop() {
141
+ await Promise.all([
142
+ this.eventProcessor.flush(),
143
+ this.outboxUpdateProcessor.flush(),
144
+ this.outboxInsertProcessor.flush()
145
+ ]);
146
+ await super.stop();
147
+ }
148
+ getBreaker(name) {
149
+ let breaker = this.breakers.get(name);
150
+ if (!breaker) {
151
+ breaker = new CircuitBreaker({
152
+ failureThreshold: 5,
153
+ resetTimeoutMs: 3e4
154
+ });
155
+ this.breakers.set(name, breaker);
156
+ }
157
+ return breaker;
158
+ }
159
+ async process(message, attempt = 1) {
160
+ const { event } = message;
161
+ const publishPromises = [];
162
+ const payloadResult = registry.safeParsePayload("notification.dispatched", event.payload);
163
+ if (!payloadResult.success) {
164
+ this.logger.warn({
165
+ messageId: message.id,
166
+ issues: payloadResult.error.issues
167
+ }, "invalid notification.dispatched payload — skipping");
168
+ return;
169
+ }
170
+ const task = payloadResult.data;
171
+ const fallbackToNextChannel = async (reason) => {
172
+ if (task.fallbackChain && task.fallbackChain.length > 0 && task.recipient) {
173
+ const nextChannel = task.fallbackChain[0];
174
+ const remainingChain = task.fallbackChain.slice(1);
175
+ const fallbackPayload = {
176
+ projectId: task.projectId,
177
+ rawEventId: task.enrichedEventId,
178
+ recipientId: task.recipientId,
179
+ channel: nextChannel,
180
+ priority: task.priority,
181
+ templateId: task.templateId,
182
+ templateVariables: task.templateVariables,
183
+ aiPrompts: task.aiPrompts,
184
+ recipient: task.recipient,
185
+ scheduledAt: void 0,
186
+ fallbackChain: remainingChain.length > 0 ? remainingChain : void 0
187
+ };
188
+ const p = getPriorityBucket(task.priority);
189
+ await (this.enrichedProducers[p] ?? this.enrichedProducers["normal"]).publish(buildStreamEvent("notification.enriched", fallbackPayload, "delivery", event.metadata.traceId));
190
+ this.logger.info({
191
+ taskId: task.taskId,
192
+ reason,
193
+ nextChannel,
194
+ traceId: event.metadata.traceId
195
+ }, "Delivery failed completely, rolling over to next channel in fallback chain");
196
+ return true;
197
+ }
198
+ return false;
199
+ };
200
+ const transports = this.transportRegistry.getAll(task.channel);
201
+ if (transports.length === 0) {
202
+ this.logger.warn({
203
+ taskId: task.taskId,
204
+ channel: task.channel
205
+ }, "no transport registered for channel — dropping");
206
+ if (!await fallbackToNextChannel("no_transport")) {
207
+ this.globalEmitter.emit("delivery:failed", task.taskId, "no transport", task.channel, task.projectId);
208
+ await this.eventProcessor.add(buildStreamEvent("notification.failed", {
209
+ projectId: task.projectId,
210
+ taskId: task.taskId,
211
+ enrichedEventId: task.enrichedEventId,
212
+ recipientId: task.recipientId,
213
+ channel: task.channel,
214
+ failureReason: "no transport registered for channel",
215
+ failureCode: "no_transport",
216
+ retryable: false,
217
+ attempt,
218
+ templateId: task.templateId,
219
+ workflowInstanceId: event.metadata.source === "workflow" ? event.metadata.traceId : void 0,
220
+ campaignId: task.campaignId
221
+ }, "delivery", event.metadata.traceId));
222
+ }
223
+ return;
224
+ }
225
+ const limitConfig = transports[0].limits;
226
+ if (limitConfig) {
227
+ const { allowed, retryAfterMs } = await throttleProvider(this.redisCli, task.channel, limitConfig, this.logger);
228
+ if (!allowed) {
229
+ task.throttleAttemptCount = (task.throttleAttemptCount ?? 0) + 1;
230
+ const maxAttempts = task.deliveryOptions?.maxAttempts ?? 3;
231
+ if (task.throttleAttemptCount > maxAttempts) {
232
+ this.logger.warn({
233
+ messageId: message.id,
234
+ taskId: task.taskId,
235
+ attempts: task.throttleAttemptCount
236
+ }, "provider rate limit max attempts exceeded");
237
+ if (!await fallbackToNextChannel("provider_throttle_exceeded")) {
238
+ this.globalEmitter.emit("delivery:failed", task.taskId, "provider throttle exceeded", task.channel, task.projectId);
239
+ await this.eventProcessor.add(buildStreamEvent("notification.failed", {
240
+ projectId: task.projectId,
241
+ taskId: task.taskId,
242
+ enrichedEventId: task.enrichedEventId,
243
+ recipientId: task.recipientId,
244
+ channel: task.channel,
245
+ failureReason: "provider rate limit max attempts exceeded",
246
+ failureCode: "provider_throttle_exceeded",
247
+ retryable: false,
248
+ attempt,
249
+ templateId: task.templateId,
250
+ workflowInstanceId: event.metadata.source === "workflow" ? event.metadata.traceId : void 0,
251
+ campaignId: task.campaignId
252
+ }, "delivery", event.metadata.traceId));
253
+ }
254
+ return;
255
+ }
256
+ const { sql } = await import("drizzle-orm");
257
+ await this.db.insert(scheduledPayloads).values({
258
+ taskId: task.taskId,
259
+ payload: task
260
+ }).onConflictDoUpdate({
261
+ target: scheduledPayloads.taskId,
262
+ set: { payload: sql`EXCLUDED.payload` }
263
+ });
264
+ await this.scheduledProducer.publish(buildStreamEvent("notification.scheduled", {
265
+ projectId: task.projectId,
266
+ enrichedEventId: task.enrichedEventId,
267
+ taskId: task.taskId,
268
+ scheduledAt: new Date(Date.now() + retryAfterMs).toISOString(),
269
+ throttleAttemptCount: task.throttleAttemptCount
270
+ }, "delivery", `${task.taskId}:throttle:${Date.now()}`));
271
+ return;
272
+ }
273
+ }
274
+ const tInsertStart = Date.now();
275
+ const idempotencyKey = task.taskId;
276
+ if (!await this.idempotency.checkAndMark(idempotencyKey, 60)) {
277
+ this.logger.info({
278
+ messageId: message.id,
279
+ taskId: task.taskId,
280
+ channel: task.channel,
281
+ attempt
282
+ }, "duplicate delivery — skipping");
283
+ return;
284
+ }
285
+ try {
286
+ await this.outboxInsertProcessor.add(task);
287
+ const insertTime = Date.now() - tInsertStart;
288
+ publishPromises.push(this.eventProcessor.add(buildStreamEvent("notification.dispatched", {
289
+ projectId: task.projectId,
290
+ taskId: task.taskId,
291
+ enrichedEventId: task.enrichedEventId,
292
+ recipientId: task.recipientId,
293
+ channel: task.channel,
294
+ templateId: task.templateId,
295
+ attempt,
296
+ workflowInstanceId: event.metadata.source === "workflow" ? event.metadata.traceId : void 0,
297
+ campaignId: task.campaignId
298
+ }, "delivery", event.metadata.traceId)));
299
+ if (task.channel === "push") {
300
+ let lastResult = {
301
+ success: false,
302
+ error: "No transports"
303
+ };
304
+ for (const transport of transports) try {
305
+ const tProv = Date.now();
306
+ lastResult = await this.getBreaker(`${task.channel}:${transport.constructor.name}`).execute(async () => {
307
+ const timeoutMs = task.deliveryOptions?.timeoutMs ?? 1e4;
308
+ const controller = new AbortController();
309
+ const timeout = setTimeout(() => {
310
+ controller.abort(/* @__PURE__ */ new Error(`Transport timeout after ${timeoutMs}ms`));
311
+ }, timeoutMs);
312
+ try {
313
+ task.signal = controller.signal;
314
+ const res = await Promise.race([transport.send(task), new Promise((_, reject) => {
315
+ if (controller.signal.aborted) return reject(controller.signal.reason);
316
+ controller.signal.addEventListener("abort", () => reject(controller.signal.reason));
317
+ })]);
318
+ if (!res.success && !res.invalidToken) throw new Error(res.error ?? "Transport failed");
319
+ return res;
320
+ } finally {
321
+ clearTimeout(timeout);
322
+ }
323
+ });
324
+ global._telemetry.provider += Date.now() - tProv;
325
+ if (lastResult.success || lastResult.invalidToken) break;
326
+ } catch (err) {
327
+ lastResult = {
328
+ success: false,
329
+ error: err.message
330
+ };
331
+ }
332
+ if (lastResult.invalidToken) {
333
+ await this.contactRepo.deactivate(task.projectId, task.recipientId, "push", task.destination);
334
+ this.logger.info({ token: task.destination }, "deactivated invalid push token");
335
+ this.globalEmitter.emit("delivery:failed", task.taskId, "invalidToken", task.channel, task.projectId);
336
+ metrics.deliveryFailed.inc({
337
+ channel: task.channel,
338
+ reason: "invalid_token"
339
+ });
340
+ publishPromises.push(this.eventProcessor.add(buildStreamEvent("notification.failed", {
341
+ projectId: task.projectId,
342
+ taskId: task.taskId,
343
+ enrichedEventId: task.enrichedEventId,
344
+ recipientId: task.recipientId,
345
+ channel: task.channel,
346
+ failureReason: "invalid_token",
347
+ failureCode: "push_failure",
348
+ retryable: false,
349
+ attempt,
350
+ templateId: task.templateId,
351
+ workflowInstanceId: event.metadata.source === "workflow" ? event.metadata.traceId : void 0,
352
+ campaignId: task.campaignId
353
+ }, "delivery", event.metadata.traceId)));
354
+ if (!await fallbackToNextChannel("invalid_token")) throw new NonRetryableError("Push delivery failed: invalid token");
355
+ } else if (lastResult.success) {
356
+ const providerMessageId = lastResult.providerMessageId || "push-success";
357
+ publishPromises.push(this.outboxUpdateProcessor.add({
358
+ taskId: task.taskId,
359
+ channel: task.channel,
360
+ destination: task.destination,
361
+ providerMessageId
362
+ }));
363
+ this.logger.debug({
364
+ taskId: task.taskId,
365
+ messageId: providerMessageId
366
+ }, "push delivered");
367
+ this.globalEmitter.emit("delivery:delivered", task.taskId, providerMessageId, task.channel, task.projectId);
368
+ metrics.deliverySuccess.inc({ channel: task.channel });
369
+ publishPromises.push(this.eventProcessor.add(buildStreamEvent("notification.delivered", {
370
+ projectId: task.projectId,
371
+ taskId: task.taskId,
372
+ enrichedEventId: task.enrichedEventId,
373
+ channel: task.channel,
374
+ deliveredAt: (/* @__PURE__ */ new Date()).toISOString(),
375
+ providerMessageId,
376
+ templateId: task.templateId,
377
+ workflowInstanceId: event.metadata.source === "workflow" ? event.metadata.traceId : void 0,
378
+ campaignId: task.campaignId
379
+ }, "delivery", event.metadata.traceId)));
380
+ } else {
381
+ this.logger.warn({
382
+ taskId: task.taskId,
383
+ error: lastResult.error
384
+ }, "push delivery failed");
385
+ this.globalEmitter.emit("delivery:failed", task.taskId, lastResult.error ?? "push delivery failed", task.channel, task.projectId);
386
+ metrics.deliveryFailed.inc({
387
+ channel: task.channel,
388
+ reason: "push_error"
389
+ });
390
+ publishPromises.push(this.eventProcessor.add(buildStreamEvent("notification.failed", {
391
+ projectId: task.projectId,
392
+ taskId: task.taskId,
393
+ enrichedEventId: task.enrichedEventId,
394
+ recipientId: task.recipientId,
395
+ channel: task.channel,
396
+ failureReason: lastResult.error ?? "push delivery failed",
397
+ failureCode: "push_failure",
398
+ retryable: false,
399
+ attempt,
400
+ templateId: task.templateId,
401
+ workflowInstanceId: event.metadata.source === "workflow" ? event.metadata.traceId : void 0,
402
+ campaignId: task.campaignId
403
+ }, "delivery", event.metadata.traceId)));
404
+ if (!await fallbackToNextChannel("push_delivery_failed")) throw new NonRetryableError(lastResult.error ?? "Push delivery failed");
405
+ }
406
+ } else {
407
+ let result = {
408
+ success: false,
409
+ error: "No transports"
410
+ };
411
+ for (const transport of transports) try {
412
+ const tProv = Date.now();
413
+ result = await this.getBreaker(`${task.channel}:${transport.constructor.name}`).execute(async () => {
414
+ const timeoutMs = task.deliveryOptions?.timeoutMs ?? 1e4;
415
+ const controller = new AbortController();
416
+ const timeout = setTimeout(() => {
417
+ controller.abort(/* @__PURE__ */ new Error(`Transport timeout after ${timeoutMs}ms`));
418
+ }, timeoutMs);
419
+ try {
420
+ task.signal = controller.signal;
421
+ const res = await Promise.race([transport.send(task), new Promise((_, reject) => {
422
+ if (controller.signal.aborted) return reject(controller.signal.reason);
423
+ controller.signal.addEventListener("abort", () => reject(controller.signal.reason));
424
+ })]);
425
+ if (!res.success) throw new Error(res.error ?? "Transport failed");
426
+ return res;
427
+ } finally {
428
+ clearTimeout(timeout);
429
+ }
430
+ });
431
+ global._telemetry.provider += Date.now() - tProv;
432
+ if (result.success) break;
433
+ } catch (err) {
434
+ result = {
435
+ success: false,
436
+ error: err.message
437
+ };
438
+ }
439
+ if (result.success) {
440
+ const providerMessageId = result.providerMessageId || "success";
441
+ publishPromises.push(this.outboxUpdateProcessor.add({
442
+ taskId: task.taskId,
443
+ channel: task.channel,
444
+ destination: task.destination,
445
+ providerMessageId
446
+ }));
447
+ this.globalEmitter.emit("delivery:delivered", task.taskId, providerMessageId, task.channel, task.projectId);
448
+ metrics.deliverySuccess.inc({ channel: task.channel });
449
+ publishPromises.push(this.eventProcessor.add(buildStreamEvent("notification.delivered", {
450
+ projectId: task.projectId,
451
+ taskId: task.taskId,
452
+ enrichedEventId: task.enrichedEventId,
453
+ channel: task.channel,
454
+ deliveredAt: (/* @__PURE__ */ new Date()).toISOString(),
455
+ providerMessageId,
456
+ templateId: task.templateId,
457
+ workflowInstanceId: event.metadata.source === "workflow" ? event.metadata.traceId : void 0,
458
+ campaignId: task.campaignId
459
+ }, "delivery", event.metadata.traceId)));
460
+ } else publishPromises.push(this.eventProcessor.add(buildStreamEvent("notification.failed", {
461
+ projectId: task.projectId,
462
+ taskId: task.taskId,
463
+ enrichedEventId: task.enrichedEventId,
464
+ channel: task.channel,
465
+ failureReason: result.error ?? "delivery failed completely",
466
+ failureCode: "provider_error",
467
+ retryable: false,
468
+ attempt,
469
+ templateId: task.templateId,
470
+ workflowInstanceId: event.metadata.source === "workflow" ? event.metadata.traceId : void 0,
471
+ campaignId: task.campaignId
472
+ }, "delivery", event.metadata.traceId)));
473
+ if (!result.success) {
474
+ this.logger.warn({
475
+ taskId: task.taskId,
476
+ channel: task.channel,
477
+ error: result.error
478
+ }, "delivery failed completely across providers");
479
+ this.globalEmitter.emit("delivery:failed", task.taskId, result.error ?? "delivery failed completely", task.channel, task.projectId);
480
+ metrics.deliveryFailed.inc({
481
+ channel: task.channel,
482
+ reason: "provider_error"
483
+ });
484
+ if (!await fallbackToNextChannel("all_providers_failed")) throw new NonRetryableError(result.error ?? "delivery failed");
485
+ } else this.logger.info({
486
+ taskId: task.taskId,
487
+ channel: task.channel,
488
+ messageId: result.providerMessageId
489
+ }, "notification delivered");
490
+ }
491
+ const tFlushStart = Date.now();
492
+ await Promise.all(publishPromises).catch((err) => {
493
+ this.logger.error({
494
+ err,
495
+ taskId: task.taskId
496
+ }, "failed to publish post-dispatch events, swallowing error to prevent duplicate delivery");
497
+ });
498
+ const flushTime = Date.now() - tFlushStart;
499
+ await this.idempotency.markProcessed(idempotencyKey);
500
+ const t = global._telemetry = global._telemetry || {
501
+ count: 0,
502
+ insert: 0,
503
+ provider: 0,
504
+ flush: 0,
505
+ ack: 0
506
+ };
507
+ t.count++;
508
+ t.insert += insertTime;
509
+ t.flush += flushTime;
510
+ if (t.count % 1e3 === 0) {
511
+ this.logger.debug(`[Metrics 1000 msgs] Dequeue: ${t.dequeue / 1e3}ms, DB Insert: ${t.insert / 1e3}ms, Provider: ${t.provider / 1e3}ms, Wait for Flush: ${t.flush / 1e3}ms, Ack: ${t.ack / 1e3}ms | DB Update (avg per flush): ${t.dbupdate / Math.max(1, t.flushCount)}ms`);
512
+ t.count = 0;
513
+ t.insert = 0;
514
+ t.provider = 0;
515
+ t.flush = 0;
516
+ t.dequeue = 0;
517
+ t.ack = 0;
518
+ t.dbupdate = 0;
519
+ t.flushCount = 0;
520
+ }
521
+ } catch (err) {
522
+ await this.idempotency.unmark(idempotencyKey).catch(() => {});
523
+ throw err;
524
+ }
525
+ }
526
+ };
527
+ async function startDeliveryWorker() {
528
+ logger = createLogger({
529
+ name: "delivery",
530
+ level: config.LOG_LEVEL
531
+ });
532
+ redis = new RedisClient({
533
+ url: config.REDIS_URL,
534
+ name: "delivery",
535
+ logger
536
+ });
537
+ const dbData = createDatabase({
538
+ url: config.DATABASE_URL,
539
+ applicationName: "delivery",
540
+ logger
541
+ });
542
+ sql = dbData.sql;
543
+ db = dbData.db;
544
+ consumer = new StreamConsumer({
545
+ redis: redis.native,
546
+ stream: OUTBOUND_STREAMS,
547
+ group: CONSUMER_GROUPS.DELIVERY,
548
+ consumer: `delivery-${process.pid}`,
549
+ dlqStream: STREAMS.DEAD_LETTER,
550
+ batchSize: config.WORKER_CONCURRENCY,
551
+ logger
552
+ });
553
+ scheduledProducer = new StreamProducer({
554
+ redis: redis.native,
555
+ stream: STREAMS.SCHEDULED,
556
+ logger
557
+ });
558
+ pendingScanner = new PendingMessageScanner({
559
+ redis: redis.native,
560
+ stream: OUTBOUND_STREAMS,
561
+ group: CONSUMER_GROUPS.DELIVERY,
562
+ consumer: `delivery-${process.pid}`,
563
+ logger
564
+ });
565
+ enrichedProducers = {
566
+ critical: new StreamProducer({
567
+ redis: redis.native,
568
+ stream: STREAMS.ENRICHED_CRITICAL,
569
+ logger
570
+ }),
571
+ normal: new StreamProducer({
572
+ redis: redis.native,
573
+ stream: STREAMS.ENRICHED_NORMAL,
574
+ logger
575
+ }),
576
+ low: new StreamProducer({
577
+ redis: redis.native,
578
+ stream: STREAMS.ENRICHED_LOW,
579
+ logger
580
+ })
581
+ };
582
+ const contactRepo = new ContactRepository(db);
583
+ const idempotency = new IdempotencyGuard({
584
+ redis: redis.native,
585
+ keyPrefix: "notif:processed:delivery",
586
+ ttlSeconds: 86400
587
+ });
588
+ const eventsProducer = new StreamProducer({
589
+ redis: redis.native,
590
+ stream: STREAMS.EVENTS_INBOUND,
591
+ logger
592
+ });
593
+ worker = new DeliveryWorker({
594
+ consumer,
595
+ pendingScanner,
596
+ logger,
597
+ concurrency: config.WORKER_CONCURRENCY,
598
+ transportRegistry,
599
+ idempotency,
600
+ redis: redis.native,
601
+ scheduledProducer,
602
+ enrichedProducers,
603
+ contactRepo,
604
+ eventsProducer,
605
+ globalEmitter,
606
+ db
607
+ });
608
+ healthInterval = startHealthReporter("delivery", worker, redis, logger);
609
+ logger.info({
610
+ env: config.NODE_ENV,
611
+ channels: transportRegistry.registeredChannels()
612
+ }, "delivery starting");
613
+ await worker.start();
614
+ }
615
+ async function stopDeliveryWorker() {
616
+ logger?.info("shutdown initiated");
617
+ if (healthInterval) {
618
+ clearInterval(healthInterval);
619
+ healthInterval = null;
620
+ }
621
+ if (worker) await worker.stop();
622
+ if (sql) await sql.end();
623
+ if (redis) await redis.disconnect();
624
+ logger?.info("delivery stopped");
625
+ }
626
+ //#endregion
627
+ export { startDeliveryWorker, stopDeliveryWorker };
628
+
629
+ //# sourceMappingURL=main-ClEeP5qw.mjs.map