convex-batch-processor 1.0.4 → 1.0.6

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.
@@ -32,7 +32,7 @@ export const addItems = mutation({
32
32
  ? batchId.split("::")[0]
33
33
  : batchId;
34
34
 
35
- // Find an accumulating batch for this base ID
35
+ // 1. Find accumulating batch (READ only)
36
36
  let batch = await ctx.db
37
37
  .query("batches")
38
38
  .withIndex("by_baseBatchId_status", (q) =>
@@ -40,14 +40,12 @@ export const addItems = mutation({
40
40
  )
41
41
  .first();
42
42
 
43
- let isNewBatch = false;
43
+ // 2. If no batch, create one WITH timer (one-time INSERT)
44
44
  if (!batch) {
45
- isNewBatch = true;
46
-
47
45
  // Find highest sequence number for this base ID
48
46
  const latestBatch = await ctx.db
49
47
  .query("batches")
50
- .withIndex("by_baseBatchId_status", (q) => q.eq("baseBatchId", baseBatchId))
48
+ .withIndex("by_baseBatchId_sequence", (q) => q.eq("baseBatchId", baseBatchId))
51
49
  .order("desc")
52
50
  .first();
53
51
 
@@ -58,20 +56,25 @@ export const addItems = mutation({
58
56
  batchId: newBatchId,
59
57
  baseBatchId,
60
58
  sequence: nextSequence,
61
- itemCount: 0,
62
59
  createdAt: now,
63
60
  lastUpdatedAt: now,
64
61
  status: "accumulating",
65
62
  config,
66
63
  });
67
- batch = await ctx.db.get(batchDocId);
68
- }
64
+ batch = (await ctx.db.get(batchDocId))!;
69
65
 
70
- if (!batch) {
71
- throw new Error(`Failed to create batch for ${baseBatchId}`);
66
+ // Schedule timer at creation (not on every add)
67
+ if (config.flushIntervalMs > 0) {
68
+ const scheduledFlushId = await ctx.scheduler.runAfter(
69
+ config.flushIntervalMs,
70
+ internal.lib.scheduledIntervalFlush,
71
+ { batchDocId: batch._id }
72
+ );
73
+ await ctx.db.patch(batch._id, { scheduledFlushId });
74
+ }
72
75
  }
73
76
 
74
- // INSERT into batchItems table (append-only, no read required)
77
+ // 3. INSERT items (NEVER conflicts - always a new document)
75
78
  await ctx.db.insert("batchItems", {
76
79
  batchDocId: batch._id,
77
80
  items,
@@ -79,59 +82,29 @@ export const addItems = mutation({
79
82
  createdAt: now,
80
83
  });
81
84
 
82
- const newItemCount = batch.itemCount + items.length;
83
-
84
- if (newItemCount >= config.maxBatchSize) {
85
- if (batch.scheduledFlushId) {
86
- await ctx.scheduler.cancel(batch.scheduledFlushId);
87
- }
88
-
89
- await ctx.db.patch(batch._id, {
90
- itemCount: newItemCount,
91
- lastUpdatedAt: now,
92
- status: "flushing",
93
- flushStartedAt: now,
94
- scheduledFlushId: undefined,
95
- });
96
-
97
- await ctx.scheduler.runAfter(0, internal.lib.executeFlush, {
85
+ // 4. Schedule flush check ONLY if this single call could complete a batch
86
+ // DO NOT query batchItems to count - that causes OCC conflicts when
87
+ // multiple concurrent addItems all read the same index.
88
+ //
89
+ // Dual-trigger pattern:
90
+ // - SIZE trigger: items.length >= maxBatchSize (handled here)
91
+ // - TIME trigger: flushIntervalMs timer (scheduled at batch creation)
92
+ //
93
+ // For high-throughput small items, the interval timer handles flushing.
94
+ // For large single calls, we trigger immediate flush check.
95
+ if (items.length >= config.maxBatchSize) {
96
+ await ctx.scheduler.runAfter(0, internal.lib.maybeFlush, {
98
97
  batchDocId: batch._id,
99
- processBatchHandle: config.processBatchHandle,
100
98
  });
101
-
102
- return {
103
- batchId: baseBatchId,
104
- itemCount: newItemCount,
105
- flushed: true,
106
- status: "flushing",
107
- };
108
- }
109
-
110
- let scheduledFlushId = batch.scheduledFlushId;
111
- const shouldScheduleFlush =
112
- config.flushIntervalMs > 0 &&
113
- !scheduledFlushId &&
114
- (isNewBatch || batch.itemCount === 0);
115
-
116
- if (shouldScheduleFlush) {
117
- scheduledFlushId = await ctx.scheduler.runAfter(
118
- config.flushIntervalMs,
119
- internal.lib.scheduledIntervalFlush,
120
- { batchDocId: batch._id }
121
- );
122
99
  }
123
100
 
124
- await ctx.db.patch(batch._id, {
125
- itemCount: newItemCount,
126
- lastUpdatedAt: now,
127
- config,
128
- scheduledFlushId,
129
- });
130
-
101
+ // 5. Return success - NO PATCH, NO COUNT QUERY!
102
+ // We return the count of items added in THIS call only.
103
+ // Total count can be obtained via getBatchStatus query if needed.
131
104
  return {
132
105
  batchId: baseBatchId,
133
- itemCount: newItemCount,
134
- flushed: false,
106
+ itemCount: items.length,
107
+ flushed: false, // Flush happens via interval timer or large batch detection
135
108
  status: "accumulating",
136
109
  };
137
110
  },
@@ -140,8 +113,6 @@ export const addItems = mutation({
140
113
  export const flushBatch = mutation({
141
114
  args: { batchId: v.string() },
142
115
  handler: async (ctx, { batchId }) => {
143
- const now = Date.now();
144
-
145
116
  // First try exact match (for full batch IDs like "base::0")
146
117
  let batch = await ctx.db
147
118
  .query("batches")
@@ -166,7 +137,14 @@ export const flushBatch = mutation({
166
137
  throw new Error(`Batch ${batch.baseBatchId} is not in accumulating state (current: ${batch.status})`);
167
138
  }
168
139
 
169
- if (batch.itemCount === 0) {
140
+ // Compute item count from batchItems
141
+ const batchItemDocs = await ctx.db
142
+ .query("batchItems")
143
+ .withIndex("by_batchDocId", (q) => q.eq("batchDocId", batch._id))
144
+ .collect();
145
+ const itemCount = batchItemDocs.reduce((sum, doc) => sum + doc.itemCount, 0);
146
+
147
+ if (itemCount === 0) {
170
148
  return { batchId, itemCount: 0, flushed: false, reason: "Batch is empty" };
171
149
  }
172
150
 
@@ -174,25 +152,17 @@ export const flushBatch = mutation({
174
152
  throw new Error(`Batch ${batchId} has no processBatchHandle configured`);
175
153
  }
176
154
 
177
- if (batch.scheduledFlushId) {
178
- await ctx.scheduler.cancel(batch.scheduledFlushId);
179
- }
180
-
181
- await ctx.db.patch(batch._id, {
182
- status: "flushing",
183
- flushStartedAt: now,
184
- scheduledFlushId: undefined,
185
- });
186
-
187
- await ctx.scheduler.runAfter(0, internal.lib.executeFlush, {
155
+ // Schedule maybeFlush to handle the transition (avoids OCC in user-facing mutation)
156
+ // Pass force: true to bypass threshold check for manual flushes
157
+ await ctx.scheduler.runAfter(0, internal.lib.maybeFlush, {
188
158
  batchDocId: batch._id,
189
- processBatchHandle: batch.config.processBatchHandle,
159
+ force: true,
190
160
  });
191
161
 
192
162
  return {
193
163
  batchId,
194
- itemCount: batch.itemCount,
195
- flushed: true,
164
+ itemCount,
165
+ flushed: true, // Will be flushed by maybeFlush
196
166
  status: "flushing",
197
167
  };
198
168
  },
@@ -230,14 +200,30 @@ export const getBatchStatus = query({
230
200
  // Use config from any batch (they should all have the same config)
231
201
  const config = activeBatches[0].config;
232
202
 
203
+ // Compute itemCount and lastUpdatedAt from batchItems for each batch
204
+ const batchesWithCounts = await Promise.all(
205
+ activeBatches.map(async (batch) => {
206
+ const batchItemDocs = await ctx.db
207
+ .query("batchItems")
208
+ .withIndex("by_batchDocId", (q) => q.eq("batchDocId", batch._id))
209
+ .collect();
210
+ const itemCount = batchItemDocs.reduce((sum, doc) => sum + doc.itemCount, 0);
211
+ // Compute lastUpdatedAt as max of batchItems.createdAt, or fall back to batch.lastUpdatedAt
212
+ const lastUpdatedAt = batchItemDocs.length > 0
213
+ ? Math.max(...batchItemDocs.map((doc) => doc.createdAt))
214
+ : batch.lastUpdatedAt;
215
+ return {
216
+ status: batch.status as "accumulating" | "flushing",
217
+ itemCount,
218
+ createdAt: batch.createdAt,
219
+ lastUpdatedAt,
220
+ };
221
+ })
222
+ );
223
+
233
224
  return {
234
225
  batchId: baseBatchId,
235
- batches: activeBatches.map((batch) => ({
236
- status: batch.status as "accumulating" | "flushing",
237
- itemCount: batch.itemCount,
238
- createdAt: batch.createdAt,
239
- lastUpdatedAt: batch.lastUpdatedAt,
240
- })),
226
+ batches: batchesWithCounts,
241
227
  config: {
242
228
  maxBatchSize: config.maxBatchSize,
243
229
  flushIntervalMs: config.flushIntervalMs,
@@ -254,15 +240,30 @@ export const getAllBatchesForBaseId = query({
254
240
  .withIndex("by_baseBatchId_status", (q) => q.eq("baseBatchId", baseBatchId))
255
241
  .collect();
256
242
 
257
- return batches.map((batch) => ({
258
- batchId: batch.batchId,
259
- baseBatchId: batch.baseBatchId,
260
- sequence: batch.sequence,
261
- itemCount: batch.itemCount,
262
- status: batch.status,
263
- createdAt: batch.createdAt,
264
- lastUpdatedAt: batch.lastUpdatedAt,
265
- }));
243
+ // Compute itemCount and lastUpdatedAt from batchItems for each batch
244
+ return Promise.all(
245
+ batches.map(async (batch) => {
246
+ const batchItemDocs = await ctx.db
247
+ .query("batchItems")
248
+ .withIndex("by_batchDocId", (q) => q.eq("batchDocId", batch._id))
249
+ .collect();
250
+ const itemCount = batchItemDocs.reduce((sum, doc) => sum + doc.itemCount, 0);
251
+ // Compute lastUpdatedAt as max of batchItems.createdAt, or fall back to batch.lastUpdatedAt
252
+ const lastUpdatedAt =
253
+ batchItemDocs.length > 0
254
+ ? Math.max(...batchItemDocs.map((doc) => doc.createdAt))
255
+ : batch.lastUpdatedAt;
256
+ return {
257
+ batchId: batch.batchId,
258
+ baseBatchId: batch.baseBatchId,
259
+ sequence: batch.sequence,
260
+ itemCount,
261
+ status: batch.status,
262
+ createdAt: batch.createdAt,
263
+ lastUpdatedAt,
264
+ };
265
+ })
266
+ );
266
267
  },
267
268
  });
268
269
 
@@ -301,7 +302,14 @@ export const deleteBatch = mutation({
301
302
  return { deleted: false, reason: "Cannot delete batch while flushing" };
302
303
  }
303
304
 
304
- if (batch.status === "accumulating" && batch.itemCount > 0) {
305
+ // Compute item count from batchItems
306
+ const batchItems = await ctx.db
307
+ .query("batchItems")
308
+ .withIndex("by_batchDocId", (q) => q.eq("batchDocId", batch._id))
309
+ .collect();
310
+ const itemCount = batchItems.reduce((sum, doc) => sum + doc.itemCount, 0);
311
+
312
+ if (batch.status === "accumulating" && itemCount > 0) {
305
313
  return { deleted: false, reason: "Cannot delete batch with pending items" };
306
314
  }
307
315
 
@@ -310,10 +318,6 @@ export const deleteBatch = mutation({
310
318
  }
311
319
 
312
320
  // Delete associated batchItems
313
- const batchItems = await ctx.db
314
- .query("batchItems")
315
- .withIndex("by_batchDocId", (q) => q.eq("batchDocId", batch._id))
316
- .collect();
317
321
  for (const item of batchItems) {
318
322
  await ctx.db.delete(item._id);
319
323
  }
@@ -365,6 +369,135 @@ export const collectBatchItems = internalQuery({
365
369
  },
366
370
  });
367
371
 
372
+ // Type for flush transition result
373
+ type FlushTransitionResult =
374
+ | { flushed: true; itemCount: number }
375
+ | { flushed: false; reason: string };
376
+
377
+ /**
378
+ * maybeFlush - Attempts to transition a batch from "accumulating" to "flushing" state.
379
+ *
380
+ * This is an internal action scheduled by addItems (when threshold is reached) or
381
+ * flushBatch (for manual flushes). It serves as a lightweight coordinator that
382
+ * delegates the actual state transition to doFlushTransition (a mutation).
383
+ *
384
+ * ## Why this architecture?
385
+ *
386
+ * 1. **OCC is handled automatically by Convex**: When doFlushTransition (a mutation)
387
+ * encounters an OCC conflict, Convex automatically retries it. We don't need
388
+ * external retry logic like ActionRetrier for database operations.
389
+ *
390
+ * 2. **Race conditions are handled gracefully**: Multiple maybeFlush calls can be
391
+ * scheduled concurrently (e.g., rapid addItems calls all hitting threshold).
392
+ * The first one to execute wins the race and transitions the batch to "flushing".
393
+ * Subsequent calls see status !== "accumulating" and return early with
394
+ * reason: "not_accumulating". This is expected behavior, not an error.
395
+ *
396
+ * 3. **Mutations can't call actions directly**: Convex mutations are deterministic
397
+ * and can't have side effects. To execute the user's processBatchHandle (an action),
398
+ * we need this action layer. The flow is:
399
+ * mutation (addItems) → schedules action (maybeFlush)
400
+ * action (maybeFlush) → calls mutation (doFlushTransition)
401
+ * mutation (doFlushTransition) → schedules action (executeFlush)
402
+ *
403
+ * 4. **Non-blocking for callers**: addItems returns immediately after scheduling
404
+ * maybeFlush. Users don't wait for the flush to complete.
405
+ *
406
+ * ## Failure scenarios
407
+ *
408
+ * - If maybeFlush fails completely (rare), the batch stays in "accumulating" state.
409
+ * The next addItems call that hits threshold will schedule another maybeFlush.
410
+ * - If a scheduled interval flush exists, it will also attempt the transition.
411
+ *
412
+ * @param batchDocId - The batch document ID to potentially flush
413
+ * @param force - If true, flush regardless of threshold (used by manual flush and interval timer)
414
+ */
415
+ export const maybeFlush = internalAction({
416
+ args: {
417
+ batchDocId: v.id("batches"),
418
+ force: v.optional(v.boolean()),
419
+ },
420
+ handler: async (ctx, { batchDocId, force }): Promise<void> => {
421
+ // Call the mutation directly - Convex handles OCC retries automatically.
422
+ // If another maybeFlush already transitioned this batch, the mutation
423
+ // returns { flushed: false, reason: "not_accumulating" } which is fine.
424
+ await ctx.runMutation(internal.lib.doFlushTransition, {
425
+ batchDocId,
426
+ force: force ?? false,
427
+ });
428
+ },
429
+ });
430
+
431
+ /**
432
+ * doFlushTransition - The actual state machine transition from "accumulating" to "flushing".
433
+ *
434
+ * This mutation is the source of truth for batch state transitions. It's designed to be
435
+ * idempotent and race-condition safe:
436
+ *
437
+ * - Returns early if batch is already flushing/completed (another caller won the race)
438
+ * - Returns early if batch is empty or below threshold (unless force=true)
439
+ * - On success, atomically updates status and schedules executeFlush
440
+ *
441
+ * OCC (Optimistic Concurrency Control) note:
442
+ * If two doFlushTransition calls race, Convex detects the conflict when both try to
443
+ * patch the same batch document. One succeeds, the other is auto-retried by Convex.
444
+ * On retry, it sees status="flushing" and returns { flushed: false, reason: "not_accumulating" }.
445
+ */
446
+ export const doFlushTransition = internalMutation({
447
+ args: {
448
+ batchDocId: v.id("batches"),
449
+ force: v.optional(v.boolean()),
450
+ },
451
+ handler: async (ctx, { batchDocId, force }): Promise<FlushTransitionResult> => {
452
+ const batch = await ctx.db.get(batchDocId);
453
+
454
+ // Already flushing or completed? Nothing to do. This handles the race condition
455
+ // where multiple maybeFlush calls are scheduled - the first one wins.
456
+ if (!batch || batch.status !== "accumulating") {
457
+ return { flushed: false, reason: "not_accumulating" };
458
+ }
459
+
460
+ // Check actual count from batchItems
461
+ const batchItemDocs = await ctx.db
462
+ .query("batchItems")
463
+ .withIndex("by_batchDocId", (q) => q.eq("batchDocId", batchDocId))
464
+ .collect();
465
+ const totalCount = batchItemDocs.reduce((sum, doc) => sum + doc.itemCount, 0);
466
+
467
+ // Empty batch? Nothing to flush.
468
+ if (totalCount === 0) {
469
+ return { flushed: false, reason: "empty" };
470
+ }
471
+
472
+ // Not at threshold? Skip only if not forced (interval flush uses force=true).
473
+ if (!force && totalCount < batch.config.maxBatchSize) {
474
+ return { flushed: false, reason: "below_threshold" };
475
+ }
476
+
477
+ // Cancel scheduled timer if exists
478
+ if (batch.scheduledFlushId) {
479
+ await ctx.scheduler.cancel(batch.scheduledFlushId);
480
+ }
481
+
482
+ // Transition to flushing
483
+ const now = Date.now();
484
+ await ctx.db.patch(batchDocId, {
485
+ status: "flushing",
486
+ flushStartedAt: now,
487
+ lastUpdatedAt: now,
488
+ scheduledFlushId: undefined,
489
+ });
490
+
491
+ // Schedule the actual flush
492
+ await ctx.scheduler.runAfter(0, internal.lib.executeFlush, {
493
+ batchDocId,
494
+ processBatchHandle: batch.config.processBatchHandle,
495
+ });
496
+
497
+ return { flushed: true, itemCount: totalCount };
498
+ },
499
+ });
500
+
368
501
  export const executeFlush = internalAction({
369
502
  args: {
370
503
  batchDocId: v.id("batches"),
@@ -450,43 +583,66 @@ export const recordFlushResult = internalMutation({
450
583
  await ctx.db.delete(item._id);
451
584
  }
452
585
 
453
- // Count remaining items (items added during flush)
586
+ // Check for stranded items (added after flushStartedAt)
454
587
  const remainingItems = await ctx.db
455
588
  .query("batchItems")
456
589
  .withIndex("by_batchDocId", (q) => q.eq("batchDocId", batchDocId))
457
590
  .collect();
458
- const remainingItemCount = remainingItems.reduce((sum, item) => sum + item.itemCount, 0);
459
-
460
- await ctx.db.patch(batchDocId, {
461
- status: "completed",
462
- itemCount: remainingItemCount,
463
- flushStartedAt: undefined,
464
- scheduledFlushId: undefined,
465
- });
591
+ const remainingCount = remainingItems.reduce((sum, item) => sum + item.itemCount, 0);
592
+
593
+ if (remainingCount > 0) {
594
+ // Don't complete - keep accumulating for stranded items
595
+ await ctx.db.patch(batchDocId, {
596
+ status: "accumulating",
597
+ flushStartedAt: undefined,
598
+ lastUpdatedAt: Date.now(),
599
+ });
466
600
 
467
- // Clean up old completed batches for the same base ID
468
- // Keep only the most recent completed batch to reduce clutter
469
- const completedBatches = await ctx.db
470
- .query("batches")
471
- .withIndex("by_baseBatchId_status", (q) =>
472
- q.eq("baseBatchId", batch.baseBatchId).eq("status", "completed")
473
- )
474
- .collect();
601
+ // Schedule another maybeFlush if at threshold
602
+ if (remainingCount >= batch.config.maxBatchSize) {
603
+ await ctx.scheduler.runAfter(0, internal.lib.maybeFlush, { batchDocId });
604
+ } else if (batch.config.flushIntervalMs > 0) {
605
+ // Re-schedule interval timer
606
+ const scheduledFlushId = await ctx.scheduler.runAfter(
607
+ batch.config.flushIntervalMs,
608
+ internal.lib.scheduledIntervalFlush,
609
+ { batchDocId }
610
+ );
611
+ await ctx.db.patch(batchDocId, { scheduledFlushId });
612
+ }
613
+ } else {
614
+ // No stranded items - mark completed
615
+ await ctx.db.patch(batchDocId, {
616
+ status: "completed",
617
+ flushStartedAt: undefined,
618
+ lastUpdatedAt: Date.now(),
619
+ });
475
620
 
476
- // Sort by sequence number descending and delete all but the most recent
477
- const sortedCompleted = completedBatches.sort((a, b) => b.sequence - a.sequence);
478
- for (let i = 1; i < sortedCompleted.length; i++) {
479
- // Also delete batchItems for old completed batches
480
- const oldBatchItems = await ctx.db
481
- .query("batchItems")
482
- .withIndex("by_batchDocId", (q) => q.eq("batchDocId", sortedCompleted[i]._id))
621
+ // Clean up old completed batches for the same base ID
622
+ // Keep only the most recent completed batch to reduce clutter
623
+ const completedBatches = await ctx.db
624
+ .query("batches")
625
+ .withIndex("by_baseBatchId_status", (q) =>
626
+ q.eq("baseBatchId", batch.baseBatchId).eq("status", "completed")
627
+ )
483
628
  .collect();
484
- for (const item of oldBatchItems) {
485
- await ctx.db.delete(item._id);
629
+
630
+ // Sort by sequence number descending and delete all but the most recent
631
+ const sortedCompleted = completedBatches.sort((a, b) => b.sequence - a.sequence);
632
+ for (let i = 1; i < sortedCompleted.length; i++) {
633
+ // Also delete batchItems for old completed batches
634
+ const oldBatchItems = await ctx.db
635
+ .query("batchItems")
636
+ .withIndex("by_batchDocId", (q) => q.eq("batchDocId", sortedCompleted[i]._id))
637
+ .collect();
638
+ for (const item of oldBatchItems) {
639
+ await ctx.db.delete(item._id);
640
+ }
641
+ await ctx.db.delete(sortedCompleted[i]._id);
486
642
  }
487
- await ctx.db.delete(sortedCompleted[i]._id);
488
643
  }
489
644
  } else {
645
+ // Failure case - revert to accumulating
490
646
  let scheduledFlushId: typeof batch.scheduledFlushId = undefined;
491
647
  if (batch.config.flushIntervalMs > 0 && batch.config.processBatchHandle) {
492
648
  scheduledFlushId = await ctx.scheduler.runAfter(
@@ -505,50 +661,21 @@ export const recordFlushResult = internalMutation({
505
661
  },
506
662
  });
507
663
 
508
- export const markBatchFlushing = internalMutation({
509
- args: { batchDocId: v.id("batches") },
510
- handler: async (ctx, { batchDocId }) => {
511
- const now = Date.now();
512
- const batch = await ctx.db.get(batchDocId);
513
- if (!batch || batch.status !== "accumulating" || batch.itemCount === 0) {
514
- return null;
515
- }
516
-
517
- await ctx.db.patch(batchDocId, {
518
- status: "flushing",
519
- flushStartedAt: now,
520
- scheduledFlushId: undefined,
521
- });
522
-
523
- return {
524
- processBatchHandle: batch.config.processBatchHandle,
525
- };
526
- },
527
- });
528
-
664
+ /**
665
+ * scheduledIntervalFlush - Timer-triggered flush that runs after flushIntervalMs.
666
+ *
667
+ * Scheduled once when a batch is created (if flushIntervalMs > 0). Uses force=true
668
+ * to flush regardless of whether the batch has reached maxBatchSize threshold.
669
+ * This ensures batches don't sit indefinitely waiting for more items.
670
+ */
529
671
  export const scheduledIntervalFlush = internalAction({
530
672
  args: { batchDocId: v.id("batches") },
531
- handler: async (ctx, { batchDocId }): Promise<{
532
- flushed: boolean;
533
- reason?: string;
534
- success?: boolean;
535
- errorMessage?: string;
536
- durationMs?: number;
537
- }> => {
538
- const batchData: { processBatchHandle: string } | null = await ctx.runMutation(internal.lib.markBatchFlushing, {
539
- batchDocId,
540
- });
541
-
542
- if (!batchData || !batchData.processBatchHandle) {
543
- return { flushed: false, reason: "Batch not ready for flush" };
544
- }
545
-
546
- const result: { success: boolean; errorMessage?: string; durationMs: number } = await ctx.runAction(internal.lib.executeFlush, {
673
+ handler: async (ctx, { batchDocId }): Promise<void> => {
674
+ // Use force=true to flush regardless of threshold
675
+ await ctx.runMutation(internal.lib.doFlushTransition, {
547
676
  batchDocId,
548
- processBatchHandle: batchData.processBatchHandle,
677
+ force: true,
549
678
  });
550
-
551
- return { flushed: true, ...result };
552
679
  },
553
680
  });
554
681
 
@@ -6,7 +6,7 @@ export default defineSchema({
6
6
  batchId: v.string(), // Full ID with sequence: "base::0"
7
7
  baseBatchId: v.string(), // Base ID: "base"
8
8
  sequence: v.number(), // Sequence number: 0, 1, 2...
9
- itemCount: v.number(),
9
+ // itemCount is computed on-demand from batchItems table
10
10
  createdAt: v.number(),
11
11
  lastUpdatedAt: v.number(),
12
12
  status: v.union(v.literal("accumulating"), v.literal("flushing"), v.literal("completed")),
@@ -20,6 +20,7 @@ export default defineSchema({
20
20
  })
21
21
  .index("by_batchId", ["batchId"])
22
22
  .index("by_baseBatchId_status", ["baseBatchId", "status"])
23
+ .index("by_baseBatchId_sequence", ["baseBatchId", "sequence"])
23
24
  .index("by_status", ["status"]),
24
25
 
25
26
  batchItems: defineTable({