@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,618 @@
1
+ import { actionGeneric, mutationGeneric, queryGeneric } from "convex/server";
2
+ import { v } from "convex/values";
3
+ import type { ComponentApi } from "../component/_generated/component.js";
4
+ import type { RunActionCtx, RunMutationCtx, RunQueryCtx } from "../types";
5
+
6
+ export type LoopsComponent = ComponentApi;
7
+
8
+ export interface ContactData {
9
+ email: string;
10
+ firstName?: string;
11
+ lastName?: string;
12
+ userId?: string;
13
+ source?: string;
14
+ subscribed?: boolean;
15
+ userGroup?: string;
16
+ }
17
+
18
+ export interface TransactionalEmailOptions {
19
+ transactionalId: string;
20
+ email: string;
21
+ dataVariables?: Record<string, unknown>;
22
+ idempotencyKey?: string;
23
+ }
24
+
25
+ export interface EventOptions {
26
+ email: string;
27
+ eventName: string;
28
+ eventProperties?: Record<string, unknown>;
29
+ }
30
+
31
+ export class Loops {
32
+ public readonly options?: {
33
+ apiKey?: string;
34
+ };
35
+ private readonly actions: NonNullable<LoopsComponent["actions"]>;
36
+ private readonly queries: NonNullable<LoopsComponent["queries"]>;
37
+ private readonly mutations: NonNullable<LoopsComponent["mutations"]>;
38
+ private _apiKey?: string;
39
+
40
+ constructor(
41
+ component: LoopsComponent,
42
+ options?: {
43
+ apiKey?: string;
44
+ },
45
+ ) {
46
+ if (!component) {
47
+ throw new Error(
48
+ "Loops component reference is required. " +
49
+ "Make sure the component is mounted in your convex.config.ts and use: " +
50
+ "new Loops(components.loops)",
51
+ );
52
+ }
53
+
54
+ if (!component.actions || !component.queries || !component.mutations) {
55
+ throw new Error(
56
+ "Invalid component reference. " +
57
+ "The component may not be properly mounted. " +
58
+ "Ensure the component is correctly mounted in convex.config.ts: " +
59
+ "app.use(loops);",
60
+ );
61
+ }
62
+
63
+ this.actions = component.actions;
64
+ this.queries = component.queries;
65
+ this.mutations = component.mutations;
66
+ this.options = options;
67
+ this._apiKey = options?.apiKey;
68
+
69
+ if (options?.apiKey) {
70
+ console.warn(
71
+ "API key passed directly via options. " +
72
+ "For security, use LOOPS_API_KEY environment variable instead. " +
73
+ "See README.md for details.",
74
+ );
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Get the API key, checking environment at call time (not constructor time).
80
+ * This allows the Loops client to be instantiated at module load time.
81
+ */
82
+ private get apiKey(): string {
83
+ const key = this._apiKey ?? process.env.LOOPS_API_KEY;
84
+ if (!key) {
85
+ throw new Error(
86
+ "Loops API key is required. Set LOOPS_API_KEY in your Convex environment variables.",
87
+ );
88
+ }
89
+ return key;
90
+ }
91
+
92
+ /**
93
+ * Add or update a contact in Loops
94
+ */
95
+ async addContact(ctx: RunActionCtx, contact: ContactData) {
96
+ return ctx.runAction(this.actions.addContact, {
97
+ apiKey: this.apiKey,
98
+ contact,
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Update an existing contact in Loops
104
+ */
105
+ async updateContact(
106
+ ctx: RunActionCtx,
107
+ email: string,
108
+ updates: Partial<ContactData> & {
109
+ dataVariables?: Record<string, unknown>;
110
+ },
111
+ ) {
112
+ return ctx.runAction(this.actions.updateContact, {
113
+ apiKey: this.apiKey,
114
+ email,
115
+ ...updates,
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Send a transactional email using a transactional ID
121
+ */
122
+ async sendTransactional(
123
+ ctx: RunActionCtx,
124
+ options: TransactionalEmailOptions,
125
+ ) {
126
+ return ctx.runAction(this.actions.sendTransactional, {
127
+ apiKey: this.apiKey,
128
+ ...options,
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Send an event to Loops to trigger email workflows
134
+ */
135
+ async sendEvent(ctx: RunActionCtx, options: EventOptions) {
136
+ return ctx.runAction(this.actions.sendEvent, {
137
+ apiKey: this.apiKey,
138
+ ...options,
139
+ });
140
+ }
141
+
142
+ /**
143
+ * Find a contact by email
144
+ * Retrieves contact information from Loops
145
+ */
146
+ async findContact(ctx: RunActionCtx, email: string) {
147
+ return ctx.runAction(this.actions.findContact, {
148
+ apiKey: this.apiKey,
149
+ email,
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Batch create contacts
155
+ * Create multiple contacts in a single API call
156
+ */
157
+ async batchCreateContacts(ctx: RunActionCtx, contacts: ContactData[]) {
158
+ return ctx.runAction(this.actions.batchCreateContacts, {
159
+ apiKey: this.apiKey,
160
+ contacts,
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Unsubscribe a contact
166
+ * Unsubscribes a contact from receiving emails (they remain in the system)
167
+ */
168
+ async unsubscribeContact(ctx: RunActionCtx, email: string) {
169
+ return ctx.runAction(this.actions.unsubscribeContact, {
170
+ apiKey: this.apiKey,
171
+ email,
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Resubscribe a contact
177
+ * Resubscribes a previously unsubscribed contact
178
+ */
179
+ async resubscribeContact(ctx: RunActionCtx, email: string) {
180
+ return ctx.runAction(this.actions.resubscribeContact, {
181
+ apiKey: this.apiKey,
182
+ email,
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Count contacts in the database
188
+ * Can filter by audience criteria (userGroup, source, subscribed status)
189
+ * This queries the component's local database, not Loops API
190
+ */
191
+ async countContacts(
192
+ ctx: RunQueryCtx,
193
+ options?: {
194
+ userGroup?: string;
195
+ source?: string;
196
+ subscribed?: boolean;
197
+ },
198
+ ) {
199
+ return ctx.runQuery(this.queries.countContacts, options ?? {});
200
+ }
201
+
202
+ /**
203
+ * List contacts with cursor-based pagination and optional filters
204
+ * Returns actual contact data, not just a count
205
+ * This queries the component's local database, not Loops API
206
+ *
207
+ * Uses cursor-based pagination for efficiency. Pass the `continueCursor`
208
+ * from the previous response as `cursor` to get the next page.
209
+ */
210
+ async listContacts(
211
+ ctx: RunQueryCtx,
212
+ options?: {
213
+ userGroup?: string;
214
+ source?: string;
215
+ subscribed?: boolean;
216
+ limit?: number;
217
+ cursor?: string | null;
218
+ },
219
+ ) {
220
+ return ctx.runQuery(this.queries.listContacts, {
221
+ userGroup: options?.userGroup,
222
+ source: options?.source,
223
+ subscribed: options?.subscribed,
224
+ limit: options?.limit ?? 100,
225
+ cursor: options?.cursor ?? null,
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Detect spam patterns: emails sent to the same recipient too frequently
231
+ */
232
+ async detectRecipientSpam(
233
+ ctx: RunQueryCtx,
234
+ options?: {
235
+ timeWindowMs?: number;
236
+ maxEmailsPerRecipient?: number;
237
+ },
238
+ ) {
239
+ return ctx.runQuery(this.queries.detectRecipientSpam, {
240
+ timeWindowMs: options?.timeWindowMs ?? 3600000,
241
+ maxEmailsPerRecipient: options?.maxEmailsPerRecipient ?? 10,
242
+ });
243
+ }
244
+
245
+ /**
246
+ * Detect spam patterns: emails sent by the same actor/user too frequently
247
+ */
248
+ async detectActorSpam(
249
+ ctx: RunQueryCtx,
250
+ options?: {
251
+ timeWindowMs?: number;
252
+ maxEmailsPerActor?: number;
253
+ },
254
+ ) {
255
+ return ctx.runQuery(this.queries.detectActorSpam, {
256
+ timeWindowMs: options?.timeWindowMs ?? 3600000,
257
+ maxEmailsPerActor: options?.maxEmailsPerActor ?? 100,
258
+ });
259
+ }
260
+
261
+ /**
262
+ * Get email operation statistics for monitoring
263
+ */
264
+ async getEmailStats(
265
+ ctx: RunQueryCtx,
266
+ options?: {
267
+ timeWindowMs?: number;
268
+ },
269
+ ) {
270
+ return ctx.runQuery(this.queries.getEmailStats, {
271
+ timeWindowMs: options?.timeWindowMs ?? 86400000,
272
+ });
273
+ }
274
+
275
+ /**
276
+ * Detect rapid-fire email sending patterns
277
+ */
278
+ async detectRapidFirePatterns(
279
+ ctx: RunQueryCtx,
280
+ options?: {
281
+ timeWindowMs?: number;
282
+ minEmailsInWindow?: number;
283
+ },
284
+ ) {
285
+ return ctx.runQuery(this.queries.detectRapidFirePatterns, {
286
+ timeWindowMs: options?.timeWindowMs ?? 60000,
287
+ minEmailsInWindow: options?.minEmailsInWindow ?? 5,
288
+ });
289
+ }
290
+
291
+ /**
292
+ * Check if an email can be sent to a recipient based on rate limits
293
+ */
294
+ async checkRecipientRateLimit(
295
+ ctx: RunQueryCtx,
296
+ options: {
297
+ email: string;
298
+ timeWindowMs: number;
299
+ maxEmails: number;
300
+ },
301
+ ) {
302
+ return ctx.runQuery(this.queries.checkRecipientRateLimit, options);
303
+ }
304
+
305
+ /**
306
+ * Check if an actor/user can send more emails based on rate limits
307
+ */
308
+ async checkActorRateLimit(
309
+ ctx: RunQueryCtx,
310
+ options: {
311
+ actorId: string;
312
+ timeWindowMs: number;
313
+ maxEmails: number;
314
+ },
315
+ ) {
316
+ return ctx.runQuery(this.queries.checkActorRateLimit, options);
317
+ }
318
+
319
+ /**
320
+ * Check global email sending rate limit
321
+ */
322
+ async checkGlobalRateLimit(
323
+ ctx: RunQueryCtx,
324
+ options: {
325
+ timeWindowMs: number;
326
+ maxEmails: number;
327
+ },
328
+ ) {
329
+ return ctx.runQuery(this.queries.checkGlobalRateLimit, options);
330
+ }
331
+
332
+ /**
333
+ * Delete a contact from Loops
334
+ */
335
+ async deleteContact(ctx: RunActionCtx, email: string) {
336
+ return ctx.runAction(this.actions.deleteContact, {
337
+ apiKey: this.apiKey,
338
+ email,
339
+ });
340
+ }
341
+
342
+ /**
343
+ * Trigger a loop for a contact
344
+ * Loops are automated email sequences that can be triggered by events
345
+ *
346
+ * Note: Loops.so doesn't have a direct loop trigger endpoint.
347
+ * Loops are triggered through events. Make sure your loop is configured
348
+ * in the Loops dashboard to listen for events.
349
+ *
350
+ * @param options.eventName - Optional event name. If not provided, uses `loop_{loopId}`
351
+ */
352
+ async triggerLoop(
353
+ ctx: RunActionCtx,
354
+ options: {
355
+ loopId: string;
356
+ email: string;
357
+ dataVariables?: Record<string, unknown>;
358
+ eventName?: string; // Event name that triggers the loop
359
+ },
360
+ ) {
361
+ return ctx.runAction(this.actions.triggerLoop, {
362
+ apiKey: this.apiKey,
363
+ ...options,
364
+ });
365
+ }
366
+
367
+ /**
368
+ * Backfill the contact aggregate with existing contacts.
369
+ * Run this after upgrading to a version with aggregate support.
370
+ *
371
+ * This processes contacts in batches to avoid timeout issues with large datasets.
372
+ * Call repeatedly with the returned cursor until isDone is true.
373
+ *
374
+ * Usage:
375
+ * ```ts
376
+ * // First call - clear existing aggregate and start backfill
377
+ * let result = await loops.backfillContactAggregate(ctx, { clear: true });
378
+ *
379
+ * // Continue until done
380
+ * while (!result.isDone) {
381
+ * result = await loops.backfillContactAggregate(ctx, { cursor: result.cursor });
382
+ * }
383
+ * ```
384
+ */
385
+ async backfillContactAggregate(
386
+ ctx: RunMutationCtx,
387
+ options?: {
388
+ cursor?: string | null;
389
+ batchSize?: number;
390
+ clear?: boolean;
391
+ },
392
+ ) {
393
+ return ctx.runMutation(this.mutations.backfillContactAggregate, {
394
+ cursor: options?.cursor ?? null,
395
+ batchSize: options?.batchSize ?? 100,
396
+ clear: options?.clear,
397
+ });
398
+ }
399
+
400
+ /**
401
+ * For easy re-exporting.
402
+ * Apps can do
403
+ * ```ts
404
+ * export const { addContact, sendTransactional, sendEvent, triggerLoop } = loops.api();
405
+ * ```
406
+ */
407
+ api() {
408
+ return {
409
+ addContact: actionGeneric({
410
+ args: {
411
+ email: v.string(),
412
+ firstName: v.optional(v.string()),
413
+ lastName: v.optional(v.string()),
414
+ userId: v.optional(v.string()),
415
+ source: v.optional(v.string()),
416
+ subscribed: v.optional(v.boolean()),
417
+ userGroup: v.optional(v.string()),
418
+ },
419
+ handler: async (ctx, args) => {
420
+ return await this.addContact(ctx, args);
421
+ },
422
+ }),
423
+ updateContact: actionGeneric({
424
+ args: {
425
+ email: v.string(),
426
+ firstName: v.optional(v.string()),
427
+ lastName: v.optional(v.string()),
428
+ userId: v.optional(v.string()),
429
+ source: v.optional(v.string()),
430
+ subscribed: v.optional(v.boolean()),
431
+ userGroup: v.optional(v.string()),
432
+ dataVariables: v.optional(v.any()),
433
+ },
434
+ handler: async (ctx, args) => {
435
+ const { email, ...updates } = args;
436
+ return await this.updateContact(ctx, email, updates);
437
+ },
438
+ }),
439
+ sendTransactional: actionGeneric({
440
+ args: {
441
+ transactionalId: v.string(),
442
+ email: v.string(),
443
+ dataVariables: v.optional(v.any()),
444
+ idempotencyKey: v.optional(v.string()),
445
+ },
446
+ handler: async (ctx, args) => {
447
+ return await this.sendTransactional(ctx, args);
448
+ },
449
+ }),
450
+ sendEvent: actionGeneric({
451
+ args: {
452
+ email: v.string(),
453
+ eventName: v.string(),
454
+ eventProperties: v.optional(v.any()),
455
+ },
456
+ handler: async (ctx, args) => {
457
+ return await this.sendEvent(ctx, args);
458
+ },
459
+ }),
460
+ deleteContact: actionGeneric({
461
+ args: {
462
+ email: v.string(),
463
+ },
464
+ handler: async (ctx, args) => {
465
+ return await this.deleteContact(ctx, args.email);
466
+ },
467
+ }),
468
+ triggerLoop: actionGeneric({
469
+ args: {
470
+ loopId: v.string(),
471
+ email: v.string(),
472
+ dataVariables: v.optional(v.any()),
473
+ },
474
+ handler: async (ctx, args) => {
475
+ return await this.triggerLoop(ctx, args);
476
+ },
477
+ }),
478
+ findContact: actionGeneric({
479
+ args: {
480
+ email: v.string(),
481
+ },
482
+ handler: async (ctx, args) => {
483
+ return await this.findContact(ctx, args.email);
484
+ },
485
+ }),
486
+ batchCreateContacts: actionGeneric({
487
+ args: {
488
+ contacts: v.array(
489
+ v.object({
490
+ email: v.string(),
491
+ firstName: v.optional(v.string()),
492
+ lastName: v.optional(v.string()),
493
+ userId: v.optional(v.string()),
494
+ source: v.optional(v.string()),
495
+ subscribed: v.optional(v.boolean()),
496
+ userGroup: v.optional(v.string()),
497
+ }),
498
+ ),
499
+ },
500
+ handler: async (ctx, args) => {
501
+ return await this.batchCreateContacts(ctx, args.contacts);
502
+ },
503
+ }),
504
+ unsubscribeContact: actionGeneric({
505
+ args: {
506
+ email: v.string(),
507
+ },
508
+ handler: async (ctx, args) => {
509
+ return await this.unsubscribeContact(ctx, args.email);
510
+ },
511
+ }),
512
+ resubscribeContact: actionGeneric({
513
+ args: {
514
+ email: v.string(),
515
+ },
516
+ handler: async (ctx, args) => {
517
+ return await this.resubscribeContact(ctx, args.email);
518
+ },
519
+ }),
520
+ countContacts: queryGeneric({
521
+ args: {
522
+ userGroup: v.optional(v.string()),
523
+ source: v.optional(v.string()),
524
+ subscribed: v.optional(v.boolean()),
525
+ },
526
+ handler: async (ctx, args) => {
527
+ return await this.countContacts(ctx, args);
528
+ },
529
+ }),
530
+ listContacts: queryGeneric({
531
+ args: {
532
+ userGroup: v.optional(v.string()),
533
+ source: v.optional(v.string()),
534
+ subscribed: v.optional(v.boolean()),
535
+ limit: v.optional(v.number()),
536
+ cursor: v.optional(v.union(v.string(), v.null())),
537
+ },
538
+ handler: async (ctx, args) => {
539
+ return await this.listContacts(ctx, args);
540
+ },
541
+ }),
542
+ detectRecipientSpam: queryGeneric({
543
+ args: {
544
+ timeWindowMs: v.optional(v.number()),
545
+ maxEmailsPerRecipient: v.optional(v.number()),
546
+ },
547
+ handler: async (ctx, args) => {
548
+ return await this.detectRecipientSpam(ctx, args);
549
+ },
550
+ }),
551
+ detectActorSpam: queryGeneric({
552
+ args: {
553
+ timeWindowMs: v.optional(v.number()),
554
+ maxEmailsPerActor: v.optional(v.number()),
555
+ },
556
+ handler: async (ctx, args) => {
557
+ return await this.detectActorSpam(ctx, args);
558
+ },
559
+ }),
560
+ getEmailStats: queryGeneric({
561
+ args: {
562
+ timeWindowMs: v.optional(v.number()),
563
+ },
564
+ handler: async (ctx, args) => {
565
+ return await this.getEmailStats(ctx, args);
566
+ },
567
+ }),
568
+ detectRapidFirePatterns: queryGeneric({
569
+ args: {
570
+ timeWindowMs: v.optional(v.number()),
571
+ minEmailsInWindow: v.optional(v.number()),
572
+ },
573
+ handler: async (ctx, args) => {
574
+ return await this.detectRapidFirePatterns(ctx, args);
575
+ },
576
+ }),
577
+ checkRecipientRateLimit: queryGeneric({
578
+ args: {
579
+ email: v.string(),
580
+ timeWindowMs: v.number(),
581
+ maxEmails: v.number(),
582
+ },
583
+ handler: async (ctx, args) => {
584
+ return await this.checkRecipientRateLimit(ctx, args);
585
+ },
586
+ }),
587
+ checkActorRateLimit: queryGeneric({
588
+ args: {
589
+ actorId: v.string(),
590
+ timeWindowMs: v.number(),
591
+ maxEmails: v.number(),
592
+ },
593
+ handler: async (ctx, args) => {
594
+ return await this.checkActorRateLimit(ctx, args);
595
+ },
596
+ }),
597
+ checkGlobalRateLimit: queryGeneric({
598
+ args: {
599
+ timeWindowMs: v.number(),
600
+ maxEmails: v.number(),
601
+ },
602
+ handler: async (ctx, args) => {
603
+ return await this.checkGlobalRateLimit(ctx, args);
604
+ },
605
+ }),
606
+ backfillContactAggregate: mutationGeneric({
607
+ args: {
608
+ cursor: v.optional(v.union(v.string(), v.null())),
609
+ batchSize: v.optional(v.number()),
610
+ clear: v.optional(v.boolean()),
611
+ },
612
+ handler: async (ctx, args) => {
613
+ return await this.backfillContactAggregate(ctx, args);
614
+ },
615
+ }),
616
+ };
617
+ }
618
+ }