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,224 @@
1
+ import { J as PendingMessageScanner, Jt as buildStreamEvent, U as LUA_SCHEDULER_POLL, W as getPriorityBucket, X as StreamProducer, Y as StreamConsumer, Yt as CONSUMER_GROUPS, an as registry, c as BaseWorker, en as STREAMS, et as createLogger, hn as readBaseConfig, it as createDatabase, k as RedisClient, pn as loadEnv, u as startHealthReporter, ut as scheduledPayloads } from "./src-DrSN2wCg.mjs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { inArray } from "drizzle-orm";
4
+ //#region src/services/scheduler/main.ts
5
+ loadEnv();
6
+ const config = readBaseConfig();
7
+ let logger;
8
+ let redis;
9
+ let sql$1;
10
+ let db;
11
+ let consumer;
12
+ let pendingScanner;
13
+ let worker;
14
+ let healthInterval = null;
15
+ let pollTimeout = null;
16
+ let isPolling = false;
17
+ var SchedulerWorker = class extends BaseWorker {
18
+ registry;
19
+ redisCli;
20
+ constructor(options) {
21
+ super(options);
22
+ this.registry = options.registry;
23
+ this.redisCli = options.redis;
24
+ }
25
+ async process(message) {
26
+ const { event } = message;
27
+ const payloadResult = this.registry.safeParsePayload("notification.scheduled", event.payload);
28
+ if (!payloadResult.success) {
29
+ this.logger.warn({
30
+ messageId: message.id,
31
+ issues: payloadResult.error.issues
32
+ }, "invalid notification.scheduled payload — skipping");
33
+ return;
34
+ }
35
+ const scheduled = payloadResult.data;
36
+ const scheduledAt = new Date(scheduled.scheduledAt).getTime();
37
+ const taskData = JSON.stringify({
38
+ taskId: scheduled.taskId,
39
+ enrichedEventId: scheduled.enrichedEventId,
40
+ traceId: event.metadata.traceId
41
+ });
42
+ const shard = parseInt(scheduled.taskId.slice(-1), 16) || 0;
43
+ const zsetKey = `notif:scheduled:zset:${shard}`;
44
+ await this.redisCli.zadd(zsetKey, scheduledAt, taskData);
45
+ this.logger.debug({
46
+ taskId: scheduled.taskId,
47
+ scheduledAt: scheduled.scheduledAt,
48
+ shard
49
+ }, "scheduled task queued in ZSET");
50
+ }
51
+ };
52
+ async function executeSchedulerPoll(redis, outboundProducers, logger, db) {
53
+ const lockKey = "notif:lock:scheduler:poll";
54
+ const lockOwner = randomUUID();
55
+ let acquired = false;
56
+ try {
57
+ if (await redis.set(lockKey, lockOwner, "EX", 30, "NX") !== "OK") return false;
58
+ acquired = true;
59
+ const now = Date.now();
60
+ const visibilityTimeout = 6e4;
61
+ const perShardLimit = 100;
62
+ const pipeline = redis.pipeline();
63
+ for (let i = 0; i < 16; i++) pipeline.eval(LUA_SCHEDULER_POLL, 1, `notif:scheduled:zset:${i}`, now, perShardLimit, visibilityTimeout);
64
+ const perShard = (await pipeline.exec())?.map((res) => res[0] ? [] : res[1] ?? []) ?? [];
65
+ const tasks = perShard.flat();
66
+ const anyShardFull = perShard.some((shard) => shard.length >= perShardLimit);
67
+ if (tasks.length === 0) return false;
68
+ const parsedTasks = tasks.map((t) => {
69
+ try {
70
+ return {
71
+ taskStr: t,
72
+ ...JSON.parse(t)
73
+ };
74
+ } catch (err) {
75
+ logger.error({
76
+ taskStr: t,
77
+ err
78
+ }, "failed to parse scheduled task JSON");
79
+ return null;
80
+ }
81
+ }).filter(Boolean);
82
+ if (parsedTasks.length === 0) return false;
83
+ const taskIds = parsedTasks.map((t) => t.taskId);
84
+ const dbPayloads = await db.select().from(scheduledPayloads).where(inArray(scheduledPayloads.taskId, taskIds));
85
+ const payloadMap = new Map(dbPayloads.map((row) => [row.taskId, row.payload]));
86
+ const batchedEvents = {
87
+ critical: [],
88
+ high: [],
89
+ normal: [],
90
+ low: []
91
+ };
92
+ const cleanupPipeline = redis.pipeline();
93
+ const dbCleanupIds = [];
94
+ for (let i = 0; i < parsedTasks.length; i++) {
95
+ const { taskStr, taskId, traceId } = parsedTasks[i];
96
+ const payload = payloadMap.get(taskId);
97
+ if (!payload) {
98
+ logger.warn({ taskId }, "scheduled payload not found in Postgres — skipping release");
99
+ const shard = parseInt(taskId.slice(-1), 16) || 0;
100
+ cleanupPipeline.zrem(`notif:scheduled:zset:${shard}`, taskStr);
101
+ continue;
102
+ }
103
+ const dispatchPayload = payload;
104
+ batchedEvents[getPriorityBucket(dispatchPayload.priority)].push(buildStreamEvent("notification.dispatched", dispatchPayload, "scheduler", traceId));
105
+ dbCleanupIds.push(taskId);
106
+ const shard = parseInt(taskId.slice(-1), 16) || 0;
107
+ cleanupPipeline.zrem(`notif:scheduled:zset:${shard}`, taskStr);
108
+ }
109
+ for (const p of Object.keys(batchedEvents)) if (batchedEvents[p].length > 0) await (outboundProducers[p] ?? outboundProducers["normal"]).publishBatch(batchedEvents[p]);
110
+ if (dbCleanupIds.length > 0) try {
111
+ await db.delete(scheduledPayloads).where(inArray(scheduledPayloads.taskId, dbCleanupIds));
112
+ } catch (err) {
113
+ logger.error({ err }, "failed to delete scheduled payloads from Postgres, continuing with Redis cleanup");
114
+ }
115
+ await cleanupPipeline.exec();
116
+ logger.info({ count: parsedTasks.length }, "scheduled tasks released to outbound");
117
+ return anyShardFull;
118
+ } catch (err) {
119
+ logger.error({ err }, "error in scheduler polling loop");
120
+ return false;
121
+ } finally {
122
+ if (acquired) await redis.eval(`
123
+ if redis.call("get", KEYS[1]) == ARGV[1] then
124
+ return redis.call("del", KEYS[1])
125
+ else
126
+ return 0
127
+ end
128
+ `, 1, lockKey, lockOwner).catch(() => {});
129
+ }
130
+ }
131
+ async function startSchedulerWorker() {
132
+ logger = createLogger({
133
+ name: "scheduler",
134
+ level: config.LOG_LEVEL
135
+ });
136
+ redis = new RedisClient({
137
+ url: config.REDIS_URL,
138
+ name: "scheduler",
139
+ logger
140
+ });
141
+ const dbData = createDatabase({
142
+ url: config.DATABASE_URL,
143
+ applicationName: "scheduler",
144
+ logger
145
+ });
146
+ sql$1 = dbData.sql;
147
+ db = dbData.db;
148
+ consumer = new StreamConsumer({
149
+ redis: redis.native,
150
+ stream: STREAMS.SCHEDULED,
151
+ group: CONSUMER_GROUPS.SCHEDULER,
152
+ consumer: `scheduler-${process.pid}`,
153
+ dlqStream: STREAMS.DEAD_LETTER,
154
+ batchSize: config.WORKER_CONCURRENCY,
155
+ logger
156
+ });
157
+ pendingScanner = new PendingMessageScanner({
158
+ redis: redis.native,
159
+ stream: STREAMS.SCHEDULED,
160
+ group: CONSUMER_GROUPS.SCHEDULER,
161
+ consumer: `scheduler-${process.pid}`,
162
+ logger
163
+ });
164
+ const outboundProducers = {
165
+ critical: new StreamProducer({
166
+ redis: redis.native,
167
+ stream: STREAMS.OUTBOUND_CRITICAL,
168
+ logger
169
+ }),
170
+ normal: new StreamProducer({
171
+ redis: redis.native,
172
+ stream: STREAMS.OUTBOUND_NORMAL,
173
+ logger
174
+ }),
175
+ low: new StreamProducer({
176
+ redis: redis.native,
177
+ stream: STREAMS.OUTBOUND_LOW,
178
+ logger
179
+ })
180
+ };
181
+ worker = new SchedulerWorker({
182
+ consumer,
183
+ pendingScanner,
184
+ logger,
185
+ concurrency: config.WORKER_CONCURRENCY,
186
+ registry,
187
+ redis: redis.native
188
+ });
189
+ isPolling = true;
190
+ const pollLoop = async () => {
191
+ if (!isPolling) return;
192
+ try {
193
+ const hasMore = await executeSchedulerPoll(redis.native, outboundProducers, logger, db);
194
+ if (isPolling) pollTimeout = setTimeout(() => void pollLoop(), hasMore ? 0 : 5e3);
195
+ } catch (err) {
196
+ logger.error({ err }, "scheduler poll loop error");
197
+ if (isPolling) pollTimeout = setTimeout(() => void pollLoop(), 5e3);
198
+ }
199
+ };
200
+ pollLoop();
201
+ healthInterval = startHealthReporter("scheduler", worker, redis, logger);
202
+ logger.info({ env: config.NODE_ENV }, "scheduler starting");
203
+ await worker.start();
204
+ }
205
+ async function stopSchedulerWorker() {
206
+ logger?.info("shutdown initiated");
207
+ if (healthInterval) {
208
+ clearInterval(healthInterval);
209
+ healthInterval = null;
210
+ }
211
+ if (pollTimeout) {
212
+ clearTimeout(pollTimeout);
213
+ pollTimeout = null;
214
+ }
215
+ isPolling = false;
216
+ if (worker) await worker.stop();
217
+ if (sql$1) await sql$1.end();
218
+ if (redis) await redis.disconnect();
219
+ logger?.info("scheduler stopped");
220
+ }
221
+ //#endregion
222
+ export { startSchedulerWorker, stopSchedulerWorker };
223
+
224
+ //# sourceMappingURL=main-BHYZfBBq.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"main-BHYZfBBq.mjs","names":["sql"],"sources":["../src/services/scheduler/main.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { loadEnv, readBaseConfig } from \"@/index.js\";\nimport { createLogger } from \"@/index.js\";\nimport { RedisClient, type Redis } from \"@/index.js\";\nimport {\n StreamConsumer,\n PendingMessageScanner,\n StreamProducer,\n type StreamMessage,\n} from \"@/index.js\";\nimport { BaseWorker } from \"@/index.js\";\nimport {\n STREAMS,\n CONSUMER_GROUPS,\n registry,\n buildStreamEvent,\n type NotificationScheduledPayload,\n} from \"@/index.js\";\nimport { getPriorityBucket, type WorkerOptions, LUA_SCHEDULER_POLL } from \"@/shared/index.js\";\nimport { createDatabase } from \"@/db/index.js\";\nimport { scheduledPayloads } from \"@/db/schema.js\";\nimport { inArray } from \"drizzle-orm\";\nimport { startHealthReporter } from \"@/workers/index.js\";\n// ─── Bootstrap ─────────────────────────────────────────────────────────────\n\nloadEnv();\nconst config = readBaseConfig();\n\nlet logger: ReturnType<typeof createLogger>;\nlet redis: RedisClient;\nlet sql: any;\nlet db: any;\n\nlet consumer: StreamConsumer;\nlet pendingScanner: PendingMessageScanner;\nlet worker: BaseWorker;\nlet healthInterval: NodeJS.Timeout | null = null;\nlet pollTimeout: ReturnType<typeof setTimeout> | null = null;\nlet isPolling = false;\n\nexport interface SchedulerWorkerOptions extends WorkerOptions {\n registry: any;\n redis: Redis;\n}\n\nexport class SchedulerWorker extends BaseWorker {\n private readonly registry: any;\n private readonly redisCli: Redis;\n\n constructor(options: SchedulerWorkerOptions) {\n super(options);\n this.registry = options.registry;\n this.redisCli = options.redis;\n }\n async process(message: StreamMessage): Promise<void> {\n const { event } = message;\n\n const payloadResult = this.registry.safeParsePayload(\"notification.scheduled\", event.payload);\n if (!payloadResult.success) {\n this.logger.warn(\n { messageId: message.id, issues: payloadResult.error.issues },\n \"invalid notification.scheduled payload — skipping\",\n );\n return;\n }\n\n const scheduled = payloadResult.data as NotificationScheduledPayload;\n const scheduledAt = new Date(scheduled.scheduledAt).getTime();\n\n const taskData = JSON.stringify({\n taskId: scheduled.taskId,\n enrichedEventId: scheduled.enrichedEventId,\n traceId: event.metadata.traceId,\n });\n\n const shard = parseInt(scheduled.taskId.slice(-1), 16) || 0;\n const zsetKey = `notif:scheduled:zset:${shard}`;\n await this.redisCli.zadd(zsetKey, scheduledAt, taskData);\n this.logger.debug(\n { taskId: scheduled.taskId, scheduledAt: scheduled.scheduledAt, shard },\n \"scheduled task queued in ZSET\",\n );\n }\n}\n\nexport async function executeSchedulerPoll(\n redis: Redis,\n outboundProducers: any,\n logger: any,\n db: any,\n): Promise<boolean> {\n const lockKey = \"notif:lock:scheduler:poll\";\n const lockOwner = randomUUID();\n let acquired = false;\n try {\n const lockAcquired = await redis.set(lockKey, lockOwner, \"EX\", 30, \"NX\");\n if (lockAcquired !== \"OK\") {\n return false; // Another instance is currently polling\n }\n acquired = true;\n\n const now = Date.now();\n const visibilityTimeout = 60000; // 60 seconds\n const perShardLimit = 100;\n const pipeline = redis.pipeline();\n\n for (let i = 0; i < 16; i++) {\n pipeline.eval(\n LUA_SCHEDULER_POLL,\n 1,\n `notif:scheduled:zset:${i}`,\n now,\n perShardLimit,\n visibilityTimeout,\n );\n }\n\n const results = await pipeline.exec();\n const perShard = results?.map((res: any) => (res[0] ? [] : ((res[1] ?? []) as string[]))) ?? [];\n const tasks = perShard.flat();\n // A shard that came back full may still have due tasks behind it, which is\n // what tells the caller to poll again rather than wait for the next tick.\n const anyShardFull = perShard.some((shard) => shard.length >= perShardLimit);\n\n if (tasks.length === 0) return false;\n\n const parsedTasks = tasks\n .map((t) => {\n try {\n return { taskStr: t, ...JSON.parse(t) };\n } catch (err) {\n logger.error({ taskStr: t, err }, \"failed to parse scheduled task JSON\");\n return null;\n }\n })\n .filter(Boolean) as any[];\n if (parsedTasks.length === 0) return false;\n\n const taskIds = parsedTasks.map((t) => t.taskId);\n const dbPayloads = await db\n .select()\n .from(scheduledPayloads)\n .where(inArray(scheduledPayloads.taskId, taskIds));\n const payloadMap = new Map(dbPayloads.map((row: any) => [row.taskId, row.payload]));\n\n const batchedEvents: Record<\n \"critical\" | \"high\" | \"normal\" | \"low\",\n Omit<any, \"id\" | \"timestamp\">[]\n > = {\n critical: [],\n high: [],\n normal: [],\n low: [],\n };\n\n const cleanupPipeline = redis.pipeline();\n const dbCleanupIds: string[] = [];\n\n for (let i = 0; i < parsedTasks.length; i++) {\n const { taskStr, taskId, traceId } = parsedTasks[i];\n const payload = payloadMap.get(taskId);\n\n if (!payload) {\n logger.warn({ taskId }, \"scheduled payload not found in Postgres — skipping release\");\n const shard = parseInt(taskId.slice(-1), 16) || 0;\n cleanupPipeline.zrem(`notif:scheduled:zset:${shard}`, taskStr);\n continue;\n }\n\n const dispatchPayload = payload as any;\n const p = getPriorityBucket(dispatchPayload.priority);\n\n batchedEvents[p].push(\n buildStreamEvent(\"notification.dispatched\", dispatchPayload, \"scheduler\", traceId),\n );\n\n dbCleanupIds.push(taskId);\n const shard = parseInt(taskId.slice(-1), 16) || 0;\n cleanupPipeline.zrem(`notif:scheduled:zset:${shard}`, taskStr);\n }\n\n // Every bucket, not a hardcoded subset: a task binned into one that is not\n // published here has already been added to the cleanup list, so it would be\n // deleted from Redis and Postgres without ever being sent.\n for (const p of Object.keys(batchedEvents) as (keyof typeof batchedEvents)[]) {\n if (batchedEvents[p].length > 0) {\n const producer = outboundProducers[p] ?? outboundProducers[\"normal\"]!;\n await producer!.publishBatch(batchedEvents[p]);\n }\n }\n\n if (dbCleanupIds.length > 0) {\n try {\n await db.delete(scheduledPayloads).where(inArray(scheduledPayloads.taskId, dbCleanupIds));\n } catch (err) {\n logger.error(\n { err },\n \"failed to delete scheduled payloads from Postgres, continuing with Redis cleanup\",\n );\n }\n }\n await cleanupPipeline.exec();\n\n logger.info({ count: parsedTasks.length }, \"scheduled tasks released to outbound\");\n\n // \"Poll again immediately\" — the caller loops while this is true. The old\n // test was `tasks.length === 100` against a total drawn from 16 shards, so\n // a real backlog reported \"nothing more\" and waited for the next tick.\n return anyShardFull;\n } catch (err) {\n logger.error({ err }, \"error in scheduler polling loop\");\n return false;\n } finally {\n if (acquired) {\n // Delete the lock only if we still own it\n const lua = `\n if redis.call(\"get\", KEYS[1]) == ARGV[1] then\n return redis.call(\"del\", KEYS[1])\n else\n return 0\n end\n `;\n await redis.eval(lua, 1, lockKey, lockOwner).catch(() => {});\n }\n }\n}\n\nexport async function startSchedulerWorker() {\n logger = createLogger({ name: \"scheduler\", level: config.LOG_LEVEL });\n redis = new RedisClient({ url: config.REDIS_URL, name: \"scheduler\", logger });\n const dbData = createDatabase({ url: config.DATABASE_URL, applicationName: \"scheduler\", logger });\n sql = dbData.sql;\n db = dbData.db;\n consumer = new StreamConsumer({\n redis: redis.native,\n stream: STREAMS.SCHEDULED,\n group: CONSUMER_GROUPS.SCHEDULER,\n consumer: `scheduler-${process.pid}`,\n dlqStream: STREAMS.DEAD_LETTER,\n batchSize: config.WORKER_CONCURRENCY,\n logger,\n });\n\n pendingScanner = new PendingMessageScanner({\n redis: redis.native,\n stream: STREAMS.SCHEDULED,\n group: CONSUMER_GROUPS.SCHEDULER,\n consumer: `scheduler-${process.pid}`,\n logger,\n });\n\n const outboundProducers = {\n critical: new StreamProducer({\n redis: redis.native,\n stream: STREAMS.OUTBOUND_CRITICAL,\n logger,\n }),\n normal: new StreamProducer({ redis: redis.native, stream: STREAMS.OUTBOUND_NORMAL, logger }),\n low: new StreamProducer({ redis: redis.native, stream: STREAMS.OUTBOUND_LOW, logger }),\n };\n\n // ─── Stage 4a: Scheduled notification processor ─────────────────────────────\n //\n // Reads from SCHEDULED stream, parses, and queues tasks in a Redis ZSET.\n // A background polling interval releases tasks when they are due.\n\n worker = new SchedulerWorker({\n consumer,\n pendingScanner,\n logger,\n concurrency: config.WORKER_CONCURRENCY,\n registry,\n redis: redis.native,\n });\n\n // ─── Polling Loop ─────────────────────────────────────────────────────────\n isPolling = true;\n const pollLoop = async (): Promise<void> => {\n if (!isPolling) return;\n try {\n const hasMore = await executeSchedulerPoll(redis.native, outboundProducers, logger, db);\n if (isPolling) {\n pollTimeout = setTimeout(() => void pollLoop(), hasMore ? 0 : 5000);\n }\n } catch (err) {\n logger.error({ err }, \"scheduler poll loop error\");\n if (isPolling) {\n pollTimeout = setTimeout(() => void pollLoop(), 5000);\n }\n }\n };\n void pollLoop();\n\n // ─── Health check interval ──────────────────────────────────────────────────\n\n healthInterval = startHealthReporter(\"scheduler\", worker, redis, logger);\n\n logger.info({ env: config.NODE_ENV }, \"scheduler starting\");\n await worker.start();\n}\n\n// ─── Shutdown ──────────────────────────────────────────────────────────────\n\nexport async function stopSchedulerWorker(): Promise<void> {\n logger?.info(\"shutdown initiated\");\n if (healthInterval) {\n clearInterval(healthInterval);\n healthInterval = null;\n }\n if (pollTimeout) {\n clearTimeout(pollTimeout);\n pollTimeout = null;\n }\n isPolling = false;\n if (worker) await worker.stop();\n if (sql) await sql.end();\n if (redis) await redis.disconnect();\n logger?.info(\"scheduler stopped\");\n}\n"],"mappings":"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"}
@@ -0,0 +1,430 @@
1
+ import { E as UserRepository, J as PendingMessageScanner, Jt as buildStreamEvent, S as PreferenceRepository, T as TemplateRepository, W as getPriorityBucket, X as StreamProducer, Y as StreamConsumer, Yt as CONSUMER_GROUPS, Zt as INBOUND_STREAMS, an as registry, c as BaseWorker, en as STREAMS, et as createLogger, g as TemplateCache, hn as readBaseConfig, it as createDatabase, k as RedisClient, pn as loadEnv, rt as IdempotencyGuard, u as startHealthReporter, x as ContactRepository } from "./src-DrSN2wCg.mjs";
2
+ //#region src/services/enricher/main.ts
3
+ loadEnv();
4
+ const config = readBaseConfig();
5
+ let logger;
6
+ let redis;
7
+ let sql;
8
+ let db;
9
+ let consumer;
10
+ let pendingScanner;
11
+ let worker;
12
+ let healthInterval = null;
13
+ var EnricherWorker = class extends BaseWorker {
14
+ producers;
15
+ idempotency;
16
+ userRepo;
17
+ prefRepo;
18
+ contactRepo;
19
+ templateCache;
20
+ userBatch = [];
21
+ batchTimer = null;
22
+ eventBuffer = [];
23
+ flushTimer = null;
24
+ contactBatch = [];
25
+ contactBatchTimer = null;
26
+ async loadContacts(projectId, userIds) {
27
+ return new Promise((resolve, reject) => {
28
+ this.contactBatch.push({
29
+ projectId,
30
+ userIds,
31
+ resolve,
32
+ reject
33
+ });
34
+ if (this.contactBatch.length >= 500) {
35
+ if (this.contactBatchTimer) clearTimeout(this.contactBatchTimer);
36
+ this.flushContactBatch();
37
+ } else if (!this.contactBatchTimer) this.contactBatchTimer = setTimeout(() => void this.flushContactBatch(), 10);
38
+ });
39
+ }
40
+ async flushContactBatch() {
41
+ const batch = this.contactBatch;
42
+ this.contactBatch = [];
43
+ this.contactBatchTimer = null;
44
+ if (batch.length === 0) return;
45
+ try {
46
+ const byProject = /* @__PURE__ */ new Map();
47
+ for (const b of batch) {
48
+ if (!byProject.has(b.projectId)) byProject.set(b.projectId, []);
49
+ byProject.get(b.projectId).push(b);
50
+ }
51
+ for (const [projectId, items] of byProject) {
52
+ const userIds = Array.from(new Set(items.flatMap((i) => i.userIds)));
53
+ const contactsMap = await this.contactRepo.findActiveByUserIds(projectId, userIds);
54
+ for (const item of items) item.resolve(contactsMap);
55
+ }
56
+ } catch (err) {
57
+ for (const b of batch) b.reject(err);
58
+ }
59
+ }
60
+ constructor(options) {
61
+ super(options);
62
+ this.producers = options.producers;
63
+ this.idempotency = options.idempotency;
64
+ this.userRepo = options.userRepo;
65
+ this.prefRepo = options.prefRepo;
66
+ this.contactRepo = options.contactRepo;
67
+ this.templateCache = options.templateCache;
68
+ this.flushTimer = setInterval(() => void this.flushWorkerBuffers(), 100);
69
+ }
70
+ async stop() {
71
+ if (this.batchTimer) {
72
+ clearTimeout(this.batchTimer);
73
+ this.batchTimer = null;
74
+ }
75
+ if (this.flushTimer) {
76
+ clearInterval(this.flushTimer);
77
+ this.flushTimer = null;
78
+ }
79
+ if (this.contactBatchTimer) {
80
+ clearTimeout(this.contactBatchTimer);
81
+ this.contactBatchTimer = null;
82
+ }
83
+ await this.flushContactBatch();
84
+ await this.flushUserBatch();
85
+ await this.flushWorkerBuffers();
86
+ await super.stop();
87
+ }
88
+ async flushWorkerBuffers() {
89
+ if (this.eventBuffer.length === 0) return;
90
+ const events = this.eventBuffer;
91
+ this.eventBuffer = [];
92
+ try {
93
+ const byProducer = /* @__PURE__ */ new Map();
94
+ for (const e of events) {
95
+ if (!byProducer.has(e.producer)) byProducer.set(e.producer, []);
96
+ byProducer.get(e.producer).push(e);
97
+ }
98
+ for (const [producer, batch] of byProducer) {
99
+ await producer.publishBatch(batch.map((b) => b.event));
100
+ for (const b of batch) b.resolve();
101
+ }
102
+ } catch (err) {
103
+ this.logger.error({ err }, "failed to flush events in EnricherWorker");
104
+ for (const e of events) e.reject(err);
105
+ }
106
+ }
107
+ async loadUser(projectId, userId) {
108
+ return new Promise((resolve, reject) => {
109
+ this.userBatch.push({
110
+ projectId,
111
+ userId,
112
+ resolve,
113
+ reject
114
+ });
115
+ if (this.userBatch.length >= 500) {
116
+ if (this.batchTimer) clearTimeout(this.batchTimer);
117
+ this.flushUserBatch();
118
+ } else if (!this.batchTimer) this.batchTimer = setTimeout(() => {
119
+ this.flushUserBatch();
120
+ }, 10);
121
+ });
122
+ }
123
+ async flushUserBatch() {
124
+ const batch = this.userBatch;
125
+ this.userBatch = [];
126
+ this.batchTimer = null;
127
+ const byProject = /* @__PURE__ */ new Map();
128
+ for (const b of batch) {
129
+ if (!byProject.has(b.projectId)) byProject.set(b.projectId, []);
130
+ byProject.get(b.projectId).push(b);
131
+ }
132
+ for (const [projectId, reqs] of byProject.entries()) try {
133
+ const uniqueIds = Array.from(new Set(reqs.map((r) => r.userId)));
134
+ const profiles = await this.userRepo.findRecordsByIds(projectId, uniqueIds);
135
+ const profileMap = new Map(profiles.map((p) => [p.userId, p]));
136
+ for (const req of reqs) req.resolve(profileMap.get(req.userId) || null);
137
+ } catch (err) {
138
+ for (const req of reqs) req.reject(err);
139
+ }
140
+ }
141
+ async process(message) {
142
+ const { event } = message;
143
+ const publishPromises = [];
144
+ let isRequested = true;
145
+ const requestedResult = registry.safeParsePayload("notification.requested", event.payload);
146
+ let createdResult = null;
147
+ if (!requestedResult.success) {
148
+ createdResult = registry.safeParsePayload("notification.created", event.payload);
149
+ isRequested = false;
150
+ }
151
+ if (!isRequested && (!createdResult || !createdResult.success)) {
152
+ const issues = createdResult ? createdResult.error.issues : requestedResult.error.issues;
153
+ this.logger.warn({
154
+ messageId: message.id,
155
+ issues
156
+ }, "invalid payload — skipping");
157
+ return;
158
+ }
159
+ if (!isRequested) {
160
+ const raw = createdResult.data;
161
+ const dedupeId = `${raw.projectId}:${raw.idempotencyKey ?? event.id}`;
162
+ if (!await this.idempotency.checkAndMark(dedupeId, 60)) return;
163
+ try {
164
+ const profile = await this.userRepo.findRecordById(raw.projectId, raw.recipientId);
165
+ if (!profile) return;
166
+ const prefs = await this.prefRepo.findByUserId(raw.projectId, raw.recipientId);
167
+ const optedOutTypes = new Set(prefs.filter((p) => !p.optedIn).map((p) => p.eventType));
168
+ const enrichedPayload = {
169
+ projectId: raw.projectId,
170
+ rawEventId: event.id,
171
+ recipientId: raw.recipientId,
172
+ channel: raw.channel,
173
+ priority: raw.priority,
174
+ templateId: raw.templateId,
175
+ templateVariables: raw.payload,
176
+ recipient: {
177
+ id: profile.userId,
178
+ email: profile.email ?? void 0,
179
+ locale: profile.language ?? "en",
180
+ timezone: profile.timezone ?? "UTC",
181
+ preferences: {
182
+ optedOut: optedOutTypes.has(event.type) || profile.preferences.topics?.[event.type] === false,
183
+ channels: Object.entries(profile.preferences.channels ?? {}).filter(([_, enabled]) => !enabled).map(([channel]) => channel),
184
+ quietHours: profile.preferences.quietHours
185
+ }
186
+ },
187
+ scheduledAt: raw.scheduledAt
188
+ };
189
+ const p = getPriorityBucket(raw.priority);
190
+ const producer = this.producers[p] ?? this.producers["normal"];
191
+ publishPromises.push(new Promise((resolve, reject) => {
192
+ this.eventBuffer.push({
193
+ producer,
194
+ event: buildStreamEvent("notification.enriched", enrichedPayload, "enricher", event.metadata.traceId),
195
+ resolve,
196
+ reject
197
+ });
198
+ }));
199
+ this.logger.info({
200
+ messageId: message.id,
201
+ eventId: event.id,
202
+ recipientId: raw.recipientId
203
+ }, "event enriched");
204
+ } catch (err) {
205
+ throw err;
206
+ }
207
+ await Promise.all(publishPromises).catch(async (err) => {
208
+ await this.idempotency.unmark(dedupeId).catch(() => {});
209
+ throw err;
210
+ });
211
+ await this.idempotency.markProcessed(dedupeId);
212
+ return;
213
+ }
214
+ const raw = requestedResult.data;
215
+ const dedupeId = `${raw.projectId}:${raw.idempotencyKey ?? event.id}`;
216
+ if (!await this.idempotency.checkAndMark(dedupeId, 60)) return;
217
+ try {
218
+ let userIds = [];
219
+ if (raw.target.type === "user") userIds = [raw.target.userId];
220
+ else if (raw.target.type === "segment") {
221
+ userIds = await this.userRepo.findUsersBySegment(raw.projectId, raw.target.segment);
222
+ this.logger.info({
223
+ segment: raw.target.segment,
224
+ count: userIds.length
225
+ }, "Resolved segment");
226
+ } else if (raw.target.type === "topic") {
227
+ userIds = await this.userRepo.findUsersByTopic(raw.projectId, raw.target.topic);
228
+ this.logger.info({
229
+ topic: raw.target.topic,
230
+ count: userIds.length
231
+ }, "Resolved topic");
232
+ } else this.logger.warn({ target: raw.target }, "Segment/topic resolution not fully implemented");
233
+ const maxUsers = readBaseConfig().SEGMENT_MAX_USERS;
234
+ if (userIds.length > maxUsers) {
235
+ this.logger.error({
236
+ count: userIds.length,
237
+ max: maxUsers,
238
+ projectId: raw.projectId,
239
+ eventId: event.id
240
+ }, "Segment fan-out exceeds maximum allowed limit");
241
+ const p = getPriorityBucket(raw.priority ?? "normal");
242
+ await (this.producers[p] ?? this.producers.normal).publish(buildStreamEvent("notification.failed", {
243
+ projectId: raw.projectId,
244
+ rawEventId: event.id,
245
+ error: `Segment fan-out of ${userIds.length} exceeds limit of ${maxUsers}`
246
+ }, "enricher", event.metadata.traceId));
247
+ return;
248
+ }
249
+ const topics = (raw.templateId ? await this.templateCache.getCachedTemplate(raw.projectId, raw.templateId) : null)?.topics ?? [];
250
+ const channels = raw.channels && raw.channels.length > 0 ? raw.channels : ["email"];
251
+ const isFallback = raw.fallback === true;
252
+ const channelsToProcess = isFallback ? [channels[0]] : channels;
253
+ const fallbackChain = isFallback ? channels.slice(1) : void 0;
254
+ const chunkArray = (arr, size) => Array.from({ length: Math.ceil(arr.length / size) }, (v, i) => arr.slice(i * size, i * size + size));
255
+ const chunks = chunkArray(userIds, 500);
256
+ for (const chunk of chunks) {
257
+ const profiles = (await Promise.all(chunk.map((id) => this.loadUser(raw.projectId, id)))).filter(Boolean);
258
+ const contactsByUser = await this.loadContacts(raw.projectId, profiles.map((profile) => profile.userId));
259
+ const batchedEvents = {
260
+ critical: [],
261
+ high: [],
262
+ normal: [],
263
+ low: []
264
+ };
265
+ for (const profile of profiles) for (const channel of channelsToProcess) {
266
+ const channelContacts = (contactsByUser.get(profile.userId) ?? []).filter((contact) => contact.channel === channel);
267
+ const destinations = channel === "push" ? [void 0] : channelContacts.map((c) => c.target);
268
+ if (destinations.length === 0) {
269
+ this.logger.info({
270
+ recipientId: profile.userId,
271
+ channel
272
+ }, "no active contact for channel");
273
+ continue;
274
+ }
275
+ for (const destination of destinations) {
276
+ const enrichedPayload = {
277
+ projectId: raw.projectId,
278
+ rawEventId: event.id,
279
+ recipientId: profile.userId,
280
+ channel,
281
+ priority: "normal",
282
+ templateId: raw.templateId,
283
+ templateVariables: raw.data,
284
+ aiPrompts: raw.aiPrompts,
285
+ recipient: {
286
+ id: profile.userId,
287
+ email: channel === "email" ? destination ?? profile.email ?? void 0 : profile.email ?? void 0,
288
+ phone: channel === "sms" ? destination : void 0,
289
+ webhook: channel === "webhook" ? destination : void 0,
290
+ locale: profile.language ?? "en",
291
+ timezone: profile.timezone ?? "UTC",
292
+ preferences: {
293
+ optedOut: topics.some((t) => profile.preferences.topics?.[t] === false),
294
+ channels: Object.entries(profile.preferences.channels ?? {}).filter(([_, enabled]) => !enabled).map(([channel]) => channel),
295
+ quietHours: profile.preferences.quietHours
296
+ }
297
+ },
298
+ scheduledAt: raw.scheduledAt,
299
+ fallbackChain: fallbackChain?.length ? fallbackChain : void 0,
300
+ campaignId: raw.campaignId
301
+ };
302
+ const msgPriority = raw.priority ?? "normal";
303
+ const p = getPriorityBucket(msgPriority);
304
+ enrichedPayload.priority = msgPriority;
305
+ batchedEvents[p].push(buildStreamEvent("notification.enriched", enrichedPayload, "enricher", event.metadata.traceId));
306
+ }
307
+ }
308
+ for (const p of [
309
+ "critical",
310
+ "normal",
311
+ "low"
312
+ ]) if (batchedEvents[p].length > 0) {
313
+ const producer = this.producers[p] ?? this.producers.normal;
314
+ for (const ev of batchedEvents[p]) publishPromises.push(new Promise((resolve, reject) => {
315
+ this.eventBuffer.push({
316
+ producer,
317
+ event: ev,
318
+ resolve,
319
+ reject
320
+ });
321
+ }));
322
+ }
323
+ }
324
+ this.logger.info({
325
+ messageId: message.id,
326
+ eventId: event.id,
327
+ target: raw.target.type,
328
+ traceId: event.metadata.traceId
329
+ }, "event enriched");
330
+ } catch (err) {
331
+ throw err;
332
+ }
333
+ await Promise.all(publishPromises).catch(async (err) => {
334
+ await this.idempotency.unmark(dedupeId).catch(() => {});
335
+ throw err;
336
+ });
337
+ await this.idempotency.markProcessed(dedupeId);
338
+ }
339
+ };
340
+ async function startEnricherWorker() {
341
+ logger = createLogger({
342
+ name: "enricher",
343
+ level: config.LOG_LEVEL
344
+ });
345
+ redis = new RedisClient({
346
+ url: config.REDIS_URL,
347
+ name: "enricher",
348
+ logger
349
+ });
350
+ const dbData = createDatabase({
351
+ url: config.DATABASE_URL,
352
+ applicationName: "enricher",
353
+ logger
354
+ });
355
+ sql = dbData.sql;
356
+ db = dbData.db;
357
+ consumer = new StreamConsumer({
358
+ redis: redis.native,
359
+ stream: INBOUND_STREAMS,
360
+ group: CONSUMER_GROUPS.ENRICHER,
361
+ consumer: `enricher-${process.pid}`,
362
+ dlqStream: STREAMS.DEAD_LETTER,
363
+ batchSize: config.WORKER_CONCURRENCY,
364
+ logger
365
+ });
366
+ pendingScanner = new PendingMessageScanner({
367
+ redis: redis.native,
368
+ stream: INBOUND_STREAMS,
369
+ group: CONSUMER_GROUPS.ENRICHER,
370
+ consumer: `enricher-${process.pid}`,
371
+ logger
372
+ });
373
+ const producers = {
374
+ critical: new StreamProducer({
375
+ redis: redis.native,
376
+ stream: STREAMS.ENRICHED_CRITICAL,
377
+ logger
378
+ }),
379
+ normal: new StreamProducer({
380
+ redis: redis.native,
381
+ stream: STREAMS.ENRICHED_NORMAL,
382
+ logger
383
+ }),
384
+ low: new StreamProducer({
385
+ redis: redis.native,
386
+ stream: STREAMS.ENRICHED_LOW,
387
+ logger
388
+ })
389
+ };
390
+ const idempotency = new IdempotencyGuard({
391
+ redis: redis.native,
392
+ keyPrefix: "notif:processed:enricher",
393
+ ttlSeconds: 86400
394
+ });
395
+ const userRepo = new UserRepository(db);
396
+ const prefRepo = new PreferenceRepository(db);
397
+ const contactRepo = new ContactRepository(db);
398
+ const templateCache = new TemplateCache(new TemplateRepository(db));
399
+ worker = new EnricherWorker({
400
+ consumer,
401
+ pendingScanner,
402
+ logger,
403
+ maxRetriesBeforeDlq: 5,
404
+ concurrency: config.WORKER_CONCURRENCY,
405
+ producers,
406
+ idempotency,
407
+ userRepo,
408
+ prefRepo,
409
+ contactRepo,
410
+ templateCache
411
+ });
412
+ healthInterval = startHealthReporter("enricher", worker, redis, logger);
413
+ logger.info({ env: config.NODE_ENV }, "enricher starting");
414
+ await worker.start();
415
+ }
416
+ async function stopEnricherWorker() {
417
+ logger?.info("shutdown initiated");
418
+ if (healthInterval) {
419
+ clearInterval(healthInterval);
420
+ healthInterval = null;
421
+ }
422
+ if (worker) await worker.stop();
423
+ if (sql) await sql.end();
424
+ if (redis) await redis.disconnect();
425
+ logger?.info("enricher stopped");
426
+ }
427
+ //#endregion
428
+ export { startEnricherWorker, stopEnricherWorker };
429
+
430
+ //# sourceMappingURL=main-BIcKzWHE.mjs.map