@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.
Files changed (37) hide show
  1. package/dist/client/index.d.ts +47 -12
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +42 -4
  4. package/dist/component/_generated/api.d.ts +185 -1
  5. package/dist/component/_generated/api.d.ts.map +1 -1
  6. package/dist/component/_generated/component.d.ts +12 -5
  7. package/dist/component/_generated/component.d.ts.map +1 -1
  8. package/dist/component/_generated/dataModel.d.ts +1 -1
  9. package/dist/component/aggregates.d.ts +42 -0
  10. package/dist/component/aggregates.d.ts.map +1 -0
  11. package/dist/component/aggregates.js +54 -0
  12. package/dist/component/convex.config.d.ts.map +1 -1
  13. package/dist/component/convex.config.js +2 -0
  14. package/dist/component/helpers.d.ts.map +1 -1
  15. package/dist/component/http.js +1 -1
  16. package/dist/component/lib.d.ts +66 -17
  17. package/dist/component/lib.d.ts.map +1 -1
  18. package/dist/component/lib.js +194 -73
  19. package/dist/component/schema.d.ts +2 -2
  20. package/dist/component/tables/contacts.d.ts.map +1 -1
  21. package/dist/component/tables/emailOperations.d.ts +4 -4
  22. package/dist/component/tables/emailOperations.d.ts.map +1 -1
  23. package/dist/test.d.ts +2 -2
  24. package/dist/types.d.ts +249 -62
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/types.js +4 -2
  27. package/dist/utils.d.ts +6 -6
  28. package/package.json +15 -9
  29. package/src/client/index.ts +52 -6
  30. package/src/component/_generated/api.ts +190 -1
  31. package/src/component/_generated/component.ts +10 -5
  32. package/src/component/_generated/dataModel.ts +1 -1
  33. package/src/component/aggregates.ts +89 -0
  34. package/src/component/convex.config.ts +3 -0
  35. package/src/component/http.ts +1 -1
  36. package/src/component/lib.ts +226 -89
  37. package/src/types.ts +20 -122
@@ -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
- await ctx.db.insert("contacts", {
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
- * Note: When multiple filters are provided, only one index can be used.
122
- * Additional filters are applied in-memory, which is efficient for small result sets.
123
- * For large contact lists with multiple filters, consider using a composite index.
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
- // Build query using the most selective index available
134
- // Priority: userGroup > source > subscribed
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
- .collect();
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
- .collect();
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
- .collect();
206
+ .take(MAX_COUNT_LIMIT);
152
207
  } else {
153
- contacts = await ctx.db.query("contacts").collect();
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 before pagination.
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
- offset: z.number().min(0).default(0),
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
- total: z.number(),
218
- limit: z.number(),
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
- // Build query using the most selective index available
224
- let allContacts: Doc<"contacts">[];
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
- allContacts = await ctx.db
284
+ result = await paginator(ctx.db, schema)
228
285
  .query("contacts")
229
286
  .withIndex("userGroup", (q) => q.eq("userGroup", args.userGroup))
230
- .collect();
287
+ .order("desc")
288
+ .paginate(paginationOpts);
231
289
  } else if (args.source !== undefined) {
232
- allContacts = await ctx.db
290
+ result = await paginator(ctx.db, schema)
233
291
  .query("contacts")
234
292
  .withIndex("source", (q) => q.eq("source", args.source))
235
- .collect();
293
+ .order("desc")
294
+ .paginate(paginationOpts);
236
295
  } else if (args.subscribed !== undefined) {
237
- allContacts = await ctx.db
296
+ result = await paginator(ctx.db, schema)
238
297
  .query("contacts")
239
298
  .withIndex("subscribed", (q) => q.eq("subscribed", args.subscribed))
240
- .collect();
299
+ .order("desc")
300
+ .paginate(paginationOpts);
241
301
  } else {
242
- allContacts = await ctx.db.query("contacts").collect();
302
+ result = await paginator(ctx.db, schema)
303
+ .query("contacts")
304
+ .order("desc")
305
+ .paginate(paginationOpts);
243
306
  }
244
307
 
245
- // Apply additional filters if multiple criteria were provided
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
- allContacts = allContacts.filter((c) => {
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
- // Sort by createdAt (newest first)
268
- allContacts.sort((a, b) => b.createdAt - a.createdAt);
269
-
270
- const total = allContacts.length;
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: paginatedContacts,
281
- total,
282
- limit: args.limit,
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
- .collect();
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
- .collect();
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
- .collect();
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
- .collect();
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 operations>();
1067
- for (const op of operations) {
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 operations>();
1100
- for (const op of operations) {
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) => q.eq("email", args.email))
1161
- .collect();
1162
-
1163
- const recentOps = operations.filter(
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) => q.eq("actorId", args.actorId))
1212
- .collect();
1213
-
1214
- const recentOps = operations.filter(
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
- .collect();
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
+ });