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.
Files changed (95) hide show
  1. package/README.md +130 -122
  2. package/dist/index.d.mts +196 -132
  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 +3 -2
  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
@@ -0,0 +1,127 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * One-click unsubscribe (RFC 8058).
5
+ *
6
+ * Gmail and Yahoo have required `List-Unsubscribe` + `List-Unsubscribe-Post` on
7
+ * bulk mail since early 2024; without them bulk sends get throttled or junked,
8
+ * and because deliverability is reputation on the *sending domain*, that
9
+ * eventually drags transactional mail down with it.
10
+ *
11
+ * The link has to work from an inbox, years later, with no session — so the
12
+ * token carries its own claim and is signed rather than looked up. Nothing is
13
+ * stored per message, and there is no expiry: an unsubscribe link that has
14
+ * stopped working is worse than useless, because the recipient's next move is
15
+ * the spam button.
16
+ */
17
+
18
+ export interface UnsubscribeClaim {
19
+ /** Project the send belonged to. */
20
+ projectId: string;
21
+ /** The user's external id, as supplied by the caller. */
22
+ userId: string;
23
+ channel: string;
24
+ /** The address itself — used when there is no topic to opt out of. */
25
+ target: string;
26
+ /** Topics the template belonged to. Empty means "suppress the address". */
27
+ topics: string[];
28
+ }
29
+
30
+ interface WireClaim {
31
+ p: string;
32
+ u: string;
33
+ c: string;
34
+ t: string;
35
+ k: string[];
36
+ }
37
+
38
+ function b64url(input: Buffer | string): string {
39
+ return Buffer.from(input).toString("base64url");
40
+ }
41
+
42
+ /**
43
+ * Sign a claim into a URL-safe token.
44
+ *
45
+ * The signature covers the exact encoded payload rather than a re-serialisation
46
+ * of it, so a verifier never has to reproduce this function's JSON key order to
47
+ * get a matching MAC.
48
+ */
49
+ export function signUnsubscribeToken(claim: UnsubscribeClaim, secret: string): string {
50
+ const wire: WireClaim = {
51
+ p: claim.projectId,
52
+ u: claim.userId,
53
+ c: claim.channel,
54
+ t: claim.target,
55
+ k: claim.topics,
56
+ };
57
+ const payload = b64url(JSON.stringify(wire));
58
+ const mac = b64url(createHmac("sha256", secret).update(payload).digest());
59
+ return `${payload}.${mac}`;
60
+ }
61
+
62
+ /**
63
+ * Verify and decode a token. Returns null for anything not signed by `secret`.
64
+ *
65
+ * Every failure returns the same null rather than a reason: the caller is an
66
+ * unauthenticated endpoint, and distinguishing "malformed" from "bad signature"
67
+ * hands an attacker a probe.
68
+ */
69
+ export function verifyUnsubscribeToken(token: string, secret: string): UnsubscribeClaim | null {
70
+ const dot = token.indexOf(".");
71
+ if (dot <= 0 || dot === token.length - 1) return null;
72
+
73
+ const payload = token.slice(0, dot);
74
+ const provided = Buffer.from(token.slice(dot + 1), "base64url");
75
+ const expected = createHmac("sha256", secret).update(payload).digest();
76
+
77
+ // timingSafeEqual throws on a length mismatch, which is itself a signal.
78
+ if (provided.length !== expected.length) return null;
79
+ if (!timingSafeEqual(provided, expected)) return null;
80
+
81
+ try {
82
+ const wire = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as WireClaim;
83
+ if (
84
+ typeof wire.p !== "string" ||
85
+ typeof wire.u !== "string" ||
86
+ typeof wire.c !== "string" ||
87
+ typeof wire.t !== "string" ||
88
+ !Array.isArray(wire.k)
89
+ ) {
90
+ return null;
91
+ }
92
+ return {
93
+ projectId: wire.p,
94
+ userId: wire.u,
95
+ channel: wire.c,
96
+ target: wire.t,
97
+ topics: wire.k.filter((t): t is string => typeof t === "string"),
98
+ };
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ export interface UnsubscribeHeaderOptions {
105
+ claim: UnsubscribeClaim;
106
+ secret: string;
107
+ /** Externally reachable base URL of the API, e.g. https://notify.example.com */
108
+ publicUrl: string;
109
+ }
110
+
111
+ /**
112
+ * The two headers that make an inbox render a real unsubscribe button.
113
+ *
114
+ * `List-Unsubscribe-Post` is what upgrades the link from "open this URL" to
115
+ * one-click: the mail client POSTs directly and never shows the recipient a
116
+ * landing page. Sending the URL without it means the recipient has to click
117
+ * through and confirm, which mailbox providers do not count as compliant.
118
+ */
119
+ export function buildUnsubscribeHeaders(options: UnsubscribeHeaderOptions): Record<string, string> {
120
+ const token = signUnsubscribeToken(options.claim, options.secret);
121
+ const base = options.publicUrl.replace(/\/$/, "");
122
+ const url = `${base}/v1/unsubscribe?token=${encodeURIComponent(token)}`;
123
+ return {
124
+ "List-Unsubscribe": `<${url}>`,
125
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
126
+ };
127
+ }
@@ -0,0 +1,31 @@
1
+ import type { Logger } from "@/index.js";
2
+ import type { BaseWorker } from "./index.js";
3
+
4
+ export function startHealthReporter(
5
+ serviceName: string,
6
+ worker: BaseWorker,
7
+ redis: { healthCheck: () => Promise<boolean>; native: any },
8
+ logger: Logger,
9
+ intervalMs = 1000,
10
+ ): NodeJS.Timeout {
11
+ return setInterval(() => {
12
+ void (async () => {
13
+ try {
14
+ const redisOk = await redis.healthCheck();
15
+ await redis.native.set(
16
+ `notif:health:${serviceName}`,
17
+ JSON.stringify({
18
+ service: serviceName,
19
+ redis: redisOk,
20
+ ...worker.health(),
21
+ updatedAt: new Date().toISOString(),
22
+ }),
23
+ "EX",
24
+ 15,
25
+ );
26
+ } catch {
27
+ // A failed health write is not worth failing the worker over.
28
+ }
29
+ })();
30
+ }, intervalMs);
31
+ }
@@ -0,0 +1,266 @@
1
+ import type { Logger } from "@/index.js";
2
+ import type { StreamConsumer, PendingMessageScanner, StreamMessage } from "@/index.js";
3
+ import { globalEmitter, AsyncSemaphore } from "@/shared/index.js";
4
+ import { metrics } from "@/metrics/index.js";
5
+ export * from "./health.js";
6
+
7
+ // ─── Types ─────────────────────────────────────────────────────────────────
8
+
9
+ export class NonRetryableError extends Error {
10
+ readonly nonRetryable = true;
11
+ constructor(message: string) {
12
+ super(message);
13
+ this.name = "NonRetryableError";
14
+ }
15
+ }
16
+
17
+ export type WorkerState = "idle" | "running" | "stopping" | "stopped" | "error";
18
+
19
+ export interface WorkerHealth {
20
+ state: WorkerState;
21
+ processedCount: number;
22
+ errorCount: number;
23
+ lastProcessedAt: string | null;
24
+ lastErrorAt: string | null;
25
+ pendingCount: number | null;
26
+ }
27
+
28
+ export interface WorkerOptions {
29
+ consumer: StreamConsumer;
30
+ pendingScanner: PendingMessageScanner;
31
+ logger: Logger;
32
+ concurrency?: number;
33
+ recoveryIntervalMs?: number;
34
+ maxRetriesBeforeDlq?: number;
35
+ }
36
+
37
+ // ─── BaseWorker ────────────────────────────────────────────────────────────
38
+
39
+ export abstract class BaseWorker {
40
+ protected readonly logger: Logger;
41
+
42
+ private readonly consumer: StreamConsumer;
43
+ private readonly pendingScanner: PendingMessageScanner;
44
+ private readonly concurrency: number;
45
+ private readonly recoveryIntervalMs: number;
46
+ private readonly maxRetriesBeforeDlq: number;
47
+
48
+ private state: WorkerState = "idle";
49
+ private stopping = false;
50
+ private processedCount = 0;
51
+ private errorCount = 0;
52
+ private lastProcessedAt: string | null = null;
53
+ private lastErrorAt: string | null = null;
54
+ private recoveryTimer: ReturnType<typeof setInterval> | null = null;
55
+ private lastPendingCount: number | null = null;
56
+ private readonly active = new Set<Promise<void>>();
57
+ private readonly semaphore: AsyncSemaphore;
58
+ private runLoop?: Promise<void>;
59
+
60
+ constructor({
61
+ consumer,
62
+ pendingScanner,
63
+ logger,
64
+ concurrency = 10,
65
+ recoveryIntervalMs = 60_000,
66
+ maxRetriesBeforeDlq = 3,
67
+ }: WorkerOptions) {
68
+ this.consumer = consumer;
69
+ this.pendingScanner = pendingScanner;
70
+ this.logger = logger.child({ component: this.constructor.name });
71
+ this.concurrency = concurrency;
72
+ this.recoveryIntervalMs = recoveryIntervalMs;
73
+ this.maxRetriesBeforeDlq = maxRetriesBeforeDlq;
74
+ this.semaphore = new AsyncSemaphore(concurrency);
75
+ }
76
+
77
+ protected abstract process(message: StreamMessage, attempt?: number): Promise<void>;
78
+
79
+ async start(): Promise<void> {
80
+ if (this.state !== "idle") {
81
+ throw new Error(`Worker cannot start from state: ${this.state}`);
82
+ }
83
+
84
+ this.state = "running";
85
+ this.logger.info({ concurrency: this.concurrency }, "worker starting");
86
+
87
+ await this.consumer.ensureGroup();
88
+ this.startRecoveryLoop();
89
+
90
+ this.runLoop = this.consume();
91
+ }
92
+
93
+ private async consume(): Promise<void> {
94
+ for await (const batch of this.consumer.readBatch()) {
95
+ if (this.stopping) break;
96
+
97
+ for (const message of batch) {
98
+ if (this.stopping) break;
99
+
100
+ await this.semaphore.acquire();
101
+
102
+ const task = this.processWithTracking(message).finally(() => {
103
+ this.active.delete(task);
104
+ this.semaphore.release();
105
+ });
106
+
107
+ this.active.add(task);
108
+ }
109
+ }
110
+
111
+ await Promise.allSettled([...this.active]);
112
+ this.state = "stopped";
113
+ this.logger.info("worker stopped");
114
+ }
115
+
116
+ async stop(): Promise<void> {
117
+ if (this.stopping || this.state !== "running") return;
118
+
119
+ this.logger.info("worker stopping");
120
+ this.stopping = true;
121
+ this.state = "stopping";
122
+ await this.consumer.stop();
123
+ this.stopRecoveryLoop();
124
+
125
+ if (this.runLoop) {
126
+ await Promise.race([
127
+ this.runLoop,
128
+ new Promise((_, reject) =>
129
+ setTimeout(() => reject(new Error("Worker stop timeout")), 30_000),
130
+ ),
131
+ ]).catch((err) => this.logger.warn({ err }, "Worker shutdown timeout or error"));
132
+ }
133
+
134
+ this.logger.info("worker shutdown complete");
135
+ }
136
+
137
+ async recover(): Promise<void> {
138
+ this.logger.debug("scanning for stale pending messages");
139
+
140
+ const pendingCount = await this.pendingScanner.getPendingCount();
141
+ this.lastPendingCount = pendingCount;
142
+ if (pendingCount === 0) return;
143
+
144
+ this.logger.info({ pendingCount }, "found pending messages, attempting autoclaim");
145
+
146
+ const BATCH_SIZE = 1000;
147
+ while (!this.stopping) {
148
+ const messages = await this.pendingScanner.autoclaim(this.recoveryIntervalMs, BATCH_SIZE);
149
+ if (messages.length === 0) {
150
+ break; // No more eligible messages to claim
151
+ }
152
+
153
+ for (const message of messages) {
154
+ if (this.stopping) break;
155
+
156
+ await this.semaphore.acquire();
157
+
158
+ const task = this.processWithTracking(message).finally(() => {
159
+ this.active.delete(task);
160
+ this.semaphore.release();
161
+ });
162
+
163
+ this.active.add(task);
164
+ }
165
+ }
166
+ }
167
+
168
+ health(): WorkerHealth {
169
+ return {
170
+ state: this.state,
171
+ processedCount: this.processedCount,
172
+ errorCount: this.errorCount,
173
+ lastProcessedAt: this.lastProcessedAt,
174
+ lastErrorAt: this.lastErrorAt,
175
+ // Refreshed by the recovery loop; health() stays synchronous so the
176
+ // reporter never blocks on Redis.
177
+ pendingCount: this.lastPendingCount,
178
+ };
179
+ }
180
+
181
+ private async processWithTracking(message: StreamMessage): Promise<void> {
182
+ const start = Date.now();
183
+ const stream = message.stream;
184
+ const retryKey = `notif:worker:retries:${this.constructor.name}:${stream ?? "default"}:${message.id}`;
185
+
186
+ try {
187
+ const results = await this.consumer.redis
188
+ .multi()
189
+ .incr(retryKey)
190
+ .expire(retryKey, 7200)
191
+ .exec();
192
+ const retryCount = (results?.[0]?.[1] as number) ?? 1;
193
+
194
+ if (retryCount > this.maxRetriesBeforeDlq) {
195
+ this.logger.warn(
196
+ { messageId: message.id, retryCount },
197
+ "max retries exceeded, moving to dead-letter queue",
198
+ );
199
+ await this.consumer.nack(message.id, message.event, stream);
200
+ await this.consumer.redis.del(retryKey);
201
+ globalEmitter.emit(
202
+ "notification:failed",
203
+ message.id,
204
+ "Poison pill: max retries exceeded",
205
+ message.event.type,
206
+ );
207
+ return;
208
+ }
209
+
210
+ await this.process(message, retryCount);
211
+
212
+ await this.consumer.ack(message.id, stream);
213
+ await this.consumer.redis.del(retryKey);
214
+ this.processedCount += 1;
215
+ this.lastProcessedAt = new Date().toISOString();
216
+ metrics.messagesProcessed.inc({ worker: this.constructor.name, status: "success" });
217
+
218
+ this.logger.debug(
219
+ { messageId: message.id, eventType: message.event.type, durationMs: Date.now() - start },
220
+ "message processed",
221
+ );
222
+ } catch (err) {
223
+ this.errorCount += 1;
224
+ this.lastErrorAt = new Date().toISOString();
225
+ metrics.messagesProcessed.inc({ worker: this.constructor.name, status: "error" });
226
+
227
+ if (err instanceof NonRetryableError || (err as any)?.nonRetryable) {
228
+ this.logger.warn(
229
+ { err, messageId: message.id },
230
+ "non-retryable error encountered, immediately moving to dead-letter queue without retry loop",
231
+ );
232
+ await this.consumer.nack(message.id, message.event, stream);
233
+ await this.consumer.redis.del(retryKey);
234
+ globalEmitter.emit(
235
+ "notification:failed",
236
+ message.id,
237
+ (err as Error).message,
238
+ message.event.type,
239
+ );
240
+ return;
241
+ }
242
+
243
+ this.logger.error(
244
+ { err, messageId: message.id, eventType: message.event.type },
245
+ "failed to process message",
246
+ );
247
+ }
248
+ }
249
+
250
+ private startRecoveryLoop(): void {
251
+ this.recoveryTimer = setInterval(() => {
252
+ if (this.state === "running") {
253
+ this.recover().catch((err: unknown) => {
254
+ this.logger.error({ err }, "recovery loop error");
255
+ });
256
+ }
257
+ }, this.recoveryIntervalMs);
258
+ }
259
+
260
+ private stopRecoveryLoop(): void {
261
+ if (this.recoveryTimer) {
262
+ clearInterval(this.recoveryTimer);
263
+ this.recoveryTimer = null;
264
+ }
265
+ }
266
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./sdk.js";
2
+ export * from "./registry.js";
@@ -0,0 +1,21 @@
1
+ import type { WorkflowContext } from "./sdk.js";
2
+
3
+ type WorkflowHandler = (ctx: WorkflowContext) => Promise<void>;
4
+
5
+ class WorkflowRegistry {
6
+ private workflows = new Map<string, WorkflowHandler>();
7
+
8
+ register(name: string, handler: WorkflowHandler) {
9
+ this.workflows.set(name, handler);
10
+ }
11
+
12
+ get(name: string): WorkflowHandler | undefined {
13
+ return this.workflows.get(name);
14
+ }
15
+ }
16
+
17
+ export const workflowRegistry = new WorkflowRegistry();
18
+
19
+ export function workflow(name: string, handler: WorkflowHandler) {
20
+ workflowRegistry.register(name, handler);
21
+ }
@@ -0,0 +1,106 @@
1
+ import type { WorkflowNotifyInput } from "@/contracts/sdk.js";
2
+ import type {
3
+ NotificationRequestedPayload,
4
+ NotificationTarget,
5
+ } from "@/contracts/events/notification-requested.js";
6
+
7
+ export class SuspendExecutionError extends Error {
8
+ constructor(
9
+ public reason: "wait" | "waitForEvent",
10
+ public payload: any,
11
+ ) {
12
+ super(`Execution suspended for ${reason}`);
13
+ this.name = "SuspendExecutionError";
14
+ }
15
+ }
16
+
17
+ export interface WorkflowEvent {
18
+ user: { id: string };
19
+ [key: string]: any;
20
+ }
21
+
22
+ export interface WorkflowContext {
23
+ step: WorkflowStepContext;
24
+ event: WorkflowEvent;
25
+ }
26
+
27
+ /** What a completed `step.notify()` records and returns. */
28
+ export interface WorkflowNotifyResult {
29
+ success: boolean;
30
+ messageId: string;
31
+ notificationId: string;
32
+ }
33
+
34
+ export interface WorkflowStepContext {
35
+ notify(payload: WorkflowNotifyInput): Promise<WorkflowNotifyResult>;
36
+ wait(duration: string): Promise<void>;
37
+ waitForEvent(
38
+ eventName: string,
39
+ options?: { timeout?: string; match?: Record<string, any> },
40
+ ): Promise<any | null>;
41
+ run<T>(name: string, fn: () => Promise<T> | T): Promise<T>;
42
+ }
43
+
44
+ /**
45
+ * Works out who a `step.notify()` call is for.
46
+ *
47
+ * A target named in the step payload wins. Only when the step names none does
48
+ * the notification fall back to the instance's own user — the common case, and
49
+ * the reason most steps carry no target at all.
50
+ */
51
+ export function resolveStepTarget(
52
+ args: WorkflowNotifyInput,
53
+ instanceInput: unknown,
54
+ ): NotificationTarget {
55
+ if (args.segment !== undefined) return { type: "segment", segment: args.segment };
56
+ if (args.topic !== undefined) return { type: "topic", topic: args.topic };
57
+
58
+ if (args.user !== undefined) {
59
+ if (Array.isArray(args.user)) {
60
+ throw new Error(
61
+ "step.notify() takes a single `user`. To reach several people from one workflow, " +
62
+ "use one notify step each, or target a `segment`.",
63
+ );
64
+ }
65
+ return {
66
+ type: "user",
67
+ userId: typeof args.user === "string" ? args.user : args.user.id,
68
+ };
69
+ }
70
+
71
+ const inherited = (instanceInput as { user?: { id?: string } } | null)?.user?.id;
72
+ if (!inherited) {
73
+ throw new Error(
74
+ "step.notify() has no target: the step payload names no `user`, `segment` or `topic`, " +
75
+ "and the workflow instance was triggered without `input.user.id`.",
76
+ );
77
+ }
78
+ return { type: "user", userId: inherited };
79
+ }
80
+
81
+ /**
82
+ * Maps a `step.notify()` payload onto the wire event the pipeline consumes.
83
+ *
84
+ * The field names differ either side of the boundary — `template` becomes
85
+ * `templateId`, `sendAt` becomes `scheduledAt` — so this translation is
86
+ * deliberate rather than a spread, and the return type keeps it honest.
87
+ */
88
+ export function buildStepNotifyPayload(
89
+ args: WorkflowNotifyInput,
90
+ instanceInput: unknown,
91
+ projectId: string,
92
+ idempotencyKey?: string,
93
+ ): NotificationRequestedPayload {
94
+ return {
95
+ projectId,
96
+ target: resolveStepTarget(args, instanceInput),
97
+ templateId: args.template,
98
+ priority: args.priority ?? "normal",
99
+ channels: args.channels,
100
+ data: args.data ?? {},
101
+ aiPrompts: args.aiPrompts,
102
+ fallback: args.fallback ?? false,
103
+ scheduledAt: args.sendAt,
104
+ idempotencyKey,
105
+ };
106
+ }
@@ -1 +0,0 @@
1
- {"version":3,"file":"main-CAH0_Q6d.mjs","names":[],"sources":["../src/services/ai/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 NotificationAiPendingPayload,\n type NotificationDispatchedPayload,\n getAiConfig,\n AI_DEFAULTS,\n} from \"@/index.js\";\nimport { generateText } from \"ai\";\nimport { type StreamName } from \"@/contracts/streams.js\";\nimport { IdempotencyGuard } from \"@/index.js\";\nimport { TemplateRepository } from \"@/index.js\";\nimport { createDatabase } from \"@/db/index.js\";\nimport { scheduledPayloads } from \"@/db/schema.js\";\nimport { getPriorityBucket, globalEmitter, type WorkerOptions } from \"@/shared/index.js\";\nimport { renderWithTemplate, TemplateCache } from \"@/templates/index.js\";\nimport { startHealthReporter } from \"@/workers/index.js\";\n\n// ─── Bootstrap ─────────────────────────────────────────────────────────────\n\nloadEnv();\nconst config = readBaseConfig();\n\nlet logger: ReturnType<typeof createLogger>;\nlet redis: RedisClient;\nlet sql: any;\nlet db: any;\nlet templateRepo: TemplateRepository;\n\nlet templateCache: TemplateCache;\n\nlet consumer: StreamConsumer;\nlet pendingScanner: PendingMessageScanner;\nlet worker: BaseWorker;\nlet healthInterval: NodeJS.Timeout | null = null;\nlet subscriber: any = null;\n\n/**\n * A model failure that retrying cannot fix (bad prompt, rejected request,\n * unsupported model). Thrown so the notification fails once instead of being\n * re-billed on every retry.\n */\nexport class PermanentAiError extends Error {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"PermanentAiError\";\n }\n}\n\n/** Timeouts, rate limits and 5xx are worth another attempt; 4xx are not. */\nexport function isRetryableAiError(err: unknown): boolean {\n if (err instanceof PermanentAiError) return false;\n\n const e = err as { name?: string; statusCode?: number; status?: number } | null;\n if (!e) return false;\n\n if (e.name === \"TimeoutError\" || e.name === \"AbortError\") return true;\n\n const status = e.statusCode ?? e.status;\n if (typeof status === \"number\") {\n return status === 408 || status === 409 || status === 429 || status >= 500;\n }\n\n // Unclassifiable (network errors, transport failures) — assume transient.\n return true;\n}\n\nexport interface AiWorkerOptions extends WorkerOptions {\n registry: any;\n idempotency: any;\n redis: Redis;\n generateAiContent: any;\n templateCache: TemplateCache;\n scheduledProducer: any;\n outboundProducers: any;\n db: any;\n}\n\nexport class AiWorker extends BaseWorker {\n private readonly registry: any;\n private readonly idempotency: any;\n private readonly redisCli: Redis;\n private readonly generateAiContent: any;\n private readonly templateCache: TemplateCache;\n private readonly scheduledProducer: any;\n private readonly outboundProducers: any;\n private readonly db: any;\n\n constructor(options: AiWorkerOptions) {\n super(options);\n this.registry = options.registry;\n this.idempotency = options.idempotency;\n this.redisCli = options.redis;\n this.generateAiContent = options.generateAiContent;\n this.templateCache = options.templateCache;\n this.scheduledProducer = options.scheduledProducer;\n this.outboundProducers = options.outboundProducers;\n this.db = options.db;\n }\n async process(message: StreamMessage): Promise<void> {\n const { event } = message;\n\n const payloadResult = this.registry.safeParsePayload(\"notification.ai_pending\", event.payload);\n if (!payloadResult.success) {\n this.logger.warn(\n { messageId: message.id, issues: payloadResult.error.issues },\n \"invalid notification.ai_pending payload — skipping\",\n );\n return;\n }\n\n const pending = payloadResult.data as NotificationAiPendingPayload;\n\n // Idempotency\n const idempotencyKey = `${pending.enrichedEventId}:${pending.recipientId}:${pending.channel}:ai`;\n if (!(await this.idempotency.checkAndMark(idempotencyKey))) {\n this.logger.debug(\n { messageId: message.id, eventId: event.id },\n \"duplicate ai task — skipping\",\n );\n return;\n }\n try {\n // Execute AI prompts. Each key is a separate billed model call, so the\n // count is capped rather than being driven by whatever the caller sent.\n const promptEntries = Object.entries(pending.aiPrompts);\n const maxPrompts =\n getAiConfig().maxPromptsPerNotification ?? AI_DEFAULTS.maxPromptsPerNotification;\n if (promptEntries.length > maxPrompts) {\n this.logger.warn(\n { messageId: message.id, requested: promptEntries.length, maxPrompts },\n \"aiPrompts exceeds the per-notification cap — extra prompts ignored\",\n );\n }\n\n const generatedVars: Record<string, string> = {};\n for (const [key, prompt] of promptEntries.slice(0, maxPrompts)) {\n generatedVars[key] = await this.generateAiContent(prompt, pending.templateVariables);\n }\n\n // Merge generated vars with original template vars\n const finalVars = { ...pending.templateVariables, ...generatedVars };\n\n const dbTemplate = pending.templateId\n ? await this.templateCache.getCachedTemplate(pending.projectId, pending.templateId)\n : null;\n\n const rendered = renderWithTemplate(dbTemplate, finalVars);\n\n const taskId = randomUUID();\n const destination =\n pending.channel === \"email\"\n ? pending.recipient.email\n : pending.channel === \"sms\"\n ? pending.recipient.phone\n : pending.channel === \"webhook\"\n ? pending.recipient.webhook\n : pending.channel === \"push\"\n ? (pending.recipient.pushTokens?.[0] ?? pending.recipient.pushToken)\n : undefined;\n const resolvedDestination =\n destination ?? (pending.channel === \"push\" ? undefined : pending.recipientId);\n\n const taskPayload: NotificationDispatchedPayload = {\n projectId: pending.projectId,\n taskId,\n enrichedEventId: pending.enrichedEventId,\n recipientId: pending.recipientId,\n channel: pending.channel,\n priority: pending.priority,\n templateId: pending.templateId,\n templateVariables: pending.templateVariables,\n aiPrompts: pending.aiPrompts,\n recipient: pending.recipient,\n renderedContent: rendered,\n destination: resolvedDestination,\n deliveryOptions: {\n maxAttempts: 3,\n timeoutMs: 10_000,\n },\n fallbackChain: pending.fallbackChain,\n };\n\n const envelope = buildStreamEvent(\n \"notification.dispatched\",\n taskPayload as Record<string, unknown>,\n \"ai-worker\",\n event.metadata.traceId,\n );\n\n // Route by scheduledAt\n const now = Date.now();\n const scheduledAt = pending.scheduledAt ? new Date(pending.scheduledAt).getTime() : now;\n\n if (scheduledAt > now) {\n await this.db.insert(scheduledPayloads).values({\n taskId,\n payload: taskPayload,\n });\n\n const scheduledEnvelope = buildStreamEvent(\n \"notification.scheduled\",\n {\n projectId: pending.projectId,\n enrichedEventId: pending.enrichedEventId,\n taskId,\n scheduledAt: pending.scheduledAt!,\n },\n \"ai-worker\",\n event.metadata.traceId,\n );\n\n await this.scheduledProducer.publish(scheduledEnvelope);\n this.logger.info(\n {\n messageId: message.id,\n taskId,\n scheduledAt: pending.scheduledAt,\n traceId: event.metadata.traceId,\n },\n \"task scheduled and payload cached after AI generation\",\n );\n } else {\n const p = getPriorityBucket(pending.priority);\n const outboundProducer = this.outboundProducers[p] ?? this.outboundProducers[\"normal\"]!;\n\n await outboundProducer.publish(envelope);\n this.logger.info(\n {\n messageId: message.id,\n taskId,\n recipientId: pending.recipientId,\n traceId: event.metadata.traceId,\n },\n \"task dispatched after AI generation\",\n );\n }\n } catch (err) {\n await this.idempotency.unmark(idempotencyKey);\n if (err instanceof PermanentAiError || (err as Error)?.name === \"PermanentAiError\") {\n // Retrying re-bills the same failing prompt. Fail the notification once.\n this.logger.error(\n { err, messageId: message.id, recipientId: pending.recipientId },\n \"AI generation failed permanently — dropping notification without retry\",\n );\n globalEmitter.emit(\n \"notification:failed\",\n pending.enrichedEventId,\n (err as Error).message,\n pending.channel,\n );\n return;\n }\n throw err;\n }\n }\n}\n\nexport async function startAiWorker() {\n logger = createLogger({ name: \"ai\", level: config.LOG_LEVEL });\n redis = new RedisClient({ url: config.REDIS_URL, name: \"ai\", logger });\n const dbData = createDatabase({ url: config.DATABASE_URL, applicationName: \"ai\", logger });\n sql = dbData.sql;\n db = dbData.db;\n templateRepo = new TemplateRepository(db);\n templateCache = new TemplateCache(templateRepo);\n consumer = new StreamConsumer({\n redis: redis.native,\n stream: STREAMS.AI_PENDING as StreamName,\n group: CONSUMER_GROUPS.AI,\n consumer: `ai-${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.AI_PENDING as StreamName,\n group: CONSUMER_GROUPS.AI,\n consumer: `ai-${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 const scheduledProducer = new StreamProducer({\n redis: redis.native,\n stream: STREAMS.SCHEDULED,\n logger,\n });\n\n const idempotency = new IdempotencyGuard({\n redis: redis.native,\n keyPrefix: \"notif:processed:ai\",\n ttlSeconds: 86_400,\n });\n\n // AI generation\n async function generateAiContent(prompt: string, vars: Record<string, unknown>): Promise<string> {\n const aiConfig = getAiConfig();\n const interpolated = prompt.replace(/\\{\\{(\\w+)\\}\\}/g, (_, k: string) => String(vars[k] ?? \"\"));\n\n if (!aiConfig || !aiConfig.aiModel) {\n logger.warn(\"AI worker called but no AI model was provided to NotifkitServer\");\n return `[AI Disabled] ${interpolated}`;\n }\n\n const maxOutputTokens = aiConfig.maxOutputTokens ?? AI_DEFAULTS.maxOutputTokens;\n const timeoutMs = aiConfig.timeoutMs ?? AI_DEFAULTS.timeoutMs;\n\n try {\n const { text } = await generateText({\n model: aiConfig.aiModel,\n prompt: interpolated,\n maxOutputTokens,\n abortSignal: AbortSignal.timeout(timeoutMs),\n });\n\n return text;\n } catch (err) {\n // BaseWorker retries a throw up to maxRetriesBeforeDlq, and every retry is\n // another billed call. Only re-throw for failures a retry could actually\n // fix; a malformed prompt or a rejected request must not be re-billed.\n if (isRetryableAiError(err)) {\n logger.error({ err }, \"AI generation failed (transient) — will retry\");\n throw err;\n }\n throw new PermanentAiError(err instanceof Error ? err.message : String(err), { cause: err });\n }\n }\n\n worker = new AiWorker({\n consumer,\n pendingScanner,\n logger,\n concurrency: config.WORKER_CONCURRENCY,\n registry,\n idempotency,\n redis: redis.native,\n generateAiContent,\n templateCache,\n scheduledProducer,\n outboundProducers,\n db,\n });\n\n subscriber = redis.native.duplicate();\n await subscriber.subscribe(\"template.invalidated\");\n subscriber.on(\"message\", (channel: string, message: string) => {\n if (channel === \"template.invalidated\") {\n templateCache.invalidateKey(message);\n logger.info({ cacheKey: message }, \"invalidated template cache\");\n }\n });\n\n healthInterval = startHealthReporter(\"ai\", worker, redis, logger);\n\n logger.info({ env: config.NODE_ENV, redis: config.REDIS_URL }, \"ai starting\");\n await worker.start();\n}\n\nexport async function stopAiWorker(): Promise<void> {\n logger?.info(\"shutdown initiated\");\n if (healthInterval) {\n clearInterval(healthInterval);\n healthInterval = null;\n }\n if (subscriber) {\n subscriber.disconnect();\n subscriber = null;\n }\n if (worker) await worker.stop();\n if (sql) await sql.end();\n if (redis) await redis.disconnect();\n logger?.info(\"ai stopped\");\n}\n"],"mappings":"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"}