@niama/loops 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +506 -0
  2. package/dist/client/index.d.ts +510 -0
  3. package/dist/client/index.d.ts.map +1 -0
  4. package/dist/client/index.js +464 -0
  5. package/dist/component/_generated/api.d.ts +232 -0
  6. package/dist/component/_generated/api.d.ts.map +1 -0
  7. package/dist/component/_generated/api.js +30 -0
  8. package/dist/component/_generated/component.d.ts +245 -0
  9. package/dist/component/_generated/component.d.ts.map +1 -0
  10. package/dist/component/_generated/component.js +9 -0
  11. package/dist/component/_generated/dataModel.d.ts +46 -0
  12. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  13. package/dist/component/_generated/dataModel.js +10 -0
  14. package/dist/component/_generated/server.d.ts +121 -0
  15. package/dist/component/_generated/server.d.ts.map +1 -0
  16. package/dist/component/_generated/server.js +77 -0
  17. package/dist/component/actions.d.ts +159 -0
  18. package/dist/component/actions.d.ts.map +1 -0
  19. package/dist/component/actions.js +468 -0
  20. package/dist/component/aggregates.d.ts +42 -0
  21. package/dist/component/aggregates.d.ts.map +1 -0
  22. package/dist/component/aggregates.js +54 -0
  23. package/dist/component/convex.config.d.ts +3 -0
  24. package/dist/component/convex.config.d.ts.map +1 -0
  25. package/dist/component/convex.config.js +5 -0
  26. package/dist/component/helpers.d.ts +16 -0
  27. package/dist/component/helpers.d.ts.map +1 -0
  28. package/dist/component/helpers.js +98 -0
  29. package/dist/component/http.d.ts +3 -0
  30. package/dist/component/http.d.ts.map +1 -0
  31. package/dist/component/http.js +208 -0
  32. package/dist/component/mutations.d.ts +55 -0
  33. package/dist/component/mutations.d.ts.map +1 -0
  34. package/dist/component/mutations.js +167 -0
  35. package/dist/component/queries.d.ts +171 -0
  36. package/dist/component/queries.d.ts.map +1 -0
  37. package/dist/component/queries.js +516 -0
  38. package/dist/component/schema.d.ts +63 -0
  39. package/dist/component/schema.d.ts.map +1 -0
  40. package/dist/component/schema.js +16 -0
  41. package/dist/component/tables/contacts.d.ts +16 -0
  42. package/dist/component/tables/contacts.d.ts.map +1 -0
  43. package/dist/component/tables/contacts.js +16 -0
  44. package/dist/component/tables/emailOperations.d.ts +17 -0
  45. package/dist/component/tables/emailOperations.d.ts.map +1 -0
  46. package/dist/component/tables/emailOperations.js +17 -0
  47. package/dist/component/validators.d.ts +338 -0
  48. package/dist/component/validators.d.ts.map +1 -0
  49. package/dist/component/validators.js +167 -0
  50. package/dist/test.d.ts +78 -0
  51. package/dist/test.d.ts.map +1 -0
  52. package/dist/test.js +16 -0
  53. package/dist/types.d.ts +39 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +0 -0
  56. package/package.json +112 -0
  57. package/src/client/index.ts +618 -0
  58. package/src/component/_generated/api.ts +253 -0
  59. package/src/component/_generated/component.ts +291 -0
  60. package/src/component/_generated/dataModel.ts +60 -0
  61. package/src/component/_generated/server.ts +161 -0
  62. package/src/component/actions.ts +556 -0
  63. package/src/component/aggregates.ts +89 -0
  64. package/src/component/convex.config.ts +8 -0
  65. package/src/component/helpers.ts +130 -0
  66. package/src/component/http.ts +236 -0
  67. package/src/component/mutations.ts +192 -0
  68. package/src/component/queries.ts +604 -0
  69. package/src/component/schema.ts +17 -0
  70. package/src/component/tables/contacts.ts +17 -0
  71. package/src/component/tables/emailOperations.ts +23 -0
  72. package/src/component/validators.ts +197 -0
  73. package/src/test.ts +27 -0
  74. package/src/types.ts +62 -0
@@ -0,0 +1,604 @@
1
+ import type { PaginationResult } from "convex/server";
2
+ import { v } from "convex/values";
3
+ import { paginator } from "convex-helpers/server/pagination";
4
+ import type { Doc } from "./_generated/dataModel";
5
+ import { query } from "./_generated/server";
6
+ import { aggregateCountByUserGroup, aggregateCountTotal } from "./aggregates";
7
+ import schema from "./schema";
8
+ import {
9
+ actorSpamValidator,
10
+ emailStatsResponseValidator,
11
+ paginatedContactsResponseValidator,
12
+ rapidFirePatternValidator,
13
+ rateLimitResponseValidator,
14
+ recipientSpamValidator,
15
+ } from "./validators";
16
+
17
+ /**
18
+ * Maximum number of documents to read when counting with filters.
19
+ * This limit prevents query read limit errors while still providing accurate
20
+ * counts for most use cases. If you have more contacts than this, consider
21
+ * using the aggregate-based counting with userGroup only.
22
+ */
23
+ const MAX_COUNT_LIMIT = 8000;
24
+
25
+ /**
26
+ * Maximum number of email operations to read for spam detection.
27
+ * This limit prevents query read limit errors while covering most spam scenarios.
28
+ * If you need to analyze more operations, consider using scheduled jobs with pagination.
29
+ */
30
+ const MAX_SPAM_DETECTION_LIMIT = 8000;
31
+
32
+ /**
33
+ * Count contacts in the database
34
+ * Can filter by audience criteria (userGroup, source, subscribed status)
35
+ *
36
+ * For userGroup-only filtering, uses efficient O(log n) aggregate counting.
37
+ * For other filters (source, subscribed), uses indexed queries with a read limit.
38
+ *
39
+ * IMPORTANT: Before using this with existing data, run the backfillContactAggregate
40
+ * mutation to populate the aggregate with existing contacts.
41
+ *
42
+ * NOTE: When filtering by source or subscribed, counts are capped at MAX_COUNT_LIMIT
43
+ * to avoid query read limit errors. For exact counts with large datasets, use
44
+ * userGroup-only filtering which uses efficient aggregate counting.
45
+ */
46
+ export const countContacts = query({
47
+ args: {
48
+ userGroup: v.optional(v.string()),
49
+ source: v.optional(v.string()),
50
+ subscribed: v.optional(v.boolean()),
51
+ },
52
+ returns: v.number(),
53
+ handler: async (ctx, args) => {
54
+ // If only userGroup is specified (or no filters), use efficient aggregate counting
55
+ const onlyUserGroupFilter =
56
+ args.source === undefined && args.subscribed === undefined;
57
+
58
+ if (onlyUserGroupFilter) {
59
+ // Use O(log n) aggregate counting - much more efficient than .collect()
60
+ if (args.userGroup === undefined) {
61
+ // Count ALL contacts across all namespaces
62
+ return await aggregateCountTotal(ctx);
63
+ }
64
+ // Count contacts in specific userGroup namespace
65
+ return await aggregateCountByUserGroup(ctx, args.userGroup);
66
+ }
67
+
68
+ // For other filters, we need to use indexed queries with in-memory filtering
69
+ // We use .take() with a reasonable limit to avoid query read limit errors
70
+ let contacts: Doc<"contacts">[];
71
+
72
+ if (args.userGroup !== undefined) {
73
+ contacts = await ctx.db
74
+ .query("contacts")
75
+ .withIndex("userGroup", (q) => q.eq("userGroup", args.userGroup))
76
+ .take(MAX_COUNT_LIMIT);
77
+ } else if (args.source !== undefined) {
78
+ contacts = await ctx.db
79
+ .query("contacts")
80
+ .withIndex("source", (q) => q.eq("source", args.source))
81
+ .take(MAX_COUNT_LIMIT);
82
+ } else if (args.subscribed !== undefined) {
83
+ contacts = await ctx.db
84
+ .query("contacts")
85
+ .withIndex("subscribed", (q) => q.eq("subscribed", args.subscribed))
86
+ .take(MAX_COUNT_LIMIT);
87
+ } else {
88
+ // This branch shouldn't be reached due to onlyUserGroupFilter check above
89
+ contacts = await ctx.db.query("contacts").take(MAX_COUNT_LIMIT);
90
+ }
91
+
92
+ // Apply additional filters if multiple criteria were provided
93
+ const filtered = contacts.filter((c) => {
94
+ if (args.userGroup !== undefined && c.userGroup !== args.userGroup) {
95
+ return false;
96
+ }
97
+ if (args.source !== undefined && c.source !== args.source) {
98
+ return false;
99
+ }
100
+ if (args.subscribed !== undefined && c.subscribed !== args.subscribed) {
101
+ return false;
102
+ }
103
+ return true;
104
+ });
105
+
106
+ return filtered.length;
107
+ },
108
+ });
109
+
110
+ /**
111
+ * List contacts from the database with cursor-based pagination
112
+ * Can filter by audience criteria (userGroup, source, subscribed status)
113
+ * Returns actual contact data, not just a count
114
+ *
115
+ * Uses cursor-based pagination for efficient querying - only reads documents
116
+ * from the cursor position forward, not all preceding documents.
117
+ *
118
+ * Note: When multiple filters are provided, only one index can be used.
119
+ * Additional filters are applied in-memory after fetching.
120
+ */
121
+ export const listContacts = query({
122
+ args: {
123
+ userGroup: v.optional(v.string()),
124
+ source: v.optional(v.string()),
125
+ subscribed: v.optional(v.boolean()),
126
+ limit: v.optional(v.number()),
127
+ cursor: v.optional(v.union(v.string(), v.null())),
128
+ },
129
+ returns: paginatedContactsResponseValidator,
130
+ handler: async (ctx, args) => {
131
+ const limit = Math.min(Math.max(1, args.limit ?? 100), 1000);
132
+ const paginationOpts = {
133
+ cursor: args.cursor ?? null,
134
+ numItems: limit,
135
+ };
136
+
137
+ // Determine which index to use based on filters
138
+ const needsFiltering =
139
+ (args.userGroup !== undefined ? 1 : 0) +
140
+ (args.source !== undefined ? 1 : 0) +
141
+ (args.subscribed !== undefined ? 1 : 0) >
142
+ 1;
143
+
144
+ let result: PaginationResult<Doc<"contacts">>;
145
+
146
+ if (args.userGroup !== undefined) {
147
+ result = await paginator(ctx.db, schema)
148
+ .query("contacts")
149
+ .withIndex("userGroup", (q) => q.eq("userGroup", args.userGroup))
150
+ .order("desc")
151
+ .paginate(paginationOpts);
152
+ } else if (args.source !== undefined) {
153
+ result = await paginator(ctx.db, schema)
154
+ .query("contacts")
155
+ .withIndex("source", (q) => q.eq("source", args.source))
156
+ .order("desc")
157
+ .paginate(paginationOpts);
158
+ } else if (args.subscribed !== undefined) {
159
+ result = await paginator(ctx.db, schema)
160
+ .query("contacts")
161
+ .withIndex("subscribed", (q) => q.eq("subscribed", args.subscribed))
162
+ .order("desc")
163
+ .paginate(paginationOpts);
164
+ } else {
165
+ result = await paginator(ctx.db, schema)
166
+ .query("contacts")
167
+ .order("desc")
168
+ .paginate(paginationOpts);
169
+ }
170
+
171
+ let contacts = result.page;
172
+
173
+ // Apply additional filters if multiple criteria were provided
174
+ if (needsFiltering) {
175
+ contacts = contacts.filter((c) => {
176
+ if (args.userGroup !== undefined && c.userGroup !== args.userGroup) {
177
+ return false;
178
+ }
179
+ if (args.source !== undefined && c.source !== args.source) {
180
+ return false;
181
+ }
182
+ if (args.subscribed !== undefined && c.subscribed !== args.subscribed) {
183
+ return false;
184
+ }
185
+ return true;
186
+ });
187
+ }
188
+
189
+ const mappedContacts = contacts.map((contact) => ({
190
+ _id: contact._id as unknown as string,
191
+ email: contact.email,
192
+ firstName: contact.firstName,
193
+ lastName: contact.lastName,
194
+ userId: contact.userId,
195
+ source: contact.source,
196
+ subscribed: contact.subscribed ?? true,
197
+ userGroup: contact.userGroup,
198
+ loopsContactId: contact.loopsContactId,
199
+ createdAt: contact.createdAt,
200
+ updatedAt: contact.updatedAt,
201
+ }));
202
+
203
+ return {
204
+ contacts: mappedContacts,
205
+ continueCursor: result.continueCursor,
206
+ isDone: result.isDone,
207
+ };
208
+ },
209
+ });
210
+
211
+ /**
212
+ * Check for spam patterns: too many emails to the same recipient in a time window
213
+ * Returns email addresses that received too many emails.
214
+ *
215
+ * NOTE: Analysis is limited to the most recent MAX_SPAM_DETECTION_LIMIT operations
216
+ * in the time window to avoid query read limit errors.
217
+ */
218
+ export const detectRecipientSpam = query({
219
+ args: {
220
+ timeWindowMs: v.optional(v.number()),
221
+ maxEmailsPerRecipient: v.optional(v.number()),
222
+ },
223
+ returns: v.array(recipientSpamValidator),
224
+ handler: async (ctx, args) => {
225
+ const timeWindowMs = args.timeWindowMs ?? 3600000;
226
+ const maxEmailsPerRecipient = args.maxEmailsPerRecipient ?? 10;
227
+ const cutoffTime = Date.now() - timeWindowMs;
228
+
229
+ const operations = await ctx.db
230
+ .query("emailOperations")
231
+ .withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
232
+ .take(MAX_SPAM_DETECTION_LIMIT);
233
+
234
+ const emailCounts = new Map<string, number>();
235
+ for (const op of operations) {
236
+ if (op.email && op.email !== "audience") {
237
+ emailCounts.set(op.email, (emailCounts.get(op.email) ?? 0) + 1);
238
+ }
239
+ }
240
+
241
+ const suspicious: Array<{
242
+ email: string;
243
+ count: number;
244
+ timeWindowMs: number;
245
+ }> = [];
246
+ for (const [email, count] of emailCounts.entries()) {
247
+ if (count > maxEmailsPerRecipient) {
248
+ suspicious.push({
249
+ email,
250
+ count,
251
+ timeWindowMs,
252
+ });
253
+ }
254
+ }
255
+
256
+ return suspicious;
257
+ },
258
+ });
259
+
260
+ /**
261
+ * Check for spam patterns: too many emails from the same actor/user
262
+ * Returns actor IDs that sent too many emails.
263
+ *
264
+ * NOTE: Analysis is limited to the most recent MAX_SPAM_DETECTION_LIMIT operations
265
+ * in the time window to avoid query read limit errors.
266
+ */
267
+ export const detectActorSpam = query({
268
+ args: {
269
+ timeWindowMs: v.optional(v.number()),
270
+ maxEmailsPerActor: v.optional(v.number()),
271
+ },
272
+ returns: v.array(actorSpamValidator),
273
+ handler: async (ctx, args) => {
274
+ const timeWindowMs = args.timeWindowMs ?? 3600000;
275
+ const maxEmailsPerActor = args.maxEmailsPerActor ?? 100;
276
+ const cutoffTime = Date.now() - timeWindowMs;
277
+
278
+ const operations = await ctx.db
279
+ .query("emailOperations")
280
+ .withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
281
+ .take(MAX_SPAM_DETECTION_LIMIT);
282
+
283
+ const actorCounts = new Map<string, number>();
284
+ for (const op of operations) {
285
+ if (op.actorId) {
286
+ actorCounts.set(op.actorId, (actorCounts.get(op.actorId) ?? 0) + 1);
287
+ }
288
+ }
289
+
290
+ const suspicious: Array<{
291
+ actorId: string;
292
+ count: number;
293
+ timeWindowMs: number;
294
+ }> = [];
295
+ for (const [actorId, count] of actorCounts.entries()) {
296
+ if (count > maxEmailsPerActor) {
297
+ suspicious.push({
298
+ actorId,
299
+ count,
300
+ timeWindowMs,
301
+ });
302
+ }
303
+ }
304
+
305
+ return suspicious;
306
+ },
307
+ });
308
+
309
+ /**
310
+ * Get recent email operation statistics for monitoring.
311
+ *
312
+ * NOTE: Statistics are calculated from the most recent MAX_SPAM_DETECTION_LIMIT
313
+ * operations in the time window to avoid query read limit errors. For high-volume
314
+ * applications, consider using scheduled jobs with pagination for exact statistics.
315
+ */
316
+ export const getEmailStats = query({
317
+ args: {
318
+ timeWindowMs: v.optional(v.number()),
319
+ },
320
+ returns: emailStatsResponseValidator,
321
+ handler: async (ctx, args) => {
322
+ const timeWindowMs = args.timeWindowMs ?? 86400000;
323
+ const cutoffTime = Date.now() - timeWindowMs;
324
+
325
+ const operations = await ctx.db
326
+ .query("emailOperations")
327
+ .withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
328
+ .take(MAX_SPAM_DETECTION_LIMIT);
329
+
330
+ const stats = {
331
+ totalOperations: operations.length,
332
+ successfulOperations: operations.filter((op) => op.success).length,
333
+ failedOperations: operations.filter((op) => !op.success).length,
334
+ operationsByType: {} as Record<string, number>,
335
+ uniqueRecipients: new Set<string>(),
336
+ uniqueActors: new Set<string>(),
337
+ };
338
+
339
+ for (const op of operations) {
340
+ stats.operationsByType[op.operationType] =
341
+ (stats.operationsByType[op.operationType] ?? 0) + 1;
342
+
343
+ if (op.email && op.email !== "audience") {
344
+ stats.uniqueRecipients.add(op.email);
345
+ }
346
+
347
+ if (op.actorId) {
348
+ stats.uniqueActors.add(op.actorId);
349
+ }
350
+ }
351
+
352
+ return {
353
+ ...stats,
354
+ uniqueRecipients: stats.uniqueRecipients.size,
355
+ uniqueActors: stats.uniqueActors.size,
356
+ };
357
+ },
358
+ });
359
+
360
+ /**
361
+ * Detect rapid-fire email sending patterns (multiple emails sent in quick succession)
362
+ * Returns suspicious patterns indicating potential spam.
363
+ *
364
+ * NOTE: Analysis is limited to the most recent MAX_SPAM_DETECTION_LIMIT operations
365
+ * in the time window to avoid query read limit errors.
366
+ */
367
+ export const detectRapidFirePatterns = query({
368
+ args: {
369
+ timeWindowMs: v.optional(v.number()),
370
+ minEmailsInWindow: v.optional(v.number()),
371
+ },
372
+ returns: v.array(rapidFirePatternValidator),
373
+ handler: async (ctx, args) => {
374
+ const timeWindowMs = args.timeWindowMs ?? 60000;
375
+ const minEmailsInWindow = args.minEmailsInWindow ?? 5;
376
+ const cutoffTime = Date.now() - timeWindowMs;
377
+
378
+ const operations = await ctx.db
379
+ .query("emailOperations")
380
+ .withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
381
+ .take(MAX_SPAM_DETECTION_LIMIT);
382
+
383
+ const sortedOps = [...operations].sort((a, b) => a.timestamp - b.timestamp);
384
+
385
+ const patterns: Array<{
386
+ email?: string;
387
+ actorId?: string;
388
+ count: number;
389
+ timeWindowMs: number;
390
+ firstTimestamp: number;
391
+ lastTimestamp: number;
392
+ }> = [];
393
+
394
+ const emailGroups = new Map<string, typeof sortedOps>();
395
+ for (const op of sortedOps) {
396
+ if (op.email && op.email !== "audience") {
397
+ if (!emailGroups.has(op.email)) {
398
+ emailGroups.set(op.email, []);
399
+ }
400
+ emailGroups.get(op.email)?.push(op);
401
+ }
402
+ }
403
+
404
+ for (const [email, ops] of emailGroups.entries()) {
405
+ for (let i = 0; i < ops.length; i++) {
406
+ const op = ops[i];
407
+ if (!op) continue;
408
+
409
+ const windowStart = op.timestamp;
410
+ const windowEnd = windowStart + timeWindowMs;
411
+ const opsInWindow = ops.filter(
412
+ (op) => op.timestamp >= windowStart && op.timestamp <= windowEnd,
413
+ );
414
+
415
+ if (opsInWindow.length >= minEmailsInWindow) {
416
+ patterns.push({
417
+ email,
418
+ count: opsInWindow.length,
419
+ timeWindowMs,
420
+ firstTimestamp: windowStart,
421
+ lastTimestamp: windowEnd,
422
+ });
423
+ }
424
+ }
425
+ }
426
+
427
+ const actorGroups = new Map<string, typeof sortedOps>();
428
+ for (const op of sortedOps) {
429
+ if (op.actorId) {
430
+ if (!actorGroups.has(op.actorId)) {
431
+ actorGroups.set(op.actorId, []);
432
+ }
433
+ actorGroups.get(op.actorId)?.push(op);
434
+ }
435
+ }
436
+
437
+ for (const [actorId, ops] of actorGroups.entries()) {
438
+ for (let i = 0; i < ops.length; i++) {
439
+ const op = ops[i];
440
+ if (!op) continue;
441
+
442
+ const windowStart = op.timestamp;
443
+ const windowEnd = windowStart + timeWindowMs;
444
+ const opsInWindow = ops.filter(
445
+ (op) => op.timestamp >= windowStart && op.timestamp <= windowEnd,
446
+ );
447
+
448
+ if (opsInWindow.length >= minEmailsInWindow) {
449
+ patterns.push({
450
+ actorId,
451
+ count: opsInWindow.length,
452
+ timeWindowMs,
453
+ firstTimestamp: windowStart,
454
+ lastTimestamp: windowEnd,
455
+ });
456
+ }
457
+ }
458
+ }
459
+
460
+ return patterns;
461
+ },
462
+ });
463
+
464
+ /**
465
+ * Rate limiting: Check if an email can be sent to a recipient
466
+ * Based on recent email operations in the database.
467
+ *
468
+ * Uses efficient .take() query - only reads the minimum number of documents
469
+ * needed to determine if the rate limit is exceeded.
470
+ */
471
+ export const checkRecipientRateLimit = query({
472
+ args: {
473
+ email: v.string(),
474
+ timeWindowMs: v.number(),
475
+ maxEmails: v.number(),
476
+ },
477
+ returns: rateLimitResponseValidator,
478
+ handler: async (ctx, args) => {
479
+ const cutoffTime = Date.now() - args.timeWindowMs;
480
+
481
+ // Use the compound index (email, timestamp) to efficiently query
482
+ // Only fetch up to maxEmails + 1 to check if limit exceeded
483
+ const operations = await ctx.db
484
+ .query("emailOperations")
485
+ .withIndex("email", (q) =>
486
+ q.eq("email", args.email).gte("timestamp", cutoffTime),
487
+ )
488
+ .take(args.maxEmails + 1);
489
+
490
+ // Filter for successful operations only
491
+ const recentOps = operations.filter((op) => op.success);
492
+ const count = recentOps.length;
493
+ const allowed = count < args.maxEmails;
494
+
495
+ let retryAfter: number | undefined;
496
+ if (!allowed && recentOps.length > 0) {
497
+ const oldestOp = recentOps.reduce((oldest, op) =>
498
+ op.timestamp < oldest.timestamp ? op : oldest,
499
+ );
500
+ retryAfter = oldestOp.timestamp + args.timeWindowMs - Date.now();
501
+ if (retryAfter < 0) retryAfter = 0;
502
+ }
503
+
504
+ return {
505
+ allowed,
506
+ count,
507
+ limit: args.maxEmails,
508
+ timeWindowMs: args.timeWindowMs,
509
+ retryAfter,
510
+ };
511
+ },
512
+ });
513
+
514
+ /**
515
+ * Rate limiting: Check if an actor/user can send more emails
516
+ * Based on recent email operations in the database.
517
+ *
518
+ * Uses efficient .take() query - only reads the minimum number of documents
519
+ * needed to determine if the rate limit is exceeded.
520
+ */
521
+ export const checkActorRateLimit = query({
522
+ args: {
523
+ actorId: v.string(),
524
+ timeWindowMs: v.number(),
525
+ maxEmails: v.number(),
526
+ },
527
+ returns: rateLimitResponseValidator,
528
+ handler: async (ctx, args) => {
529
+ const cutoffTime = Date.now() - args.timeWindowMs;
530
+
531
+ // Use the compound index (actorId, timestamp) to efficiently query
532
+ // Only fetch up to maxEmails + 1 to check if limit exceeded
533
+ const operations = await ctx.db
534
+ .query("emailOperations")
535
+ .withIndex("actorId", (q) =>
536
+ q.eq("actorId", args.actorId).gte("timestamp", cutoffTime),
537
+ )
538
+ .take(args.maxEmails + 1);
539
+
540
+ // Filter for successful operations only
541
+ const recentOps = operations.filter((op) => op.success);
542
+ const count = recentOps.length;
543
+ const allowed = count < args.maxEmails;
544
+
545
+ let retryAfter: number | undefined;
546
+ if (!allowed && recentOps.length > 0) {
547
+ const oldestOp = recentOps.reduce((oldest, op) =>
548
+ op.timestamp < oldest.timestamp ? op : oldest,
549
+ );
550
+ retryAfter = oldestOp.timestamp + args.timeWindowMs - Date.now();
551
+ if (retryAfter < 0) retryAfter = 0;
552
+ }
553
+
554
+ return {
555
+ allowed,
556
+ count,
557
+ limit: args.maxEmails,
558
+ timeWindowMs: args.timeWindowMs,
559
+ retryAfter,
560
+ };
561
+ },
562
+ });
563
+
564
+ /**
565
+ * Rate limiting: Check global email sending rate
566
+ * Checks total email operations across all senders.
567
+ *
568
+ * Uses efficient .take() query - only reads the minimum number of documents
569
+ * needed to determine if the rate limit is exceeded.
570
+ */
571
+ export const checkGlobalRateLimit = query({
572
+ args: {
573
+ timeWindowMs: v.number(),
574
+ maxEmails: v.number(),
575
+ },
576
+ returns: v.object({
577
+ allowed: v.boolean(),
578
+ count: v.number(),
579
+ limit: v.number(),
580
+ timeWindowMs: v.number(),
581
+ }),
582
+ handler: async (ctx, args) => {
583
+ const cutoffTime = Date.now() - args.timeWindowMs;
584
+
585
+ // Use the timestamp index to efficiently query recent operations
586
+ // Only fetch up to maxEmails + 1 to check if limit exceeded
587
+ const operations = await ctx.db
588
+ .query("emailOperations")
589
+ .withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
590
+ .take(args.maxEmails + 1);
591
+
592
+ // Filter for successful operations only
593
+ const recentOps = operations.filter((op) => op.success);
594
+ const count = recentOps.length;
595
+ const allowed = count < args.maxEmails;
596
+
597
+ return {
598
+ allowed,
599
+ count,
600
+ limit: args.maxEmails,
601
+ timeWindowMs: args.timeWindowMs,
602
+ };
603
+ },
604
+ });
@@ -0,0 +1,17 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { contactsFields } from "./tables/contacts";
3
+ import { emailOperationsFields } from "./tables/emailOperations";
4
+
5
+ export default defineSchema({
6
+ contacts: defineTable(contactsFields)
7
+ .index("email", ["email"])
8
+ .index("userId", ["userId"])
9
+ .index("userGroup", ["userGroup"])
10
+ .index("source", ["source"])
11
+ .index("subscribed", ["subscribed"]),
12
+ emailOperations: defineTable(emailOperationsFields)
13
+ .index("email", ["email", "timestamp"])
14
+ .index("actorId", ["actorId", "timestamp"])
15
+ .index("operationType", ["operationType", "timestamp"])
16
+ .index("timestamp", ["timestamp"]),
17
+ });
@@ -0,0 +1,17 @@
1
+ import { v } from "convex/values";
2
+
3
+ /**
4
+ * Contacts table field definitions
5
+ */
6
+ export const contactsFields = {
7
+ email: v.string(),
8
+ firstName: v.optional(v.string()),
9
+ lastName: v.optional(v.string()),
10
+ userId: v.optional(v.string()),
11
+ source: v.optional(v.string()),
12
+ subscribed: v.optional(v.boolean()),
13
+ userGroup: v.optional(v.string()),
14
+ loopsContactId: v.optional(v.string()),
15
+ createdAt: v.number(),
16
+ updatedAt: v.number(),
17
+ };
@@ -0,0 +1,23 @@
1
+ import { v } from "convex/values";
2
+
3
+ /**
4
+ * Email operations table field definitions
5
+ */
6
+ export const emailOperationsFields = {
7
+ operationType: v.union(
8
+ v.literal("transactional"),
9
+ v.literal("event"),
10
+ v.literal("campaign"),
11
+ v.literal("loop"),
12
+ ),
13
+ email: v.string(),
14
+ actorId: v.optional(v.string()),
15
+ transactionalId: v.optional(v.string()),
16
+ campaignId: v.optional(v.string()),
17
+ loopId: v.optional(v.string()),
18
+ eventName: v.optional(v.string()),
19
+ timestamp: v.number(),
20
+ success: v.boolean(),
21
+ messageId: v.optional(v.string()),
22
+ metadata: v.optional(v.record(v.string(), v.any())),
23
+ };