@devwithbobby/loops 0.1.19 → 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.
- package/dist/client/index.d.ts +47 -12
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +42 -4
- package/dist/component/_generated/api.d.ts +185 -1
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/component.d.ts +12 -5
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/dataModel.d.ts +1 -1
- package/dist/component/aggregates.d.ts +42 -0
- package/dist/component/aggregates.d.ts.map +1 -0
- package/dist/component/aggregates.js +54 -0
- package/dist/component/convex.config.d.ts.map +1 -1
- package/dist/component/convex.config.js +2 -0
- package/dist/component/helpers.d.ts.map +1 -1
- package/dist/component/http.js +1 -1
- package/dist/component/lib.d.ts +66 -17
- package/dist/component/lib.d.ts.map +1 -1
- package/dist/component/lib.js +194 -73
- package/dist/component/schema.d.ts +2 -2
- package/dist/component/tables/contacts.d.ts.map +1 -1
- package/dist/component/tables/emailOperations.d.ts +4 -4
- package/dist/component/tables/emailOperations.d.ts.map +1 -1
- package/dist/test.d.ts +2 -2
- package/dist/types.d.ts +249 -62
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +4 -2
- package/dist/utils.d.ts +6 -6
- package/package.json +15 -9
- package/src/client/index.ts +52 -6
- package/src/component/_generated/api.ts +190 -1
- package/src/component/_generated/component.ts +10 -5
- package/src/component/_generated/dataModel.ts +1 -1
- package/src/component/aggregates.ts +89 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/http.ts +1 -1
- package/src/component/lib.ts +226 -89
- package/src/types.ts +20 -122
package/src/component/lib.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
+
import { paginator } from "convex-helpers/server/pagination";
|
|
2
|
+
import type { PaginationResult } from "convex/server";
|
|
1
3
|
import { z } from "zod";
|
|
2
4
|
import { internalLib } from "../types";
|
|
3
5
|
import { za, zm, zq } from "../utils.js";
|
|
4
6
|
import type { Doc } from "./_generated/dataModel.js";
|
|
7
|
+
import {
|
|
8
|
+
aggregateClear,
|
|
9
|
+
aggregateCountByUserGroup,
|
|
10
|
+
aggregateCountTotal,
|
|
11
|
+
aggregateDelete,
|
|
12
|
+
aggregateInsert,
|
|
13
|
+
aggregateReplace,
|
|
14
|
+
} from "./aggregates";
|
|
5
15
|
import { loopsFetch, sanitizeLoopsError } from "./helpers";
|
|
16
|
+
import schema from "./schema";
|
|
6
17
|
import { contactValidator } from "./validators.js";
|
|
7
18
|
|
|
8
19
|
/**
|
|
@@ -28,6 +39,7 @@ export const storeContact = zm({
|
|
|
28
39
|
.unique();
|
|
29
40
|
|
|
30
41
|
if (existing) {
|
|
42
|
+
// Update the contact
|
|
31
43
|
await ctx.db.patch(existing._id, {
|
|
32
44
|
firstName: args.firstName,
|
|
33
45
|
lastName: args.lastName,
|
|
@@ -38,8 +50,15 @@ export const storeContact = zm({
|
|
|
38
50
|
loopsContactId: args.loopsContactId,
|
|
39
51
|
updatedAt: now,
|
|
40
52
|
});
|
|
53
|
+
|
|
54
|
+
// Get the updated document and update aggregate if userGroup changed
|
|
55
|
+
const updated = await ctx.db.get(existing._id);
|
|
56
|
+
if (updated && existing.userGroup !== updated.userGroup) {
|
|
57
|
+
await aggregateReplace(ctx, existing, updated);
|
|
58
|
+
}
|
|
41
59
|
} else {
|
|
42
|
-
|
|
60
|
+
// Insert new contact
|
|
61
|
+
const id = await ctx.db.insert("contacts", {
|
|
43
62
|
email: args.email,
|
|
44
63
|
firstName: args.firstName,
|
|
45
64
|
lastName: args.lastName,
|
|
@@ -51,6 +70,12 @@ export const storeContact = zm({
|
|
|
51
70
|
createdAt: now,
|
|
52
71
|
updatedAt: now,
|
|
53
72
|
});
|
|
73
|
+
|
|
74
|
+
// Add to aggregate for counting
|
|
75
|
+
const newDoc = await ctx.db.get(id);
|
|
76
|
+
if (newDoc) {
|
|
77
|
+
await aggregateInsert(ctx, newDoc);
|
|
78
|
+
}
|
|
54
79
|
}
|
|
55
80
|
},
|
|
56
81
|
});
|
|
@@ -70,6 +95,8 @@ export const removeContact = zm({
|
|
|
70
95
|
.unique();
|
|
71
96
|
|
|
72
97
|
if (existing) {
|
|
98
|
+
// Remove from aggregate first (before deleting the document)
|
|
99
|
+
await aggregateDelete(ctx, existing);
|
|
73
100
|
await ctx.db.delete(existing._id);
|
|
74
101
|
}
|
|
75
102
|
},
|
|
@@ -114,13 +141,27 @@ export const logEmailOperation = zm({
|
|
|
114
141
|
},
|
|
115
142
|
});
|
|
116
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Maximum number of documents to read when counting with filters.
|
|
146
|
+
* This limit prevents query read limit errors while still providing accurate
|
|
147
|
+
* counts for most use cases. If you have more contacts than this, consider
|
|
148
|
+
* using the aggregate-based counting with userGroup only.
|
|
149
|
+
*/
|
|
150
|
+
const MAX_COUNT_LIMIT = 8000;
|
|
151
|
+
|
|
117
152
|
/**
|
|
118
153
|
* Count contacts in the database
|
|
119
154
|
* Can filter by audience criteria (userGroup, source, subscribed status)
|
|
120
155
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
156
|
+
* For userGroup-only filtering, uses efficient O(log n) aggregate counting.
|
|
157
|
+
* For other filters (source, subscribed), uses indexed queries with a read limit.
|
|
158
|
+
*
|
|
159
|
+
* IMPORTANT: Before using this with existing data, run the backfillContactAggregate
|
|
160
|
+
* mutation to populate the aggregate with existing contacts.
|
|
161
|
+
*
|
|
162
|
+
* NOTE: When filtering by source or subscribed, counts are capped at MAX_COUNT_LIMIT
|
|
163
|
+
* to avoid query read limit errors. For exact counts with large datasets, use
|
|
164
|
+
* userGroup-only filtering which uses efficient aggregate counting.
|
|
124
165
|
*/
|
|
125
166
|
export const countContacts = zq({
|
|
126
167
|
args: z.object({
|
|
@@ -130,41 +171,45 @@ export const countContacts = zq({
|
|
|
130
171
|
}),
|
|
131
172
|
returns: z.number(),
|
|
132
173
|
handler: async (ctx, args) => {
|
|
133
|
-
//
|
|
134
|
-
|
|
174
|
+
// If only userGroup is specified (or no filters), use efficient aggregate counting
|
|
175
|
+
const onlyUserGroupFilter =
|
|
176
|
+
args.source === undefined && args.subscribed === undefined;
|
|
177
|
+
|
|
178
|
+
if (onlyUserGroupFilter) {
|
|
179
|
+
// Use O(log n) aggregate counting - much more efficient than .collect()
|
|
180
|
+
if (args.userGroup === undefined) {
|
|
181
|
+
// Count ALL contacts across all namespaces
|
|
182
|
+
return await aggregateCountTotal(ctx);
|
|
183
|
+
}
|
|
184
|
+
// Count contacts in specific userGroup namespace
|
|
185
|
+
return await aggregateCountByUserGroup(ctx, args.userGroup);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// For other filters, we need to use indexed queries with in-memory filtering
|
|
189
|
+
// We use .take() with a reasonable limit to avoid query read limit errors
|
|
135
190
|
let contacts: Doc<"contacts">[];
|
|
136
191
|
|
|
137
192
|
if (args.userGroup !== undefined) {
|
|
138
193
|
contacts = await ctx.db
|
|
139
194
|
.query("contacts")
|
|
140
195
|
.withIndex("userGroup", (q) => q.eq("userGroup", args.userGroup))
|
|
141
|
-
.
|
|
196
|
+
.take(MAX_COUNT_LIMIT);
|
|
142
197
|
} else if (args.source !== undefined) {
|
|
143
198
|
contacts = await ctx.db
|
|
144
199
|
.query("contacts")
|
|
145
200
|
.withIndex("source", (q) => q.eq("source", args.source))
|
|
146
|
-
.
|
|
201
|
+
.take(MAX_COUNT_LIMIT);
|
|
147
202
|
} else if (args.subscribed !== undefined) {
|
|
148
203
|
contacts = await ctx.db
|
|
149
204
|
.query("contacts")
|
|
150
205
|
.withIndex("subscribed", (q) => q.eq("subscribed", args.subscribed))
|
|
151
|
-
.
|
|
206
|
+
.take(MAX_COUNT_LIMIT);
|
|
152
207
|
} else {
|
|
153
|
-
|
|
208
|
+
// This branch shouldn't be reached due to onlyUserGroupFilter check above
|
|
209
|
+
contacts = await ctx.db.query("contacts").take(MAX_COUNT_LIMIT);
|
|
154
210
|
}
|
|
155
211
|
|
|
156
212
|
// Apply additional filters if multiple criteria were provided
|
|
157
|
-
// This avoids redundant filtering when only one filter was used
|
|
158
|
-
const needsFiltering =
|
|
159
|
-
(args.userGroup !== undefined ? 1 : 0) +
|
|
160
|
-
(args.source !== undefined ? 1 : 0) +
|
|
161
|
-
(args.subscribed !== undefined ? 1 : 0) >
|
|
162
|
-
1;
|
|
163
|
-
|
|
164
|
-
if (!needsFiltering) {
|
|
165
|
-
return contacts.length;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
213
|
const filtered = contacts.filter((c) => {
|
|
169
214
|
if (args.userGroup !== undefined && c.userGroup !== args.userGroup) {
|
|
170
215
|
return false;
|
|
@@ -183,12 +228,15 @@ export const countContacts = zq({
|
|
|
183
228
|
});
|
|
184
229
|
|
|
185
230
|
/**
|
|
186
|
-
* List contacts from the database with pagination
|
|
231
|
+
* List contacts from the database with cursor-based pagination
|
|
187
232
|
* Can filter by audience criteria (userGroup, source, subscribed status)
|
|
188
233
|
* Returns actual contact data, not just a count
|
|
189
234
|
*
|
|
235
|
+
* Uses cursor-based pagination for efficient querying - only reads documents
|
|
236
|
+
* from the cursor position forward, not all preceding documents.
|
|
237
|
+
*
|
|
190
238
|
* Note: When multiple filters are provided, only one index can be used.
|
|
191
|
-
* Additional filters are applied in-memory
|
|
239
|
+
* Additional filters are applied in-memory after fetching.
|
|
192
240
|
*/
|
|
193
241
|
export const listContacts = zq({
|
|
194
242
|
args: z.object({
|
|
@@ -196,7 +244,7 @@ export const listContacts = zq({
|
|
|
196
244
|
source: z.string().optional(),
|
|
197
245
|
subscribed: z.boolean().optional(),
|
|
198
246
|
limit: z.number().min(1).max(1000).default(100),
|
|
199
|
-
|
|
247
|
+
cursor: z.string().nullable().optional(),
|
|
200
248
|
}),
|
|
201
249
|
returns: z.object({
|
|
202
250
|
contacts: z.array(
|
|
@@ -214,43 +262,54 @@ export const listContacts = zq({
|
|
|
214
262
|
updatedAt: z.number(),
|
|
215
263
|
}),
|
|
216
264
|
),
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
offset: z.number(),
|
|
220
|
-
hasMore: z.boolean(),
|
|
265
|
+
continueCursor: z.string().nullable(),
|
|
266
|
+
isDone: z.boolean(),
|
|
221
267
|
}),
|
|
222
268
|
handler: async (ctx, args) => {
|
|
223
|
-
|
|
224
|
-
|
|
269
|
+
const paginationOpts = {
|
|
270
|
+
cursor: args.cursor ?? null,
|
|
271
|
+
numItems: args.limit,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// Determine which index to use based on filters
|
|
275
|
+
const needsFiltering =
|
|
276
|
+
(args.userGroup !== undefined ? 1 : 0) +
|
|
277
|
+
(args.source !== undefined ? 1 : 0) +
|
|
278
|
+
(args.subscribed !== undefined ? 1 : 0) >
|
|
279
|
+
1;
|
|
280
|
+
|
|
281
|
+
let result: PaginationResult<Doc<"contacts">>;
|
|
225
282
|
|
|
226
283
|
if (args.userGroup !== undefined) {
|
|
227
|
-
|
|
284
|
+
result = await paginator(ctx.db, schema)
|
|
228
285
|
.query("contacts")
|
|
229
286
|
.withIndex("userGroup", (q) => q.eq("userGroup", args.userGroup))
|
|
230
|
-
.
|
|
287
|
+
.order("desc")
|
|
288
|
+
.paginate(paginationOpts);
|
|
231
289
|
} else if (args.source !== undefined) {
|
|
232
|
-
|
|
290
|
+
result = await paginator(ctx.db, schema)
|
|
233
291
|
.query("contacts")
|
|
234
292
|
.withIndex("source", (q) => q.eq("source", args.source))
|
|
235
|
-
.
|
|
293
|
+
.order("desc")
|
|
294
|
+
.paginate(paginationOpts);
|
|
236
295
|
} else if (args.subscribed !== undefined) {
|
|
237
|
-
|
|
296
|
+
result = await paginator(ctx.db, schema)
|
|
238
297
|
.query("contacts")
|
|
239
298
|
.withIndex("subscribed", (q) => q.eq("subscribed", args.subscribed))
|
|
240
|
-
.
|
|
299
|
+
.order("desc")
|
|
300
|
+
.paginate(paginationOpts);
|
|
241
301
|
} else {
|
|
242
|
-
|
|
302
|
+
result = await paginator(ctx.db, schema)
|
|
303
|
+
.query("contacts")
|
|
304
|
+
.order("desc")
|
|
305
|
+
.paginate(paginationOpts);
|
|
243
306
|
}
|
|
244
307
|
|
|
245
|
-
|
|
246
|
-
const needsFiltering =
|
|
247
|
-
(args.userGroup !== undefined ? 1 : 0) +
|
|
248
|
-
(args.source !== undefined ? 1 : 0) +
|
|
249
|
-
(args.subscribed !== undefined ? 1 : 0) >
|
|
250
|
-
1;
|
|
308
|
+
let contacts = result.page;
|
|
251
309
|
|
|
310
|
+
// Apply additional filters if multiple criteria were provided
|
|
252
311
|
if (needsFiltering) {
|
|
253
|
-
|
|
312
|
+
contacts = contacts.filter((c) => {
|
|
254
313
|
if (args.userGroup !== undefined && c.userGroup !== args.userGroup) {
|
|
255
314
|
return false;
|
|
256
315
|
}
|
|
@@ -264,24 +323,15 @@ export const listContacts = zq({
|
|
|
264
323
|
});
|
|
265
324
|
}
|
|
266
325
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const paginatedContacts = allContacts
|
|
272
|
-
.slice(args.offset, args.offset + args.limit)
|
|
273
|
-
.map((contact) => ({
|
|
274
|
-
...contact,
|
|
275
|
-
subscribed: contact.subscribed ?? true, // Ensure subscribed is always boolean
|
|
276
|
-
}));
|
|
277
|
-
const hasMore = args.offset + args.limit < total;
|
|
326
|
+
const mappedContacts = contacts.map((contact) => ({
|
|
327
|
+
...contact,
|
|
328
|
+
subscribed: contact.subscribed ?? true,
|
|
329
|
+
}));
|
|
278
330
|
|
|
279
331
|
return {
|
|
280
|
-
contacts:
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
offset: args.offset,
|
|
284
|
-
hasMore,
|
|
332
|
+
contacts: mappedContacts,
|
|
333
|
+
continueCursor: result.continueCursor,
|
|
334
|
+
isDone: result.isDone,
|
|
285
335
|
};
|
|
286
336
|
},
|
|
287
337
|
});
|
|
@@ -868,9 +918,19 @@ export const resubscribeContact = za({
|
|
|
868
918
|
},
|
|
869
919
|
});
|
|
870
920
|
|
|
921
|
+
/**
|
|
922
|
+
* Maximum number of email operations to read for spam detection.
|
|
923
|
+
* This limit prevents query read limit errors while covering most spam scenarios.
|
|
924
|
+
* If you need to analyze more operations, consider using scheduled jobs with pagination.
|
|
925
|
+
*/
|
|
926
|
+
const MAX_SPAM_DETECTION_LIMIT = 8000;
|
|
927
|
+
|
|
871
928
|
/**
|
|
872
929
|
* Check for spam patterns: too many emails to the same recipient in a time window
|
|
873
|
-
* Returns email addresses that received too many emails
|
|
930
|
+
* Returns email addresses that received too many emails.
|
|
931
|
+
*
|
|
932
|
+
* NOTE: Analysis is limited to the most recent MAX_SPAM_DETECTION_LIMIT operations
|
|
933
|
+
* in the time window to avoid query read limit errors.
|
|
874
934
|
*/
|
|
875
935
|
export const detectRecipientSpam = zq({
|
|
876
936
|
args: z.object({
|
|
@@ -892,7 +952,7 @@ export const detectRecipientSpam = zq({
|
|
|
892
952
|
const operations = await ctx.db
|
|
893
953
|
.query("emailOperations")
|
|
894
954
|
.withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
|
|
895
|
-
.
|
|
955
|
+
.take(MAX_SPAM_DETECTION_LIMIT);
|
|
896
956
|
|
|
897
957
|
const emailCounts = new Map<string, number>();
|
|
898
958
|
for (const op of operations) {
|
|
@@ -922,7 +982,10 @@ export const detectRecipientSpam = zq({
|
|
|
922
982
|
|
|
923
983
|
/**
|
|
924
984
|
* Check for spam patterns: too many emails from the same actor/user
|
|
925
|
-
* Returns actor IDs that sent too many emails
|
|
985
|
+
* Returns actor IDs that sent too many emails.
|
|
986
|
+
*
|
|
987
|
+
* NOTE: Analysis is limited to the most recent MAX_SPAM_DETECTION_LIMIT operations
|
|
988
|
+
* in the time window to avoid query read limit errors.
|
|
926
989
|
*/
|
|
927
990
|
export const detectActorSpam = zq({
|
|
928
991
|
args: z.object({
|
|
@@ -942,7 +1005,7 @@ export const detectActorSpam = zq({
|
|
|
942
1005
|
const operations = await ctx.db
|
|
943
1006
|
.query("emailOperations")
|
|
944
1007
|
.withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
|
|
945
|
-
.
|
|
1008
|
+
.take(MAX_SPAM_DETECTION_LIMIT);
|
|
946
1009
|
|
|
947
1010
|
const actorCounts = new Map<string, number>();
|
|
948
1011
|
for (const op of operations) {
|
|
@@ -971,7 +1034,11 @@ export const detectActorSpam = zq({
|
|
|
971
1034
|
});
|
|
972
1035
|
|
|
973
1036
|
/**
|
|
974
|
-
* Get recent email operation statistics for monitoring
|
|
1037
|
+
* Get recent email operation statistics for monitoring.
|
|
1038
|
+
*
|
|
1039
|
+
* NOTE: Statistics are calculated from the most recent MAX_SPAM_DETECTION_LIMIT
|
|
1040
|
+
* operations in the time window to avoid query read limit errors. For high-volume
|
|
1041
|
+
* applications, consider using scheduled jobs with pagination for exact statistics.
|
|
975
1042
|
*/
|
|
976
1043
|
export const getEmailStats = zq({
|
|
977
1044
|
args: z.object({
|
|
@@ -993,7 +1060,7 @@ export const getEmailStats = zq({
|
|
|
993
1060
|
const operations = await ctx.db
|
|
994
1061
|
.query("emailOperations")
|
|
995
1062
|
.withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
|
|
996
|
-
.
|
|
1063
|
+
.take(MAX_SPAM_DETECTION_LIMIT);
|
|
997
1064
|
|
|
998
1065
|
const stats = {
|
|
999
1066
|
totalOperations: operations.length,
|
|
@@ -1027,7 +1094,10 @@ export const getEmailStats = zq({
|
|
|
1027
1094
|
|
|
1028
1095
|
/**
|
|
1029
1096
|
* Detect rapid-fire email sending patterns (multiple emails sent in quick succession)
|
|
1030
|
-
* Returns suspicious patterns indicating potential spam
|
|
1097
|
+
* Returns suspicious patterns indicating potential spam.
|
|
1098
|
+
*
|
|
1099
|
+
* NOTE: Analysis is limited to the most recent MAX_SPAM_DETECTION_LIMIT operations
|
|
1100
|
+
* in the time window to avoid query read limit errors.
|
|
1031
1101
|
*/
|
|
1032
1102
|
export const detectRapidFirePatterns = zq({
|
|
1033
1103
|
args: z.object({
|
|
@@ -1050,9 +1120,9 @@ export const detectRapidFirePatterns = zq({
|
|
|
1050
1120
|
const operations = await ctx.db
|
|
1051
1121
|
.query("emailOperations")
|
|
1052
1122
|
.withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
|
|
1053
|
-
.
|
|
1123
|
+
.take(MAX_SPAM_DETECTION_LIMIT);
|
|
1054
1124
|
|
|
1055
|
-
operations.sort((a, b) => a.timestamp - b.timestamp);
|
|
1125
|
+
const sortedOps = [...operations].sort((a, b) => a.timestamp - b.timestamp);
|
|
1056
1126
|
|
|
1057
1127
|
const patterns: Array<{
|
|
1058
1128
|
email?: string;
|
|
@@ -1063,8 +1133,8 @@ export const detectRapidFirePatterns = zq({
|
|
|
1063
1133
|
lastTimestamp: number;
|
|
1064
1134
|
}> = [];
|
|
1065
1135
|
|
|
1066
|
-
const emailGroups = new Map<string, typeof
|
|
1067
|
-
for (const op of
|
|
1136
|
+
const emailGroups = new Map<string, typeof sortedOps>();
|
|
1137
|
+
for (const op of sortedOps) {
|
|
1068
1138
|
if (op.email && op.email !== "audience") {
|
|
1069
1139
|
if (!emailGroups.has(op.email)) {
|
|
1070
1140
|
emailGroups.set(op.email, []);
|
|
@@ -1096,8 +1166,8 @@ export const detectRapidFirePatterns = zq({
|
|
|
1096
1166
|
}
|
|
1097
1167
|
}
|
|
1098
1168
|
|
|
1099
|
-
const actorGroups = new Map<string, typeof
|
|
1100
|
-
for (const op of
|
|
1169
|
+
const actorGroups = new Map<string, typeof sortedOps>();
|
|
1170
|
+
for (const op of sortedOps) {
|
|
1101
1171
|
if (op.actorId) {
|
|
1102
1172
|
if (!actorGroups.has(op.actorId)) {
|
|
1103
1173
|
actorGroups.set(op.actorId, []);
|
|
@@ -1135,7 +1205,10 @@ export const detectRapidFirePatterns = zq({
|
|
|
1135
1205
|
|
|
1136
1206
|
/**
|
|
1137
1207
|
* Rate limiting: Check if an email can be sent to a recipient
|
|
1138
|
-
* Based on recent email operations in the database
|
|
1208
|
+
* Based on recent email operations in the database.
|
|
1209
|
+
*
|
|
1210
|
+
* Uses efficient .take() query - only reads the minimum number of documents
|
|
1211
|
+
* needed to determine if the rate limit is exceeded.
|
|
1139
1212
|
*/
|
|
1140
1213
|
export const checkRecipientRateLimit = zq({
|
|
1141
1214
|
args: z.object({
|
|
@@ -1155,15 +1228,17 @@ export const checkRecipientRateLimit = zq({
|
|
|
1155
1228
|
handler: async (ctx, args) => {
|
|
1156
1229
|
const cutoffTime = Date.now() - args.timeWindowMs;
|
|
1157
1230
|
|
|
1231
|
+
// Use the compound index (email, timestamp) to efficiently query
|
|
1232
|
+
// Only fetch up to maxEmails + 1 to check if limit exceeded
|
|
1158
1233
|
const operations = await ctx.db
|
|
1159
1234
|
.query("emailOperations")
|
|
1160
|
-
.withIndex("email", (q) =>
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
(op) => op.timestamp >= cutoffTime && op.success,
|
|
1165
|
-
);
|
|
1235
|
+
.withIndex("email", (q) =>
|
|
1236
|
+
q.eq("email", args.email).gte("timestamp", cutoffTime),
|
|
1237
|
+
)
|
|
1238
|
+
.take(args.maxEmails + 1);
|
|
1166
1239
|
|
|
1240
|
+
// Filter for successful operations only
|
|
1241
|
+
const recentOps = operations.filter((op) => op.success);
|
|
1167
1242
|
const count = recentOps.length;
|
|
1168
1243
|
const allowed = count < args.maxEmails;
|
|
1169
1244
|
|
|
@@ -1188,7 +1263,10 @@ export const checkRecipientRateLimit = zq({
|
|
|
1188
1263
|
|
|
1189
1264
|
/**
|
|
1190
1265
|
* Rate limiting: Check if an actor/user can send more emails
|
|
1191
|
-
* Based on recent email operations in the database
|
|
1266
|
+
* Based on recent email operations in the database.
|
|
1267
|
+
*
|
|
1268
|
+
* Uses efficient .take() query - only reads the minimum number of documents
|
|
1269
|
+
* needed to determine if the rate limit is exceeded.
|
|
1192
1270
|
*/
|
|
1193
1271
|
export const checkActorRateLimit = zq({
|
|
1194
1272
|
args: z.object({
|
|
@@ -1206,15 +1284,17 @@ export const checkActorRateLimit = zq({
|
|
|
1206
1284
|
handler: async (ctx, args) => {
|
|
1207
1285
|
const cutoffTime = Date.now() - args.timeWindowMs;
|
|
1208
1286
|
|
|
1287
|
+
// Use the compound index (actorId, timestamp) to efficiently query
|
|
1288
|
+
// Only fetch up to maxEmails + 1 to check if limit exceeded
|
|
1209
1289
|
const operations = await ctx.db
|
|
1210
1290
|
.query("emailOperations")
|
|
1211
|
-
.withIndex("actorId", (q) =>
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
(op) => op.timestamp >= cutoffTime && op.success,
|
|
1216
|
-
);
|
|
1291
|
+
.withIndex("actorId", (q) =>
|
|
1292
|
+
q.eq("actorId", args.actorId).gte("timestamp", cutoffTime),
|
|
1293
|
+
)
|
|
1294
|
+
.take(args.maxEmails + 1);
|
|
1217
1295
|
|
|
1296
|
+
// Filter for successful operations only
|
|
1297
|
+
const recentOps = operations.filter((op) => op.success);
|
|
1218
1298
|
const count = recentOps.length;
|
|
1219
1299
|
const allowed = count < args.maxEmails;
|
|
1220
1300
|
|
|
@@ -1239,7 +1319,10 @@ export const checkActorRateLimit = zq({
|
|
|
1239
1319
|
|
|
1240
1320
|
/**
|
|
1241
1321
|
* Rate limiting: Check global email sending rate
|
|
1242
|
-
* Checks total email operations across all senders
|
|
1322
|
+
* Checks total email operations across all senders.
|
|
1323
|
+
*
|
|
1324
|
+
* Uses efficient .take() query - only reads the minimum number of documents
|
|
1325
|
+
* needed to determine if the rate limit is exceeded.
|
|
1243
1326
|
*/
|
|
1244
1327
|
export const checkGlobalRateLimit = zq({
|
|
1245
1328
|
args: z.object({
|
|
@@ -1255,11 +1338,14 @@ export const checkGlobalRateLimit = zq({
|
|
|
1255
1338
|
handler: async (ctx, args) => {
|
|
1256
1339
|
const cutoffTime = Date.now() - args.timeWindowMs;
|
|
1257
1340
|
|
|
1341
|
+
// Use the timestamp index to efficiently query recent operations
|
|
1342
|
+
// Only fetch up to maxEmails + 1 to check if limit exceeded
|
|
1258
1343
|
const operations = await ctx.db
|
|
1259
1344
|
.query("emailOperations")
|
|
1260
1345
|
.withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
|
|
1261
|
-
.
|
|
1346
|
+
.take(args.maxEmails + 1);
|
|
1262
1347
|
|
|
1348
|
+
// Filter for successful operations only
|
|
1263
1349
|
const recentOps = operations.filter((op) => op.success);
|
|
1264
1350
|
const count = recentOps.length;
|
|
1265
1351
|
const allowed = count < args.maxEmails;
|
|
@@ -1272,3 +1358,54 @@ export const checkGlobalRateLimit = zq({
|
|
|
1272
1358
|
};
|
|
1273
1359
|
},
|
|
1274
1360
|
});
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Backfill the contact aggregate with existing contacts.
|
|
1364
|
+
* Run this mutation after upgrading to a version with aggregate support.
|
|
1365
|
+
*
|
|
1366
|
+
* This processes contacts in batches to avoid timeout issues with large datasets.
|
|
1367
|
+
* Call repeatedly with the returned cursor until isDone is true.
|
|
1368
|
+
*
|
|
1369
|
+
* Usage:
|
|
1370
|
+
* 1. First call with clear: true to reset the aggregate
|
|
1371
|
+
* 2. Subsequent calls with the returned cursor until isDone is true
|
|
1372
|
+
*/
|
|
1373
|
+
export const backfillContactAggregate = zm({
|
|
1374
|
+
args: z.object({
|
|
1375
|
+
cursor: z.string().nullable().optional(),
|
|
1376
|
+
batchSize: z.number().min(1).max(500).default(100),
|
|
1377
|
+
clear: z.boolean().optional(), // Set to true on first call to clear existing aggregate
|
|
1378
|
+
}),
|
|
1379
|
+
returns: z.object({
|
|
1380
|
+
processed: z.number(),
|
|
1381
|
+
cursor: z.string().nullable(),
|
|
1382
|
+
isDone: z.boolean(),
|
|
1383
|
+
}),
|
|
1384
|
+
handler: async (ctx, args) => {
|
|
1385
|
+
// Clear aggregate on first call if requested
|
|
1386
|
+
if (args.clear && !args.cursor) {
|
|
1387
|
+
await aggregateClear(ctx);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
const paginationOpts = {
|
|
1391
|
+
cursor: args.cursor ?? null,
|
|
1392
|
+
numItems: args.batchSize,
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
const result = await paginator(ctx.db, schema)
|
|
1396
|
+
.query("contacts")
|
|
1397
|
+
.order("asc")
|
|
1398
|
+
.paginate(paginationOpts);
|
|
1399
|
+
|
|
1400
|
+
// Insert each contact into the aggregate
|
|
1401
|
+
for (const contact of result.page) {
|
|
1402
|
+
await aggregateInsert(ctx, contact);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
return {
|
|
1406
|
+
processed: result.page.length,
|
|
1407
|
+
cursor: result.continueCursor,
|
|
1408
|
+
isDone: result.isDone,
|
|
1409
|
+
};
|
|
1410
|
+
},
|
|
1411
|
+
});
|