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,501 @@
1
+ import type { Redis } from "@/index.js";
2
+ import type { Logger } from "@/index.js";
3
+ import {
4
+ StreamEventSchema,
5
+ type StreamEvent,
6
+ type StreamName,
7
+ type ConsumerGroup,
8
+ readBaseConfig,
9
+ STREAMS,
10
+ } from "@/index.js";
11
+ import { metrics } from "@/metrics/index.js";
12
+
13
+ // ─── Types ─────────────────────────────────────────────────────────────────
14
+
15
+ export interface StreamMessage {
16
+ id: string;
17
+ event: StreamEvent;
18
+ /** Stream this message was read from. Required to ack/claim against the right one. */
19
+ stream?: string;
20
+ }
21
+
22
+ export interface PendingEntry {
23
+ id: string;
24
+ consumer: string;
25
+ idleMs: number;
26
+ deliveryCount: number;
27
+ /** Stream this entry is pending on. Stream ids are not unique across streams. */
28
+ stream: StreamName;
29
+ }
30
+
31
+ // ─── Internal helpers ──────────────────────────────────────────────────────
32
+
33
+ function parseMessage(id: string, fields: string[] | null, logger?: Logger): StreamMessage | null {
34
+ if (!fields) return null;
35
+
36
+ const dataIndex = fields.indexOf("data");
37
+ if (dataIndex === -1) return null;
38
+
39
+ const raw = fields[dataIndex + 1];
40
+ if (!raw) return null;
41
+
42
+ let decoded: unknown;
43
+ try {
44
+ decoded = JSON.parse(raw);
45
+ } catch (err) {
46
+ logger?.warn({ id, err }, "failed to parse stream event JSON");
47
+ return null;
48
+ }
49
+
50
+ const parsed = StreamEventSchema.safeParse(decoded);
51
+ if (!parsed.success) {
52
+ logger?.warn({ id, error: parsed.error.issues }, "failed to parse stream event");
53
+ return null;
54
+ }
55
+
56
+ return { id, event: parsed.data };
57
+ }
58
+
59
+ // ─── StreamProducer ────────────────────────────────────────────────────────
60
+
61
+ export interface StreamProducerOptions {
62
+ redis: Redis;
63
+ stream: StreamName;
64
+ logger?: Logger;
65
+ maxLen?: number;
66
+ }
67
+
68
+ export class StreamProducer {
69
+ private readonly redis: Redis;
70
+ private readonly stream: StreamName;
71
+ private readonly logger?: Logger;
72
+ private readonly maxLen: number;
73
+
74
+ constructor({ redis, stream, logger, maxLen }: StreamProducerOptions) {
75
+ this.redis = redis;
76
+ this.stream = stream;
77
+ this.logger = logger;
78
+ this.maxLen = maxLen ?? readBaseConfig().QUEUE_MAX_LEN;
79
+ }
80
+
81
+ async publish(partial: Omit<StreamEvent, "id" | "timestamp">): Promise<string> {
82
+ const event: StreamEvent = {
83
+ ...partial,
84
+ id: crypto.randomUUID(),
85
+ timestamp: new Date().toISOString(),
86
+ };
87
+
88
+ const messageId = await this.redis.xadd(
89
+ this.stream,
90
+ "MAXLEN",
91
+ "~",
92
+ String(this.maxLen),
93
+ "*",
94
+ "data",
95
+ JSON.stringify(event),
96
+ );
97
+
98
+ if (!messageId) throw new Error(`XADD to ${this.stream} returned null`);
99
+
100
+ this.logger?.debug(
101
+ { stream: this.stream, messageId, eventType: event.type, eventId: event.id },
102
+ "event published",
103
+ );
104
+
105
+ return messageId;
106
+ }
107
+
108
+ private async monitorMaxLen(stream: string) {
109
+ if (Math.random() < 0.05) {
110
+ // Check ~5% of the time to avoid overhead
111
+ try {
112
+ const len = await this.redis.xlen(stream);
113
+ metrics.queueSize.set({ stream }, len);
114
+
115
+ if (len > this.maxLen * 0.8) {
116
+ this.logger?.warn(
117
+ { stream, len, maxLen: this.maxLen },
118
+ "stream is nearing MAXLEN limit (80%+)",
119
+ );
120
+ }
121
+
122
+ const dlqLen = await this.redis.xlen(STREAMS.DEAD_LETTER);
123
+ metrics.queueSize.set({ stream: STREAMS.DEAD_LETTER }, dlqLen);
124
+ } catch (err) {
125
+ this.logger?.debug({ err }, "failed to monitor stream length");
126
+ }
127
+ }
128
+ }
129
+
130
+ async publishBatch(
131
+ partials: Omit<StreamEvent, "id" | "timestamp">[],
132
+ ): Promise<{ messageIds: string[]; eventIds: string[] }> {
133
+ if (partials.length === 0) return { messageIds: [], eventIds: [] };
134
+
135
+ const pipeline = this.redis.pipeline();
136
+ const timestamp = new Date().toISOString();
137
+
138
+ const eventIds: string[] = [];
139
+
140
+ for (const partial of partials) {
141
+ const id = crypto.randomUUID();
142
+ eventIds.push(id);
143
+ const event: StreamEvent = {
144
+ ...partial,
145
+ id,
146
+ timestamp,
147
+ };
148
+
149
+ pipeline.xadd(
150
+ this.stream,
151
+ "MAXLEN",
152
+ "~",
153
+ String(this.maxLen),
154
+ "*",
155
+ "data",
156
+ JSON.stringify(event),
157
+ );
158
+ }
159
+
160
+ const results = await pipeline.exec();
161
+ if (!results) throw new Error(`Pipeline execution failed for ${this.stream}`);
162
+
163
+ const messageIds: string[] = [];
164
+ for (let i = 0; i < results.length; i++) {
165
+ const result = results[i];
166
+ if (!result) throw new Error("Pipeline result is undefined");
167
+ const [err, msgId] = result;
168
+ if (err) throw err;
169
+ messageIds.push(msgId as string);
170
+ }
171
+
172
+ this.logger?.debug({ stream: this.stream, count: partials.length }, "batch events published");
173
+
174
+ this.monitorMaxLen(this.stream).catch(() => {});
175
+
176
+ return { messageIds, eventIds };
177
+ }
178
+ }
179
+
180
+ // ─── StreamConsumer ────────────────────────────────────────────────────────
181
+
182
+ export interface StreamConsumerOptions {
183
+ redis: Redis;
184
+ stream: StreamName | StreamName[];
185
+ group: ConsumerGroup;
186
+ consumer: string;
187
+ dlqStream?: StreamName;
188
+ logger?: Logger;
189
+ batchSize?: number;
190
+ blockMs?: number;
191
+ }
192
+
193
+ type XReadGroupResult = Array<[string, Array<[string, string[] | null]>]> | null;
194
+
195
+ export class StreamConsumer {
196
+ readonly redis: Redis;
197
+ private readonly blockingRedis: Redis;
198
+ private readonly streams: StreamName[];
199
+ private readonly group: ConsumerGroup;
200
+ private readonly consumer: string;
201
+ private readonly dlqStream?: StreamName;
202
+ private readonly logger?: Logger;
203
+ private readonly batchSize: number;
204
+ private readonly blockMs: number;
205
+ private running = false;
206
+
207
+ constructor({
208
+ redis,
209
+ stream,
210
+ group,
211
+ consumer,
212
+ dlqStream,
213
+ logger,
214
+ batchSize = 10,
215
+ blockMs = 5_000,
216
+ }: StreamConsumerOptions) {
217
+ this.redis = redis;
218
+ this.blockingRedis = redis.duplicate();
219
+ this.streams = Array.isArray(stream) ? stream : [stream];
220
+ this.group = group;
221
+ this.consumer = consumer;
222
+ this.dlqStream = dlqStream;
223
+ this.logger = logger;
224
+ this.batchSize = batchSize;
225
+ this.blockMs = blockMs;
226
+ }
227
+
228
+ async ensureGroup(): Promise<void> {
229
+ for (const s of this.streams) {
230
+ try {
231
+ // Start from the beginning so events published before the first worker
232
+ // comes online are not silently skipped. Retention is controlled by the
233
+ // producer's MAXLEN policy rather than consumer-group creation time.
234
+ await this.redis.xgroup("CREATE", s, this.group, "0", "MKSTREAM");
235
+ this.logger?.info({ stream: s, group: this.group }, "consumer group created");
236
+ } catch (err) {
237
+ if (err instanceof Error && err.message.includes("BUSYGROUP")) {
238
+ this.logger?.debug({ stream: s, group: this.group }, "consumer group already exists");
239
+ continue;
240
+ }
241
+ throw err;
242
+ }
243
+ }
244
+ }
245
+
246
+ async *readBatch(): AsyncGenerator<StreamMessage[], void, unknown> {
247
+ this.running = true;
248
+ let retryDelay = 1000;
249
+
250
+ while (this.running) {
251
+ try {
252
+ let currentStreams = [...this.streams];
253
+ // Weighted fair queuing: 10% of the time, rotate the priority order
254
+ // to prevent starvation of low priority queues.
255
+ if (currentStreams.length > 1 && Math.random() < 0.1) {
256
+ const offset = Math.floor(Math.random() * (currentStreams.length - 1)) + 1;
257
+ for (let i = 0; i < offset; i++) {
258
+ currentStreams.push(currentStreams.shift()!);
259
+ }
260
+ }
261
+
262
+ let results;
263
+ let deadConnectionTimer: ReturnType<typeof setTimeout> | undefined;
264
+ try {
265
+ results = (await Promise.race([
266
+ this.blockingRedis.xreadgroup(
267
+ "GROUP",
268
+ this.group,
269
+ this.consumer,
270
+ "COUNT",
271
+ String(this.batchSize),
272
+ "BLOCK",
273
+ String(this.blockMs),
274
+ "STREAMS",
275
+ ...currentStreams,
276
+ ...currentStreams.map(() => ">"),
277
+ ),
278
+ new Promise((_, reject) => {
279
+ deadConnectionTimer = setTimeout(
280
+ () => reject(new Error("XREADGROUP_TIMEOUT_DEAD_CONNECTION")),
281
+ this.blockMs + 5000,
282
+ );
283
+ }),
284
+ ])) as XReadGroupResult;
285
+ } catch (err: any) {
286
+ if (err.message === "XREADGROUP_TIMEOUT_DEAD_CONNECTION") {
287
+ this.logger?.warn(
288
+ "XREADGROUP took too long, assuming dead connection. Disconnecting...",
289
+ );
290
+ this.blockingRedis.disconnect();
291
+ throw err;
292
+ }
293
+ throw err;
294
+ } finally {
295
+ // Whichever side of the race loses stays pending, so the guard timer
296
+ // outlives the read it was guarding. Normally the loop turns over once
297
+ // per `blockMs` and only a couple accumulate — but whenever the read
298
+ // returns straight away, one timer per iteration piles up unbounded.
299
+ clearTimeout(deadConnectionTimer);
300
+ }
301
+
302
+ retryDelay = 1000; // reset on success
303
+
304
+ if (!results) continue;
305
+
306
+ const batch: StreamMessage[] = [];
307
+ for (const [streamName, messages] of results) {
308
+ for (const [id, fields] of messages) {
309
+ const msg = parseMessage(id, fields, this.logger);
310
+ if (!msg) {
311
+ await this.redis.xack(streamName, this.group, id);
312
+ continue;
313
+ }
314
+ // Attach original stream name for dynamic acking
315
+ msg.stream = streamName as StreamName;
316
+ batch.push(msg);
317
+ }
318
+ }
319
+ if (batch.length > 0) yield batch;
320
+ } catch (err) {
321
+ if (
322
+ !this.running &&
323
+ err instanceof Error &&
324
+ err?.message?.toLowerCase?.().includes("connection is closed")
325
+ ) {
326
+ break; // Expected during graceful shutdown
327
+ }
328
+ this.logger?.error({ err }, "error reading from stream");
329
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
330
+ retryDelay = Math.min(retryDelay * 2, 30_000); // Exponential backoff up to 30s
331
+ }
332
+ }
333
+ }
334
+
335
+ async ack(messageId: string | string[], stream?: string): Promise<void> {
336
+ const s = stream ?? this.streams[0]!;
337
+ const ids = Array.isArray(messageId) ? messageId : [messageId];
338
+ if (ids.length === 0) return;
339
+ await this.redis.xack(s, this.group, ...ids);
340
+ this.logger?.debug({ stream: s, count: ids.length }, "messages acknowledged");
341
+ }
342
+
343
+ async nack(messageId: string, event: StreamEvent, stream?: string): Promise<void> {
344
+ const s = stream ?? this.streams[0]!;
345
+ if (this.dlqStream) {
346
+ // Sequential rather than pipelined, and in this order: the ack is what
347
+ // makes the drop final, so it must never run against a DLQ write that
348
+ // did not land. A pipeline is not a transaction — both commands execute
349
+ // regardless — so inspecting its results afterwards would be too late.
350
+ // Throwing here leaves the message pending for the recovery loop, which
351
+ // is the recoverable end of the trade.
352
+ const dlqId = await this.redis.xadd(
353
+ this.dlqStream,
354
+ "*",
355
+ "data",
356
+ JSON.stringify({
357
+ ...event,
358
+ dlq: { originalStream: s, ackedAt: new Date().toISOString() },
359
+ }),
360
+ );
361
+
362
+ if (!dlqId) {
363
+ throw new Error(`XADD to dead-letter stream ${this.dlqStream} returned null`);
364
+ }
365
+
366
+ await this.redis.xack(s, this.group, messageId);
367
+ this.logger?.warn(
368
+ { stream: s, dlqStream: this.dlqStream, messageId, eventId: event.id, dlqId },
369
+ "message moved to dead-letter queue and acked",
370
+ );
371
+ } else {
372
+ await this.ack(messageId, s);
373
+ }
374
+ }
375
+
376
+ async stop(): Promise<void> {
377
+ this.running = false;
378
+ try {
379
+ await this.blockingRedis.quit();
380
+ } catch (err: any) {
381
+ if (!err?.message?.toLowerCase?.().includes("connection is closed")) {
382
+ throw err;
383
+ }
384
+ }
385
+ }
386
+ }
387
+
388
+ // ─── PendingMessageScanner ─────────────────────────────────────────────────
389
+
390
+ export interface PendingMessageScannerOptions {
391
+ redis: Redis;
392
+ stream: StreamName | StreamName[];
393
+ group: ConsumerGroup;
394
+ consumer: string;
395
+ logger?: Logger;
396
+ }
397
+
398
+ export class PendingMessageScanner {
399
+ private readonly redis: Redis;
400
+ private readonly streams: StreamName[];
401
+ private readonly group: ConsumerGroup;
402
+ private readonly consumer: string;
403
+ private readonly logger?: Logger;
404
+
405
+ constructor({ redis, stream, group, consumer, logger }: PendingMessageScannerOptions) {
406
+ this.redis = redis;
407
+ this.streams = Array.isArray(stream) ? stream : [stream];
408
+ this.group = group;
409
+ this.consumer = consumer;
410
+ this.logger = logger;
411
+ }
412
+
413
+ async getPendingCount(): Promise<number> {
414
+ let total = 0;
415
+ for (const s of this.streams) {
416
+ const summary = await this.redis.xpending(s, this.group);
417
+ if (Array.isArray(summary) && summary.length > 0) {
418
+ const count = summary[0];
419
+ if (typeof count === "number") total += count;
420
+ }
421
+ }
422
+ return total;
423
+ }
424
+
425
+ /** Pending entries for one stream, or across all of them when `stream` is omitted. */
426
+ async getPendingEntries(limit = 100, stream?: StreamName): Promise<PendingEntry[]> {
427
+ const streams = stream ? [stream] : this.streams;
428
+ const allEntries: PendingEntry[] = [];
429
+
430
+ for (const s of streams) {
431
+ const result = await this.redis.xpending(s, this.group, "-", "+", limit);
432
+ if (Array.isArray(result)) {
433
+ for (const item of result) {
434
+ if (Array.isArray(item)) {
435
+ allEntries.push({
436
+ id: item[0],
437
+ consumer: item[1],
438
+ idleMs: item[2],
439
+ deliveryCount: item[3],
440
+ stream: s,
441
+ });
442
+ }
443
+ }
444
+ }
445
+ if (allEntries.length >= limit) break;
446
+ }
447
+ return allEntries.slice(0, limit);
448
+ }
449
+
450
+ async autoclaim(minIdleMs: number, limit = 10): Promise<StreamMessage[]> {
451
+ const recovered: StreamMessage[] = [];
452
+
453
+ for (const s of this.streams) {
454
+ if (recovered.length >= limit) break;
455
+
456
+ // Scope the scan to THIS stream. Message ids are `<ms>-<seq>` and are not
457
+ // unique across streams, so claiming an id gathered from another stream
458
+ // can silently claim an unrelated message.
459
+ const pending = await this.getPendingEntries(limit * 2, s);
460
+ const toClaim = pending
461
+ .filter((p) => p.idleMs > minIdleMs * Math.pow(2, p.deliveryCount - 1))
462
+ .slice(0, limit - recovered.length);
463
+
464
+ if (toClaim.length === 0) continue;
465
+
466
+ const ids = toClaim.map((p) => p.id);
467
+ // Let Redis arbitrate rather than claiming unconditionally. Two scanners
468
+ // routinely list the same entry, and with a min-idle of 0 both claims
469
+ // succeed and the message is processed twice; with the threshold applied
470
+ // server-side the loser sees an entry whose idle time the winner has just
471
+ // reset, and gets nothing back. The filter above is stricter than this,
472
+ // so nothing it selected is excluded here for being too fresh.
473
+ const result = (await this.redis.xclaim(
474
+ s,
475
+ this.group,
476
+ this.consumer,
477
+ minIdleMs,
478
+ ...ids,
479
+ )) as Array<[string, string[] | null]>;
480
+
481
+ for (const raw of result) {
482
+ if (!raw) continue;
483
+ const [id, fields] = raw;
484
+ const msg = parseMessage(id, fields, this.logger);
485
+ if (msg) {
486
+ msg.stream = s;
487
+ recovered.push(msg);
488
+ }
489
+ }
490
+ }
491
+
492
+ if (recovered.length > 0) {
493
+ this.logger?.info(
494
+ { group: this.group, count: recovered.length },
495
+ "autoclaimed pending messages",
496
+ );
497
+ }
498
+
499
+ return recovered;
500
+ }
501
+ }
@@ -0,0 +1,210 @@
1
+ import type { Redis } from "@/index.js";
2
+ import { LRUCache } from "@/shared/index.js";
3
+
4
+ import { randomUUID } from "crypto";
5
+
6
+ export interface ThrottleResult {
7
+ allowed: boolean;
8
+ count: number;
9
+ limit: number;
10
+ }
11
+
12
+ // ─── Per-project overrides ──────────────────────────────────────────────────
13
+
14
+ /**
15
+ * Throttle overrides stored on the project row. `null` on either field means
16
+ * "no override" — fall back to the process-wide default.
17
+ */
18
+ export interface ProjectThrottleSettings {
19
+ throttleLimit: number | null;
20
+ throttleWindowHours: number | null;
21
+ }
22
+
23
+ const NO_OVERRIDES: ProjectThrottleSettings = {
24
+ throttleLimit: null,
25
+ throttleWindowHours: null,
26
+ };
27
+
28
+ export interface ProjectSettingsCacheOptions {
29
+ maxSize?: number;
30
+ ttlMs?: number;
31
+ }
32
+
33
+ /**
34
+ * Caches per-project throttle overrides for the engine.
35
+ *
36
+ * The throttle check runs once per notification, so an uncached lookup here
37
+ * would put a Postgres round trip on the hot path. Projects with no overrides
38
+ * are cached as well — the common case must not cost a query per message.
39
+ *
40
+ * The TTL bounds staleness on its own; `invalidate()` exists so a settings
41
+ * change published over pub/sub applies immediately rather than at expiry.
42
+ */
43
+ export class ProjectSettingsCache {
44
+ private readonly cache: LRUCache<string, ProjectThrottleSettings>;
45
+ /**
46
+ * Lookups already on the wire, keyed by project.
47
+ *
48
+ * The cache only fills once a query has come back, so without this a cold
49
+ * project at the start of a campaign puts one query per in-flight message on
50
+ * Postgres before the first answer lands — exactly when the database is
51
+ * busiest. Followers wait on the leader's promise instead.
52
+ */
53
+ private readonly inFlight = new Map<string, Promise<ProjectThrottleSettings>>();
54
+
55
+ constructor(
56
+ private readonly load: (projectId: string) => Promise<ProjectThrottleSettings | null>,
57
+ { maxSize = 1000, ttlMs = 60_000 }: ProjectSettingsCacheOptions = {},
58
+ ) {
59
+ this.cache = new LRUCache<string, ProjectThrottleSettings>(maxSize, ttlMs);
60
+ }
61
+
62
+ /**
63
+ * Throws whatever the loader throws. The caller decides whether a settings
64
+ * lookup failure should drop the message or fall back to defaults.
65
+ */
66
+ async get(projectId: string): Promise<ProjectThrottleSettings> {
67
+ const cached = this.cache.get(projectId);
68
+ if (cached) return cached;
69
+
70
+ const existing = this.inFlight.get(projectId);
71
+ if (existing) return existing;
72
+
73
+ // Assigned before any callback below can run, since `load` cannot settle
74
+ // within this synchronous block.
75
+ let pending!: Promise<ProjectThrottleSettings>;
76
+ pending = this.load(projectId)
77
+ .then((settings) => {
78
+ const resolved = settings ?? NO_OVERRIDES;
79
+ // Only cache while this is still the current lookup: an invalidate()
80
+ // that landed while the query was on the wire means the answer in hand
81
+ // already describes the old settings.
82
+ if (this.inFlight.get(projectId) === pending) {
83
+ this.cache.set(projectId, resolved);
84
+ }
85
+ return resolved;
86
+ })
87
+ .finally(() => {
88
+ // Cleared on failure too, so one bad lookup does not pin every later
89
+ // caller to the same rejection.
90
+ if (this.inFlight.get(projectId) === pending) {
91
+ this.inFlight.delete(projectId);
92
+ }
93
+ });
94
+
95
+ this.inFlight.set(projectId, pending);
96
+ return pending;
97
+ }
98
+
99
+ invalidate(projectId: string): void {
100
+ this.cache.delete(projectId);
101
+ // A lookup that started before the change would write a stale value on
102
+ // arrival; dropping it here sends the next caller back to the database.
103
+ this.inFlight.delete(projectId);
104
+ }
105
+
106
+ clear(): void {
107
+ this.cache.clear();
108
+ this.inFlight.clear();
109
+ }
110
+ }
111
+
112
+ // ─── UserThrottle ──────────────────────────────────────────────────────────
113
+ // True sliding window counter using Redis ZSET: max N sends per window per user.
114
+
115
+ export interface UserThrottleOptions {
116
+ redis: Redis;
117
+ maxPerHour?: number;
118
+ windowHours?: number;
119
+ }
120
+
121
+ export interface ThrottleCheckOptions {
122
+ /** Per-project cap for this window. `0` blocks every non-critical send. */
123
+ limit?: number | null;
124
+ /** Per-project window length in hours. */
125
+ windowHours?: number | null;
126
+ /** Future send time. The window is evaluated at that instant, not at now. */
127
+ scheduledAt?: string;
128
+ }
129
+
130
+ /** Reject stored values that would make the window meaningless. */
131
+ function positiveOrNull(value: number | null | undefined): number | null {
132
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
133
+ }
134
+
135
+ /** A limit of 0 is a legitimate kill switch, so zero is allowed here. */
136
+ function nonNegativeOrNull(value: number | null | undefined): number | null {
137
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
138
+ }
139
+
140
+ export class UserThrottle {
141
+ private readonly redis: Redis;
142
+ private readonly maxPerHour: number;
143
+ private readonly windowHours: number;
144
+
145
+ constructor({ redis, maxPerHour = 3, windowHours = 1 }: UserThrottleOptions) {
146
+ this.redis = redis;
147
+ this.maxPerHour = maxPerHour;
148
+ this.windowHours = windowHours;
149
+ }
150
+
151
+ /**
152
+ * @param projectId Tenant that owns `userId`. User ids are caller-supplied
153
+ * external ids, so they collide across tenants and MUST be namespaced —
154
+ * otherwise one tenant's traffic throttles another's.
155
+ * @param options Per-project overrides. Values that are absent, null, or
156
+ * nonsensical fall back to this instance's defaults.
157
+ */
158
+ async check(
159
+ projectId: string,
160
+ userId: string,
161
+ priority?: string,
162
+ options: ThrottleCheckOptions = {},
163
+ ): Promise<ThrottleResult> {
164
+ const limit = nonNegativeOrNull(options.limit) ?? this.maxPerHour;
165
+ const windowHours = positiveOrNull(options.windowHours) ?? this.windowHours;
166
+
167
+ if (priority === "critical") {
168
+ return { allowed: true, count: 0, limit };
169
+ }
170
+
171
+ const windowMs = windowHours * 3600_000;
172
+ const key = `throttle:${projectId}:user:${userId}`;
173
+ const targetTime = options.scheduledAt ? new Date(options.scheduledAt).getTime() : Date.now();
174
+ const windowStart = targetTime - windowMs;
175
+ const memberId = randomUUID();
176
+
177
+ const LUA_THROTTLE = `
178
+ redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", ARGV[1])
179
+ local count = redis.call("ZCARD", KEYS[1])
180
+ if tonumber(count) < tonumber(ARGV[2]) then
181
+ redis.call("ZADD", KEYS[1], tonumber(ARGV[3]), ARGV[4])
182
+ redis.call("EXPIRE", KEYS[1], tonumber(ARGV[5]))
183
+ return tonumber(count) + 1
184
+ end
185
+ return tonumber(count) + 1
186
+ `;
187
+
188
+ // The key must outlive the window it is counting. For a future-dated send
189
+ // that means surviving until targetTime plus one more window, so a task
190
+ // scheduled for next week still counts against the right bucket.
191
+ const windowSeconds = Math.ceil(windowMs / 1000);
192
+ const ttlSeconds = Math.max(
193
+ windowSeconds,
194
+ Math.ceil((targetTime - Date.now()) / 1000) + windowSeconds,
195
+ );
196
+
197
+ const count = (await this.redis.eval(
198
+ LUA_THROTTLE,
199
+ 1,
200
+ key,
201
+ windowStart,
202
+ limit,
203
+ targetTime,
204
+ memberId,
205
+ ttlSeconds,
206
+ )) as number;
207
+
208
+ return { allowed: count <= limit, count, limit };
209
+ }
210
+ }