notifkit 0.1.3 → 0.1.4

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.
Files changed (95) hide show
  1. package/README.md +95 -79
  2. package/dist/index.d.mts +199 -135
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/{main-DtHWhueo.mjs → main-40zwq6b0.mjs} +28 -3
  7. package/dist/{main-DtHWhueo.mjs.map → main-40zwq6b0.mjs.map} +1 -1
  8. package/dist/{main-DyfbnJc3.mjs → main-BFre2-HQ.mjs} +2 -2
  9. package/dist/{main-DyfbnJc3.mjs.map → main-BFre2-HQ.mjs.map} +1 -1
  10. package/dist/{main-CAH0_Q6d.mjs → main-BNJtzY61.mjs} +3 -3
  11. package/dist/main-BNJtzY61.mjs.map +1 -0
  12. package/dist/{main-B561M1d3.mjs → main-BOPMYqsW.mjs} +2 -2
  13. package/dist/{main-B561M1d3.mjs.map → main-BOPMYqsW.mjs.map} +1 -1
  14. package/dist/{main-CCfc45ev.mjs → main-CiigNpsP.mjs} +7 -4
  15. package/dist/main-CiigNpsP.mjs.map +1 -0
  16. package/dist/{main-Ce9dcrsg.mjs → main-DeNFQ-UL.mjs} +6 -3
  17. package/dist/{main-Ce9dcrsg.mjs.map → main-DeNFQ-UL.mjs.map} +1 -1
  18. package/dist/{main-B-jwm8ED.mjs → main-DmCPcxOc.mjs} +2 -2
  19. package/dist/{main-B-jwm8ED.mjs.map → main-DmCPcxOc.mjs.map} +1 -1
  20. package/dist/{main-C45e7grq.mjs → main-DvgJSm11.mjs} +2 -2
  21. package/dist/{main-C45e7grq.mjs.map → main-DvgJSm11.mjs.map} +1 -1
  22. package/dist/{src-C-PfEDMY.mjs → src-vG79L-8m.mjs} +57 -26
  23. package/dist/src-vG79L-8m.mjs.map +1 -0
  24. package/drizzle/0002_wide_colleen_wing.sql +2 -0
  25. package/drizzle/0003_skinny_daimon_hellstrom.sql +1 -0
  26. package/drizzle/0004_pretty_bruce_banner.sql +1 -0
  27. package/drizzle/meta/0002_snapshot.json +1460 -0
  28. package/drizzle/meta/0003_snapshot.json +1460 -0
  29. package/drizzle/meta/0004_snapshot.json +1470 -0
  30. package/drizzle/meta/_journal.json +21 -0
  31. package/package.json +2 -1
  32. package/src/client.ts +412 -0
  33. package/src/config/index.ts +107 -0
  34. package/src/contracts/common.ts +28 -0
  35. package/src/contracts/envelope.ts +31 -0
  36. package/src/contracts/events/notification-ai-pending.ts +18 -0
  37. package/src/contracts/events/notification-canceled.ts +7 -0
  38. package/src/contracts/events/notification-created.ts +14 -0
  39. package/src/contracts/events/notification-delivered.ts +17 -0
  40. package/src/contracts/events/notification-dispatched.ts +45 -0
  41. package/src/contracts/events/notification-enriched.ts +46 -0
  42. package/src/contracts/events/notification-failed.ts +19 -0
  43. package/src/contracts/events/notification-requested.ts +36 -0
  44. package/src/contracts/events/notification-scheduled.ts +9 -0
  45. package/src/contracts/events/notification-skipped.ts +9 -0
  46. package/src/contracts/helpers.ts +21 -0
  47. package/src/contracts/index.ts +46 -0
  48. package/src/contracts/metadata.ts +10 -0
  49. package/src/contracts/registry.ts +88 -0
  50. package/src/contracts/sdk.ts +242 -0
  51. package/src/contracts/streams.ts +62 -0
  52. package/src/db/index.ts +69 -0
  53. package/src/db/schema.ts +412 -0
  54. package/src/idempotency/index.ts +50 -0
  55. package/src/index.ts +19 -0
  56. package/src/logger/index.ts +60 -0
  57. package/src/metrics/index.ts +53 -0
  58. package/src/queue/index.ts +501 -0
  59. package/src/rate-limiter/index.ts +210 -0
  60. package/src/redis/index.ts +89 -0
  61. package/src/repositories/index.ts +1246 -0
  62. package/src/server.ts +277 -0
  63. package/src/services/ai/main.ts +404 -0
  64. package/src/services/api/handlers.ts +1734 -0
  65. package/src/services/api/http.ts +64 -0
  66. package/src/services/api/main.ts +693 -0
  67. package/src/services/api/router.ts +82 -0
  68. package/src/services/delivery/main.ts +842 -0
  69. package/src/services/delivery/throttle.ts +71 -0
  70. package/src/services/engine/main.ts +827 -0
  71. package/src/services/enricher/main.ts +594 -0
  72. package/src/services/events/main.ts +365 -0
  73. package/src/services/scheduler/main.ts +319 -0
  74. package/src/services/workflow/main.ts +627 -0
  75. package/src/shared/batch-processor.ts +67 -0
  76. package/src/shared/cache.ts +47 -0
  77. package/src/shared/circuit-breaker.ts +74 -0
  78. package/src/shared/dataloader.ts +41 -0
  79. package/src/shared/events.ts +3 -0
  80. package/src/shared/index.ts +39 -0
  81. package/src/shared/semaphore.ts +33 -0
  82. package/src/shared/utils.ts +64 -0
  83. package/src/templates/cache.ts +32 -0
  84. package/src/templates/index.ts +69 -0
  85. package/src/templates/render.ts +128 -0
  86. package/src/transport/index.ts +96 -0
  87. package/src/unsubscribe/index.ts +127 -0
  88. package/src/workers/health.ts +31 -0
  89. package/src/workers/index.ts +266 -0
  90. package/src/workflows/index.ts +2 -0
  91. package/src/workflows/registry.ts +21 -0
  92. package/src/workflows/sdk.ts +106 -0
  93. package/dist/main-CAH0_Q6d.mjs.map +0 -1
  94. package/dist/main-CCfc45ev.mjs.map +0 -1
  95. package/dist/src-C-PfEDMY.mjs.map +0 -1
package/src/server.ts ADDED
@@ -0,0 +1,277 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { registerTransport, type Transport } from "./transport/index.js";
3
+ import { globalEmitter } from "./shared/index.js";
4
+ import { createLogger, type Logger } from "./logger/index.js";
5
+ import type { LanguageModel } from "ai";
6
+
7
+ export interface NotifkitOptions {
8
+ redisUrl?: string;
9
+ databaseUrl?: string;
10
+ port?: number;
11
+ logLevel?: "fatal" | "error" | "warn" | "info" | "debug" | "trace" | "silent";
12
+ nodeEnv?: "development" | "test" | "production";
13
+ services: (
14
+ "api" | "delivery" | "engine" | "enricher" | "scheduler" | "ai" | "workflow" | "events" | "all"
15
+ )[];
16
+ providers?: Transport[];
17
+ autoMigrate?: boolean;
18
+ aiModel?: LanguageModel;
19
+ workerConcurrency?: number;
20
+ redisOptions?: {
21
+ maxQueueLength?: number;
22
+ };
23
+ dbOptions?: {
24
+ maxConnections?: number;
25
+ };
26
+ }
27
+
28
+ export class NotifkitServer extends EventEmitter {
29
+ private options: NotifkitOptions;
30
+ private pgContainer: any = null;
31
+ private redisContainer: any = null;
32
+ private eventCleanupFns: (() => void)[] = [];
33
+ private signalHandlersAttached = false;
34
+ private sigintHandler?: () => void;
35
+ private sigtermHandler?: () => void;
36
+ private logger: Logger;
37
+
38
+ constructor(options: NotifkitOptions) {
39
+ super();
40
+ this.options = options;
41
+ this.logger = createLogger({
42
+ name: "server",
43
+ level: options.logLevel || (process.env.LOG_LEVEL as any) || "info",
44
+ });
45
+
46
+ // Forward worker/API events to the server instance
47
+ const eventNames = [
48
+ "delivery:delivered",
49
+ "delivery:failed",
50
+ "notification:throttled",
51
+ "notification:failed",
52
+ "notification:skipped",
53
+ "notification:canceled",
54
+ ];
55
+ for (const name of eventNames) {
56
+ const listener = (...args: any[]) => {
57
+ this.emit(name, ...args);
58
+ };
59
+ globalEmitter.on(name, listener);
60
+ this.eventCleanupFns.push(() => {
61
+ globalEmitter.off(name, listener);
62
+ });
63
+ }
64
+ }
65
+
66
+ async start() {
67
+ // 1. Initial configuration setup for environment overrides
68
+ const { setGlobalConfig, readBaseConfig } = await import("./config/index.js");
69
+ if (this.options.port) process.env.PORT = String(this.options.port);
70
+ if (this.options.logLevel) process.env.LOG_LEVEL = this.options.logLevel;
71
+ if (this.options.nodeEnv) process.env.NODE_ENV = this.options.nodeEnv;
72
+ if (this.options.workerConcurrency)
73
+ process.env.WORKER_CONCURRENCY = String(this.options.workerConcurrency);
74
+ if (this.options.redisOptions?.maxQueueLength)
75
+ process.env.QUEUE_MAX_LEN = String(this.options.redisOptions.maxQueueLength);
76
+ if (this.options.dbOptions?.maxConnections)
77
+ process.env.DB_MAX_CONNECTIONS = String(this.options.dbOptions.maxConnections);
78
+
79
+ const isProduction = process.env.NODE_ENV === "production";
80
+
81
+ // 2. Spin up test containers if needed (only in development/test)
82
+ if (!this.options.redisUrl) {
83
+ if (process.env.REDIS_URL) {
84
+ this.options.redisUrl = process.env.REDIS_URL;
85
+ } else if (!isProduction) {
86
+ this.logger.info(
87
+ "No redisUrl provided, spinning up Redis container for development/testing...",
88
+ );
89
+ const { RedisContainer } = await import("@testcontainers/redis");
90
+ this.redisContainer = await new RedisContainer("redis:alpine").start();
91
+ this.options.redisUrl = this.redisContainer.getConnectionUrl();
92
+ } else {
93
+ throw new Error(
94
+ "Missing required configuration: REDIS_URL must be provided when running in production mode.",
95
+ );
96
+ }
97
+ }
98
+
99
+ if (!this.options.databaseUrl) {
100
+ if (process.env.DATABASE_URL) {
101
+ this.options.databaseUrl = process.env.DATABASE_URL;
102
+ } else if (!isProduction) {
103
+ this.logger.info(
104
+ "No databaseUrl provided, spinning up PostgreSQL container for development/testing...",
105
+ );
106
+ const { PostgreSqlContainer } = await import("@testcontainers/postgresql");
107
+ this.pgContainer = await new PostgreSqlContainer("postgres:15-alpine").start();
108
+ this.options.databaseUrl = this.pgContainer.getConnectionUri();
109
+ } else {
110
+ throw new Error(
111
+ "Missing required configuration: DATABASE_URL must be provided when running in production mode.",
112
+ );
113
+ }
114
+ }
115
+
116
+ if (this.options.redisUrl) process.env.REDIS_URL = this.options.redisUrl;
117
+ if (this.options.databaseUrl) process.env.DATABASE_URL = this.options.databaseUrl;
118
+
119
+ // 3. Set global config overrides
120
+ const finalConfig = readBaseConfig();
121
+ setGlobalConfig(finalConfig);
122
+ if (this.options.aiModel) {
123
+ const { setAiConfig } = await import("./config/index.js");
124
+ setAiConfig({ aiModel: this.options.aiModel });
125
+ }
126
+
127
+ // 4. Run database migrations if enabled
128
+ if (this.options.autoMigrate !== false) {
129
+ this.logger.info("Running database migrations...");
130
+ const { createDatabase, runMigrations } = await import("./db/index.js");
131
+ const { db, sql } = createDatabase({ url: this.options.databaseUrl! });
132
+ await runMigrations(db);
133
+ await sql.end();
134
+ this.logger.info("Database migrations complete");
135
+ }
136
+
137
+ const services = this.options.services.includes("all")
138
+ ? ["api", "delivery", "engine", "enricher", "scheduler", "ai", "workflow", "events"]
139
+ : this.options.services;
140
+
141
+ if (this.options.providers) {
142
+ for (const provider of this.options.providers) {
143
+ registerTransport(provider);
144
+ }
145
+ this.logger.info(`Registered ${this.options.providers.length} custom providers`);
146
+ }
147
+
148
+ const startupPromises: Promise<any>[] = [];
149
+
150
+ if (services.includes("api")) {
151
+ const { startApiServer } = await import("./services/api/main.js");
152
+ startupPromises.push(startApiServer());
153
+ }
154
+
155
+ if (services.includes("delivery")) {
156
+ const { startDeliveryWorker } = await import("./services/delivery/main.js");
157
+ startupPromises.push(startDeliveryWorker());
158
+ }
159
+
160
+ if (services.includes("engine")) {
161
+ const { startEngineWorker } = await import("./services/engine/main.js");
162
+ startupPromises.push(startEngineWorker());
163
+ }
164
+
165
+ if (services.includes("enricher")) {
166
+ const { startEnricherWorker } = await import("./services/enricher/main.js");
167
+ startupPromises.push(startEnricherWorker());
168
+ }
169
+
170
+ if (services.includes("scheduler")) {
171
+ const { startSchedulerWorker } = await import("./services/scheduler/main.js");
172
+ startupPromises.push(startSchedulerWorker());
173
+ }
174
+
175
+ if (services.includes("ai")) {
176
+ const { startAiWorker } = await import("./services/ai/main.js");
177
+ startupPromises.push(startAiWorker());
178
+ }
179
+
180
+ if (services.includes("workflow")) {
181
+ const { startWorkflowWorker } = await import("./services/workflow/main.js");
182
+ startupPromises.push(startWorkflowWorker());
183
+ }
184
+
185
+ if (services.includes("events")) {
186
+ const { startEventWorker } = await import("./services/events/main.js");
187
+ startupPromises.push(startEventWorker());
188
+ }
189
+
190
+ const handleSignal = async (signal: string) => {
191
+ this.logger.info(`Received ${signal}, starting graceful shutdown...`);
192
+ await this.stop();
193
+ process.exit(0);
194
+ };
195
+
196
+ if (!this.signalHandlersAttached) {
197
+ this.sigintHandler = () => {
198
+ void handleSignal("SIGINT");
199
+ };
200
+ this.sigtermHandler = () => {
201
+ void handleSignal("SIGTERM");
202
+ };
203
+ process.once("SIGINT", this.sigintHandler);
204
+ process.once("SIGTERM", this.sigtermHandler);
205
+ this.signalHandlersAttached = true;
206
+ }
207
+
208
+ await Promise.all(startupPromises);
209
+ }
210
+
211
+ async stop() {
212
+ if (this.signalHandlersAttached) {
213
+ if (this.sigintHandler) process.removeListener("SIGINT", this.sigintHandler);
214
+ if (this.sigtermHandler) process.removeListener("SIGTERM", this.sigtermHandler);
215
+ this.signalHandlersAttached = false;
216
+ }
217
+
218
+ // Cleanup event listeners to prevent memory leaks
219
+ for (const cleanup of this.eventCleanupFns) {
220
+ cleanup();
221
+ }
222
+ this.eventCleanupFns = [];
223
+
224
+ const services = this.options.services.includes("all")
225
+ ? ["api", "delivery", "engine", "enricher", "scheduler", "ai", "workflow", "events"]
226
+ : this.options.services;
227
+
228
+ if (services.includes("api")) {
229
+ const { stopApiServer } = await import("./services/api/main.js");
230
+ await stopApiServer();
231
+ }
232
+
233
+ if (services.includes("delivery")) {
234
+ const { stopDeliveryWorker } = await import("./services/delivery/main.js");
235
+ await stopDeliveryWorker();
236
+ }
237
+
238
+ if (services.includes("engine")) {
239
+ const { stopEngineWorker } = await import("./services/engine/main.js");
240
+ await stopEngineWorker();
241
+ }
242
+
243
+ if (services.includes("enricher")) {
244
+ const { stopEnricherWorker } = await import("./services/enricher/main.js");
245
+ await stopEnricherWorker();
246
+ }
247
+
248
+ if (services.includes("scheduler")) {
249
+ const { stopSchedulerWorker } = await import("./services/scheduler/main.js");
250
+ await stopSchedulerWorker();
251
+ }
252
+
253
+ if (services.includes("ai")) {
254
+ const { stopAiWorker } = await import("./services/ai/main.js");
255
+ await stopAiWorker();
256
+ }
257
+
258
+ if (services.includes("workflow")) {
259
+ const { stopWorkflowWorker } = await import("./services/workflow/main.js");
260
+ await stopWorkflowWorker();
261
+ }
262
+
263
+ if (services.includes("events")) {
264
+ const { stopEventWorker } = await import("./services/events/main.js");
265
+ await stopEventWorker();
266
+ }
267
+
268
+ if (this.pgContainer) {
269
+ this.logger.info("Stopping PostgreSQL container...");
270
+ await this.pgContainer.stop();
271
+ }
272
+ if (this.redisContainer) {
273
+ this.logger.info("Stopping Redis container...");
274
+ await this.redisContainer.stop();
275
+ }
276
+ }
277
+ }
@@ -0,0 +1,404 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { loadEnv, readBaseConfig } from "@/index.js";
3
+ import { createLogger } from "@/index.js";
4
+ import { RedisClient, type Redis } from "@/index.js";
5
+ import {
6
+ StreamConsumer,
7
+ PendingMessageScanner,
8
+ StreamProducer,
9
+ type StreamMessage,
10
+ } from "@/index.js";
11
+ import { BaseWorker } from "@/index.js";
12
+ import {
13
+ STREAMS,
14
+ CONSUMER_GROUPS,
15
+ registry,
16
+ buildStreamEvent,
17
+ type NotificationAiPendingPayload,
18
+ type NotificationDispatchedPayload,
19
+ getAiConfig,
20
+ AI_DEFAULTS,
21
+ } from "@/index.js";
22
+ import { generateText } from "ai";
23
+ import { type StreamName } from "@/contracts/streams.js";
24
+ import { IdempotencyGuard } from "@/index.js";
25
+ import { TemplateRepository } from "@/index.js";
26
+ import { createDatabase } from "@/db/index.js";
27
+ import { scheduledPayloads } from "@/db/schema.js";
28
+ import { getPriorityBucket, globalEmitter, type WorkerOptions } from "@/shared/index.js";
29
+ import { renderWithTemplate, TemplateCache } from "@/templates/index.js";
30
+ import { startHealthReporter } from "@/workers/index.js";
31
+
32
+ // ─── Bootstrap ─────────────────────────────────────────────────────────────
33
+
34
+ loadEnv();
35
+ const config = readBaseConfig();
36
+
37
+ let logger: ReturnType<typeof createLogger>;
38
+ let redis: RedisClient;
39
+ let sql: any;
40
+ let db: any;
41
+ let templateRepo: TemplateRepository;
42
+
43
+ let templateCache: TemplateCache;
44
+
45
+ let consumer: StreamConsumer;
46
+ let pendingScanner: PendingMessageScanner;
47
+ let worker: BaseWorker;
48
+ let healthInterval: NodeJS.Timeout | null = null;
49
+ let subscriber: any = null;
50
+
51
+ /**
52
+ * A model failure that retrying cannot fix (bad prompt, rejected request,
53
+ * unsupported model). Thrown so the notification fails once instead of being
54
+ * re-billed on every retry.
55
+ */
56
+ export class PermanentAiError extends Error {
57
+ constructor(message: string, options?: ErrorOptions) {
58
+ super(message, options);
59
+ this.name = "PermanentAiError";
60
+ }
61
+ }
62
+
63
+ /** Timeouts, rate limits and 5xx are worth another attempt; 4xx are not. */
64
+ export function isRetryableAiError(err: unknown): boolean {
65
+ if (err instanceof PermanentAiError) return false;
66
+
67
+ const e = err as { name?: string; statusCode?: number; status?: number } | null;
68
+ if (!e) return false;
69
+
70
+ if (e.name === "TimeoutError" || e.name === "AbortError") return true;
71
+
72
+ const status = e.statusCode ?? e.status;
73
+ if (typeof status === "number") {
74
+ return status === 408 || status === 409 || status === 429 || status >= 500;
75
+ }
76
+
77
+ // Unclassifiable (network errors, transport failures) — assume transient.
78
+ return true;
79
+ }
80
+
81
+ export interface AiWorkerOptions extends WorkerOptions {
82
+ registry: any;
83
+ idempotency: any;
84
+ redis: Redis;
85
+ generateAiContent: any;
86
+ templateCache: TemplateCache;
87
+ scheduledProducer: any;
88
+ outboundProducers: any;
89
+ db: any;
90
+ }
91
+
92
+ export class AiWorker extends BaseWorker {
93
+ private readonly registry: any;
94
+ private readonly idempotency: any;
95
+ private readonly redisCli: Redis;
96
+ private readonly generateAiContent: any;
97
+ private readonly templateCache: TemplateCache;
98
+ private readonly scheduledProducer: any;
99
+ private readonly outboundProducers: any;
100
+ private readonly db: any;
101
+
102
+ constructor(options: AiWorkerOptions) {
103
+ super(options);
104
+ this.registry = options.registry;
105
+ this.idempotency = options.idempotency;
106
+ this.redisCli = options.redis;
107
+ this.generateAiContent = options.generateAiContent;
108
+ this.templateCache = options.templateCache;
109
+ this.scheduledProducer = options.scheduledProducer;
110
+ this.outboundProducers = options.outboundProducers;
111
+ this.db = options.db;
112
+ }
113
+ async process(message: StreamMessage): Promise<void> {
114
+ const { event } = message;
115
+
116
+ const payloadResult = this.registry.safeParsePayload("notification.ai_pending", event.payload);
117
+ if (!payloadResult.success) {
118
+ this.logger.warn(
119
+ { messageId: message.id, issues: payloadResult.error.issues },
120
+ "invalid notification.ai_pending payload — skipping",
121
+ );
122
+ return;
123
+ }
124
+
125
+ const pending = payloadResult.data as NotificationAiPendingPayload;
126
+
127
+ // Idempotency
128
+ const idempotencyKey = `${pending.enrichedEventId}:${pending.recipientId}:${pending.channel}:ai`;
129
+ if (!(await this.idempotency.checkAndMark(idempotencyKey))) {
130
+ this.logger.debug(
131
+ { messageId: message.id, eventId: event.id },
132
+ "duplicate ai task — skipping",
133
+ );
134
+ return;
135
+ }
136
+ try {
137
+ // Execute AI prompts. Each key is a separate billed model call, so the
138
+ // count is capped rather than being driven by whatever the caller sent.
139
+ const promptEntries = Object.entries(pending.aiPrompts);
140
+ const maxPrompts =
141
+ getAiConfig().maxPromptsPerNotification ?? AI_DEFAULTS.maxPromptsPerNotification;
142
+ if (promptEntries.length > maxPrompts) {
143
+ this.logger.warn(
144
+ { messageId: message.id, requested: promptEntries.length, maxPrompts },
145
+ "aiPrompts exceeds the per-notification cap — extra prompts ignored",
146
+ );
147
+ }
148
+
149
+ const generatedVars: Record<string, string> = {};
150
+ for (const [key, prompt] of promptEntries.slice(0, maxPrompts)) {
151
+ generatedVars[key] = await this.generateAiContent(prompt, pending.templateVariables);
152
+ }
153
+
154
+ // Merge generated vars with original template vars
155
+ const finalVars = { ...pending.templateVariables, ...generatedVars };
156
+
157
+ const dbTemplate = pending.templateId
158
+ ? await this.templateCache.getCachedTemplate(pending.projectId, pending.templateId)
159
+ : null;
160
+
161
+ const rendered = renderWithTemplate(dbTemplate, finalVars);
162
+
163
+ const taskId = randomUUID();
164
+ const destination =
165
+ pending.channel === "email"
166
+ ? pending.recipient.email
167
+ : pending.channel === "sms" || pending.channel === "whatsapp"
168
+ ? pending.recipient.phone
169
+ : pending.channel === "webhook"
170
+ ? pending.recipient.webhook
171
+ : pending.channel === "telegram"
172
+ ? pending.recipient.telegram
173
+ : pending.channel === "discord"
174
+ ? pending.recipient.discord
175
+ : pending.channel === "slack"
176
+ ? pending.recipient.slack
177
+ : pending.channel === "push"
178
+ ? (pending.recipient.pushTokens?.[0] ?? pending.recipient.pushToken)
179
+ : undefined;
180
+ const resolvedDestination =
181
+ destination ?? (pending.channel === "push" ? undefined : pending.recipientId);
182
+
183
+ const taskPayload: NotificationDispatchedPayload = {
184
+ projectId: pending.projectId,
185
+ taskId,
186
+ enrichedEventId: pending.enrichedEventId,
187
+ recipientId: pending.recipientId,
188
+ channel: pending.channel,
189
+ priority: pending.priority,
190
+ templateId: pending.templateId,
191
+ templateVariables: pending.templateVariables,
192
+ aiPrompts: pending.aiPrompts,
193
+ recipient: pending.recipient,
194
+ renderedContent: rendered,
195
+ destination: resolvedDestination,
196
+ deliveryOptions: {
197
+ maxAttempts: 3,
198
+ timeoutMs: 10_000,
199
+ },
200
+ fallbackChain: pending.fallbackChain,
201
+ };
202
+
203
+ const envelope = buildStreamEvent(
204
+ "notification.dispatched",
205
+ taskPayload as Record<string, unknown>,
206
+ "ai-worker",
207
+ event.metadata.traceId,
208
+ );
209
+
210
+ // Route by scheduledAt
211
+ const now = Date.now();
212
+ const scheduledAt = pending.scheduledAt ? new Date(pending.scheduledAt).getTime() : now;
213
+
214
+ if (scheduledAt > now) {
215
+ await this.db.insert(scheduledPayloads).values({
216
+ taskId,
217
+ payload: taskPayload,
218
+ });
219
+
220
+ const scheduledEnvelope = buildStreamEvent(
221
+ "notification.scheduled",
222
+ {
223
+ projectId: pending.projectId,
224
+ enrichedEventId: pending.enrichedEventId,
225
+ taskId,
226
+ scheduledAt: pending.scheduledAt!,
227
+ },
228
+ "ai-worker",
229
+ event.metadata.traceId,
230
+ );
231
+
232
+ await this.scheduledProducer.publish(scheduledEnvelope);
233
+ this.logger.info(
234
+ {
235
+ messageId: message.id,
236
+ taskId,
237
+ scheduledAt: pending.scheduledAt,
238
+ traceId: event.metadata.traceId,
239
+ },
240
+ "task scheduled and payload cached after AI generation",
241
+ );
242
+ } else {
243
+ const p = getPriorityBucket(pending.priority);
244
+ const outboundProducer = this.outboundProducers[p] ?? this.outboundProducers["normal"]!;
245
+
246
+ await outboundProducer.publish(envelope);
247
+ this.logger.info(
248
+ {
249
+ messageId: message.id,
250
+ taskId,
251
+ recipientId: pending.recipientId,
252
+ traceId: event.metadata.traceId,
253
+ },
254
+ "task dispatched after AI generation",
255
+ );
256
+ }
257
+ } catch (err) {
258
+ await this.idempotency.unmark(idempotencyKey);
259
+ if (err instanceof PermanentAiError || (err as Error)?.name === "PermanentAiError") {
260
+ // Retrying re-bills the same failing prompt. Fail the notification once.
261
+ this.logger.error(
262
+ { err, messageId: message.id, recipientId: pending.recipientId },
263
+ "AI generation failed permanently — dropping notification without retry",
264
+ );
265
+ globalEmitter.emit(
266
+ "notification:failed",
267
+ pending.enrichedEventId,
268
+ (err as Error).message,
269
+ pending.channel,
270
+ );
271
+ return;
272
+ }
273
+ throw err;
274
+ }
275
+ }
276
+ }
277
+
278
+ export async function startAiWorker() {
279
+ logger = createLogger({ name: "ai", level: config.LOG_LEVEL });
280
+ redis = new RedisClient({ url: config.REDIS_URL, name: "ai", logger });
281
+ const dbData = createDatabase({ url: config.DATABASE_URL, applicationName: "ai", logger });
282
+ sql = dbData.sql;
283
+ db = dbData.db;
284
+ templateRepo = new TemplateRepository(db);
285
+ templateCache = new TemplateCache(templateRepo);
286
+ consumer = new StreamConsumer({
287
+ redis: redis.native,
288
+ stream: STREAMS.AI_PENDING as StreamName,
289
+ group: CONSUMER_GROUPS.AI,
290
+ consumer: `ai-${process.pid}`,
291
+ dlqStream: STREAMS.DEAD_LETTER,
292
+ batchSize: config.WORKER_CONCURRENCY,
293
+ logger,
294
+ });
295
+
296
+ pendingScanner = new PendingMessageScanner({
297
+ redis: redis.native,
298
+ stream: STREAMS.AI_PENDING as StreamName,
299
+ group: CONSUMER_GROUPS.AI,
300
+ consumer: `ai-${process.pid}`,
301
+ logger,
302
+ });
303
+
304
+ const outboundProducers = {
305
+ critical: new StreamProducer({
306
+ redis: redis.native,
307
+ stream: STREAMS.OUTBOUND_CRITICAL,
308
+ logger,
309
+ }),
310
+ normal: new StreamProducer({ redis: redis.native, stream: STREAMS.OUTBOUND_NORMAL, logger }),
311
+ low: new StreamProducer({ redis: redis.native, stream: STREAMS.OUTBOUND_LOW, logger }),
312
+ };
313
+
314
+ const scheduledProducer = new StreamProducer({
315
+ redis: redis.native,
316
+ stream: STREAMS.SCHEDULED,
317
+ logger,
318
+ });
319
+
320
+ const idempotency = new IdempotencyGuard({
321
+ redis: redis.native,
322
+ keyPrefix: "notif:processed:ai",
323
+ ttlSeconds: 86_400,
324
+ });
325
+
326
+ // AI generation
327
+ async function generateAiContent(prompt: string, vars: Record<string, unknown>): Promise<string> {
328
+ const aiConfig = getAiConfig();
329
+ const interpolated = prompt.replace(/\{\{(\w+)\}\}/g, (_, k: string) => String(vars[k] ?? ""));
330
+
331
+ if (!aiConfig || !aiConfig.aiModel) {
332
+ logger.warn("AI worker called but no AI model was provided to NotifkitServer");
333
+ return `[AI Disabled] ${interpolated}`;
334
+ }
335
+
336
+ const maxOutputTokens = aiConfig.maxOutputTokens ?? AI_DEFAULTS.maxOutputTokens;
337
+ const timeoutMs = aiConfig.timeoutMs ?? AI_DEFAULTS.timeoutMs;
338
+
339
+ try {
340
+ const { text } = await generateText({
341
+ model: aiConfig.aiModel,
342
+ prompt: interpolated,
343
+ maxOutputTokens,
344
+ abortSignal: AbortSignal.timeout(timeoutMs),
345
+ });
346
+
347
+ return text;
348
+ } catch (err) {
349
+ // BaseWorker retries a throw up to maxRetriesBeforeDlq, and every retry is
350
+ // another billed call. Only re-throw for failures a retry could actually
351
+ // fix; a malformed prompt or a rejected request must not be re-billed.
352
+ if (isRetryableAiError(err)) {
353
+ logger.error({ err }, "AI generation failed (transient) — will retry");
354
+ throw err;
355
+ }
356
+ throw new PermanentAiError(err instanceof Error ? err.message : String(err), { cause: err });
357
+ }
358
+ }
359
+
360
+ worker = new AiWorker({
361
+ consumer,
362
+ pendingScanner,
363
+ logger,
364
+ concurrency: config.WORKER_CONCURRENCY,
365
+ registry,
366
+ idempotency,
367
+ redis: redis.native,
368
+ generateAiContent,
369
+ templateCache,
370
+ scheduledProducer,
371
+ outboundProducers,
372
+ db,
373
+ });
374
+
375
+ subscriber = redis.native.duplicate();
376
+ await subscriber.subscribe("template.invalidated");
377
+ subscriber.on("message", (channel: string, message: string) => {
378
+ if (channel === "template.invalidated") {
379
+ templateCache.invalidateKey(message);
380
+ logger.info({ cacheKey: message }, "invalidated template cache");
381
+ }
382
+ });
383
+
384
+ healthInterval = startHealthReporter("ai", worker, redis, logger);
385
+
386
+ logger.info({ env: config.NODE_ENV, redis: config.REDIS_URL }, "ai starting");
387
+ await worker.start();
388
+ }
389
+
390
+ export async function stopAiWorker(): Promise<void> {
391
+ logger?.info("shutdown initiated");
392
+ if (healthInterval) {
393
+ clearInterval(healthInterval);
394
+ healthInterval = null;
395
+ }
396
+ if (subscriber) {
397
+ subscriber.disconnect();
398
+ subscriber = null;
399
+ }
400
+ if (worker) await worker.stop();
401
+ if (sql) await sql.end();
402
+ if (redis) await redis.disconnect();
403
+ logger?.info("ai stopped");
404
+ }