notifkit 0.1.2 → 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.
- package/README.md +130 -122
- package/dist/index.d.mts +196 -132
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/{main-DtHWhueo.mjs → main-40zwq6b0.mjs} +28 -3
- package/dist/{main-DtHWhueo.mjs.map → main-40zwq6b0.mjs.map} +1 -1
- package/dist/{main-DyfbnJc3.mjs → main-BFre2-HQ.mjs} +2 -2
- package/dist/{main-DyfbnJc3.mjs.map → main-BFre2-HQ.mjs.map} +1 -1
- package/dist/{main-CAH0_Q6d.mjs → main-BNJtzY61.mjs} +3 -3
- package/dist/main-BNJtzY61.mjs.map +1 -0
- package/dist/{main-B561M1d3.mjs → main-BOPMYqsW.mjs} +2 -2
- package/dist/{main-B561M1d3.mjs.map → main-BOPMYqsW.mjs.map} +1 -1
- package/dist/{main-CCfc45ev.mjs → main-CiigNpsP.mjs} +7 -4
- package/dist/main-CiigNpsP.mjs.map +1 -0
- package/dist/{main-Ce9dcrsg.mjs → main-DeNFQ-UL.mjs} +6 -3
- package/dist/{main-Ce9dcrsg.mjs.map → main-DeNFQ-UL.mjs.map} +1 -1
- package/dist/{main-B-jwm8ED.mjs → main-DmCPcxOc.mjs} +2 -2
- package/dist/{main-B-jwm8ED.mjs.map → main-DmCPcxOc.mjs.map} +1 -1
- package/dist/{main-C45e7grq.mjs → main-DvgJSm11.mjs} +2 -2
- package/dist/{main-C45e7grq.mjs.map → main-DvgJSm11.mjs.map} +1 -1
- package/dist/{src-C-PfEDMY.mjs → src-vG79L-8m.mjs} +57 -26
- package/dist/src-vG79L-8m.mjs.map +1 -0
- package/drizzle/0002_wide_colleen_wing.sql +2 -0
- package/drizzle/0003_skinny_daimon_hellstrom.sql +1 -0
- package/drizzle/0004_pretty_bruce_banner.sql +1 -0
- package/drizzle/meta/0002_snapshot.json +1460 -0
- package/drizzle/meta/0003_snapshot.json +1460 -0
- package/drizzle/meta/0004_snapshot.json +1470 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +3 -2
- package/src/client.ts +412 -0
- package/src/config/index.ts +107 -0
- package/src/contracts/common.ts +28 -0
- package/src/contracts/envelope.ts +31 -0
- package/src/contracts/events/notification-ai-pending.ts +18 -0
- package/src/contracts/events/notification-canceled.ts +7 -0
- package/src/contracts/events/notification-created.ts +14 -0
- package/src/contracts/events/notification-delivered.ts +17 -0
- package/src/contracts/events/notification-dispatched.ts +45 -0
- package/src/contracts/events/notification-enriched.ts +46 -0
- package/src/contracts/events/notification-failed.ts +19 -0
- package/src/contracts/events/notification-requested.ts +36 -0
- package/src/contracts/events/notification-scheduled.ts +9 -0
- package/src/contracts/events/notification-skipped.ts +9 -0
- package/src/contracts/helpers.ts +21 -0
- package/src/contracts/index.ts +46 -0
- package/src/contracts/metadata.ts +10 -0
- package/src/contracts/registry.ts +88 -0
- package/src/contracts/sdk.ts +242 -0
- package/src/contracts/streams.ts +62 -0
- package/src/db/index.ts +69 -0
- package/src/db/schema.ts +412 -0
- package/src/idempotency/index.ts +50 -0
- package/src/index.ts +19 -0
- package/src/logger/index.ts +60 -0
- package/src/metrics/index.ts +53 -0
- package/src/queue/index.ts +501 -0
- package/src/rate-limiter/index.ts +210 -0
- package/src/redis/index.ts +89 -0
- package/src/repositories/index.ts +1246 -0
- package/src/server.ts +277 -0
- package/src/services/ai/main.ts +404 -0
- package/src/services/api/handlers.ts +1734 -0
- package/src/services/api/http.ts +64 -0
- package/src/services/api/main.ts +693 -0
- package/src/services/api/router.ts +82 -0
- package/src/services/delivery/main.ts +842 -0
- package/src/services/delivery/throttle.ts +71 -0
- package/src/services/engine/main.ts +827 -0
- package/src/services/enricher/main.ts +594 -0
- package/src/services/events/main.ts +365 -0
- package/src/services/scheduler/main.ts +319 -0
- package/src/services/workflow/main.ts +627 -0
- package/src/shared/batch-processor.ts +67 -0
- package/src/shared/cache.ts +47 -0
- package/src/shared/circuit-breaker.ts +74 -0
- package/src/shared/dataloader.ts +41 -0
- package/src/shared/events.ts +3 -0
- package/src/shared/index.ts +39 -0
- package/src/shared/semaphore.ts +33 -0
- package/src/shared/utils.ts +64 -0
- package/src/templates/cache.ts +32 -0
- package/src/templates/index.ts +69 -0
- package/src/templates/render.ts +128 -0
- package/src/transport/index.ts +96 -0
- package/src/unsubscribe/index.ts +127 -0
- package/src/workers/health.ts +31 -0
- package/src/workers/index.ts +266 -0
- package/src/workflows/index.ts +2 -0
- package/src/workflows/registry.ts +21 -0
- package/src/workflows/sdk.ts +106 -0
- package/dist/main-CAH0_Q6d.mjs.map +0 -1
- package/dist/main-CCfc45ev.mjs.map +0 -1
- 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
|
+
}
|