@tanstack/electric-db-collection 0.1.29 → 0.1.31

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/src/electric.ts CHANGED
@@ -7,17 +7,15 @@ import {
7
7
  import { Store } from "@tanstack/store"
8
8
  import DebugModule from "debug"
9
9
  import {
10
- ElectricDeleteHandlerMustReturnTxIdError,
11
- ElectricInsertHandlerMustReturnTxIdError,
12
- ElectricUpdateHandlerMustReturnTxIdError,
13
10
  ExpectedNumberInAwaitTxIdError,
11
+ StreamAbortedError,
12
+ TimeoutWaitingForMatchError,
14
13
  TimeoutWaitingForTxIdError,
15
14
  } from "./errors"
16
15
  import type {
17
16
  BaseCollectionConfig,
18
17
  CollectionConfig,
19
18
  DeleteMutationFnParams,
20
- Fn,
21
19
  InsertMutationFnParams,
22
20
  SyncConfig,
23
21
  UpdateMutationFnParams,
@@ -33,6 +31,9 @@ import type {
33
31
  ShapeStreamOptions,
34
32
  } from "@electric-sql/client"
35
33
 
34
+ // Re-export for user convenience in custom match functions
35
+ export { isChangeMessage, isControlMessage } from "@electric-sql/client"
36
+
36
37
  const debug = DebugModule.debug(`ts/db:electric`)
37
38
 
38
39
  /**
@@ -41,14 +42,20 @@ const debug = DebugModule.debug(`ts/db:electric`)
41
42
  export type Txid = number
42
43
 
43
44
  /**
44
- * Type representing the result of an insert, update, or delete handler
45
+ * Custom match function type - receives stream messages and returns boolean
46
+ * indicating if the mutation has been synchronized
45
47
  */
46
- type MaybeTxId =
47
- | {
48
- txid?: Txid | Array<Txid>
49
- }
50
- | undefined
51
- | null
48
+ export type MatchFunction<T extends Row<unknown>> = (
49
+ message: Message<T>
50
+ ) => boolean
51
+
52
+ /**
53
+ * Matching strategies for Electric synchronization
54
+ * Handlers can return:
55
+ * - Txid strategy: { txid: number | number[] } (recommended)
56
+ * - Void (no return value) - mutation completes without waiting
57
+ */
58
+ export type MatchingStrategy = { txid: Txid | Array<Txid> } | void
52
59
 
53
60
  /**
54
61
  * Type representing a snapshot end message
@@ -56,7 +63,6 @@ type MaybeTxId =
56
63
  type SnapshotEndMessage = ControlMessage & {
57
64
  headers: { control: `snapshot-end` }
58
65
  }
59
-
60
66
  // The `InferSchemaOutput` and `ResolveType` are copied from the `@tanstack/db` package
61
67
  // but we modified `InferSchemaOutput` slightly to restrict the schema output to `Row<unknown>`
62
68
  // This is needed in order for `GetExtensions` to be able to infer the parser extensions type from the schema
@@ -74,17 +80,109 @@ type InferSchemaOutput<T> = T extends StandardSchemaV1
74
80
  export interface ElectricCollectionConfig<
75
81
  T extends Row<unknown> = Row<unknown>,
76
82
  TSchema extends StandardSchemaV1 = never,
77
- > extends BaseCollectionConfig<
78
- T,
79
- string | number,
80
- TSchema,
81
- Record<string, Fn>,
82
- { txid: Txid | Array<Txid> }
83
+ > extends Omit<
84
+ BaseCollectionConfig<T, string | number, TSchema, UtilsRecord, any>,
85
+ `onInsert` | `onUpdate` | `onDelete`
83
86
  > {
84
87
  /**
85
88
  * Configuration options for the ElectricSQL ShapeStream
86
89
  */
87
90
  shapeOptions: ShapeStreamOptions<GetExtensions<T>>
91
+
92
+ /**
93
+ * Optional asynchronous handler function called before an insert operation
94
+ * @param params Object containing transaction and collection information
95
+ * @returns Promise resolving to { txid } or void
96
+ * @example
97
+ * // Basic Electric insert handler with txid (recommended)
98
+ * onInsert: async ({ transaction }) => {
99
+ * const newItem = transaction.mutations[0].modified
100
+ * const result = await api.todos.create({
101
+ * data: newItem
102
+ * })
103
+ * return { txid: result.txid }
104
+ * }
105
+ *
106
+ * @example
107
+ * // Insert handler with multiple items - return array of txids
108
+ * onInsert: async ({ transaction }) => {
109
+ * const items = transaction.mutations.map(m => m.modified)
110
+ * const results = await Promise.all(
111
+ * items.map(item => api.todos.create({ data: item }))
112
+ * )
113
+ * return { txid: results.map(r => r.txid) }
114
+ * }
115
+ *
116
+ * @example
117
+ * // Use awaitMatch utility for custom matching
118
+ * onInsert: async ({ transaction, collection }) => {
119
+ * const newItem = transaction.mutations[0].modified
120
+ * await api.todos.create({ data: newItem })
121
+ * await collection.utils.awaitMatch(
122
+ * (message) => isChangeMessage(message) &&
123
+ * message.headers.operation === 'insert' &&
124
+ * message.value.name === newItem.name
125
+ * )
126
+ * }
127
+ */
128
+ onInsert?: (params: InsertMutationFnParams<T>) => Promise<MatchingStrategy>
129
+
130
+ /**
131
+ * Optional asynchronous handler function called before an update operation
132
+ * @param params Object containing transaction and collection information
133
+ * @returns Promise resolving to { txid } or void
134
+ * @example
135
+ * // Basic Electric update handler with txid (recommended)
136
+ * onUpdate: async ({ transaction }) => {
137
+ * const { original, changes } = transaction.mutations[0]
138
+ * const result = await api.todos.update({
139
+ * where: { id: original.id },
140
+ * data: changes
141
+ * })
142
+ * return { txid: result.txid }
143
+ * }
144
+ *
145
+ * @example
146
+ * // Use awaitMatch utility for custom matching
147
+ * onUpdate: async ({ transaction, collection }) => {
148
+ * const { original, changes } = transaction.mutations[0]
149
+ * await api.todos.update({ where: { id: original.id }, data: changes })
150
+ * await collection.utils.awaitMatch(
151
+ * (message) => isChangeMessage(message) &&
152
+ * message.headers.operation === 'update' &&
153
+ * message.value.id === original.id
154
+ * )
155
+ * }
156
+ */
157
+ onUpdate?: (params: UpdateMutationFnParams<T>) => Promise<MatchingStrategy>
158
+
159
+ /**
160
+ * Optional asynchronous handler function called before a delete operation
161
+ * @param params Object containing transaction and collection information
162
+ * @returns Promise resolving to { txid } or void
163
+ * @example
164
+ * // Basic Electric delete handler with txid (recommended)
165
+ * onDelete: async ({ transaction }) => {
166
+ * const mutation = transaction.mutations[0]
167
+ * const result = await api.todos.delete({
168
+ * id: mutation.original.id
169
+ * })
170
+ * return { txid: result.txid }
171
+ * }
172
+ *
173
+ * @example
174
+ * // Use awaitMatch utility for custom matching
175
+ * onDelete: async ({ transaction, collection }) => {
176
+ * const mutation = transaction.mutations[0]
177
+ * await api.todos.delete({ id: mutation.original.id })
178
+ * await collection.utils.awaitMatch(
179
+ * (message) => isChangeMessage(message) &&
180
+ * message.headers.operation === 'delete' &&
181
+ * message.value.id === mutation.original.id
182
+ * )
183
+ * }
184
+ */
185
+ onDelete?: (params: DeleteMutationFnParams<T>) => Promise<MatchingStrategy>
88
186
  }
89
187
 
90
188
  function isUpToDateMessage<T extends Row<unknown>>(
@@ -125,11 +223,21 @@ function hasTxids<T extends Row<unknown>>(
125
223
  */
126
224
  export type AwaitTxIdFn = (txId: Txid, timeout?: number) => Promise<boolean>
127
225
 
226
+ /**
227
+ * Type for the awaitMatch utility function
228
+ */
229
+ export type AwaitMatchFn<T extends Row<unknown>> = (
230
+ matchFn: MatchFunction<T>,
231
+ timeout?: number
232
+ ) => Promise<boolean>
233
+
128
234
  /**
129
235
  * Electric collection utilities type
130
236
  */
131
- export interface ElectricCollectionUtils extends UtilsRecord {
237
+ export interface ElectricCollectionUtils<T extends Row<unknown> = Row<unknown>>
238
+ extends UtilsRecord {
132
239
  awaitTxId: AwaitTxIdFn
240
+ awaitMatch: AwaitMatchFn<T>
133
241
  }
134
242
 
135
243
  /**
@@ -173,24 +281,79 @@ export function electricCollectionOptions(
173
281
  } {
174
282
  const seenTxids = new Store<Set<Txid>>(new Set([]))
175
283
  const seenSnapshots = new Store<Array<PostgresSnapshot>>([])
284
+ const pendingMatches = new Store<
285
+ Map<
286
+ string,
287
+ {
288
+ matchFn: (message: Message<any>) => boolean
289
+ resolve: (value: boolean) => void
290
+ reject: (error: Error) => void
291
+ timeoutId: ReturnType<typeof setTimeout>
292
+ matched: boolean
293
+ }
294
+ >
295
+ >(new Map())
296
+
297
+ // Buffer messages since last up-to-date to handle race conditions
298
+ const currentBatchMessages = new Store<Array<Message<any>>>([])
299
+
300
+ /**
301
+ * Helper function to remove multiple matches from the pendingMatches store
302
+ */
303
+ const removePendingMatches = (matchIds: Array<string>) => {
304
+ if (matchIds.length > 0) {
305
+ pendingMatches.setState((current) => {
306
+ const newMatches = new Map(current)
307
+ matchIds.forEach((id) => newMatches.delete(id))
308
+ return newMatches
309
+ })
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Helper function to resolve and cleanup matched pending matches
315
+ */
316
+ const resolveMatchedPendingMatches = () => {
317
+ const matchesToResolve: Array<string> = []
318
+ pendingMatches.state.forEach((match, matchId) => {
319
+ if (match.matched) {
320
+ clearTimeout(match.timeoutId)
321
+ match.resolve(true)
322
+ matchesToResolve.push(matchId)
323
+ debug(
324
+ `${config.id ? `[${config.id}] ` : ``}awaitMatch resolved on up-to-date for match %s`,
325
+ matchId
326
+ )
327
+ }
328
+ })
329
+ removePendingMatches(matchesToResolve)
330
+ }
176
331
  const sync = createElectricSync<any>(config.shapeOptions, {
177
332
  seenTxids,
178
333
  seenSnapshots,
334
+ pendingMatches,
335
+ currentBatchMessages,
336
+ removePendingMatches,
337
+ resolveMatchedPendingMatches,
338
+ collectionId: config.id,
179
339
  })
180
340
 
181
341
  /**
182
342
  * Wait for a specific transaction ID to be synced
183
343
  * @param txId The transaction ID to wait for as a number
184
- * @param timeout Optional timeout in milliseconds (defaults to 30000ms)
344
+ * @param timeout Optional timeout in milliseconds (defaults to 5000ms)
185
345
  * @returns Promise that resolves when the txId is synced
186
346
  */
187
347
  const awaitTxId: AwaitTxIdFn = async (
188
348
  txId: Txid,
189
- timeout: number = 30000
349
+ timeout: number = 5000
190
350
  ): Promise<boolean> => {
191
- debug(`awaitTxId called with txid %d`, txId)
351
+ debug(
352
+ `${config.id ? `[${config.id}] ` : ``}awaitTxId called with txid %d`,
353
+ txId
354
+ )
192
355
  if (typeof txId !== `number`) {
193
- throw new ExpectedNumberInAwaitTxIdError(typeof txId)
356
+ throw new ExpectedNumberInAwaitTxIdError(typeof txId, config.id)
194
357
  }
195
358
 
196
359
  // First check if the txid is in the seenTxids store
@@ -207,12 +370,15 @@ export function electricCollectionOptions(
207
370
  const timeoutId = setTimeout(() => {
208
371
  unsubscribeSeenTxids()
209
372
  unsubscribeSeenSnapshots()
210
- reject(new TimeoutWaitingForTxIdError(txId))
373
+ reject(new TimeoutWaitingForTxIdError(txId, config.id))
211
374
  }, timeout)
212
375
 
213
376
  const unsubscribeSeenTxids = seenTxids.subscribe(() => {
214
377
  if (seenTxids.state.has(txId)) {
215
- debug(`awaitTxId found match for txid %o`, txId)
378
+ debug(
379
+ `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o`,
380
+ txId
381
+ )
216
382
  clearTimeout(timeoutId)
217
383
  unsubscribeSeenTxids()
218
384
  unsubscribeSeenSnapshots()
@@ -226,7 +392,7 @@ export function electricCollectionOptions(
226
392
  )
227
393
  if (visibleSnapshot) {
228
394
  debug(
229
- `awaitTxId found match for txid %o in snapshot %o`,
395
+ `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o in snapshot %o`,
230
396
  txId,
231
397
  visibleSnapshot
232
398
  )
@@ -239,49 +405,128 @@ export function electricCollectionOptions(
239
405
  })
240
406
  }
241
407
 
242
- // Create wrapper handlers for direct persistence operations that handle txid awaiting
243
- const wrappedOnInsert = config.onInsert
244
- ? async (params: InsertMutationFnParams<any>) => {
245
- // Runtime check (that doesn't follow type)
408
+ /**
409
+ * Wait for a custom match function to find a matching message
410
+ * @param matchFn Function that returns true when a message matches
411
+ * @param timeout Optional timeout in milliseconds (defaults to 5000ms)
412
+ * @returns Promise that resolves when a matching message is found
413
+ */
414
+ const awaitMatch: AwaitMatchFn<any> = async (
415
+ matchFn: MatchFunction<any>,
416
+ timeout: number = 3000
417
+ ): Promise<boolean> => {
418
+ debug(
419
+ `${config.id ? `[${config.id}] ` : ``}awaitMatch called with custom function`
420
+ )
421
+
422
+ return new Promise((resolve, reject) => {
423
+ const matchId = Math.random().toString(36)
424
+
425
+ const cleanupMatch = () => {
426
+ pendingMatches.setState((current) => {
427
+ const newMatches = new Map(current)
428
+ newMatches.delete(matchId)
429
+ return newMatches
430
+ })
431
+ }
432
+
433
+ const onTimeout = () => {
434
+ cleanupMatch()
435
+ reject(new TimeoutWaitingForMatchError(config.id))
436
+ }
246
437
 
247
- const handlerResult =
248
- ((await config.onInsert!(params)) as MaybeTxId) ?? {}
249
- const txid = handlerResult.txid
438
+ const timeoutId = setTimeout(onTimeout, timeout)
250
439
 
251
- if (!txid) {
252
- throw new ElectricInsertHandlerMustReturnTxIdError()
440
+ // We need access to the stream messages to check against the match function
441
+ // This will be handled by the sync configuration
442
+ const checkMatch = (message: Message<any>) => {
443
+ if (matchFn(message)) {
444
+ debug(
445
+ `${config.id ? `[${config.id}] ` : ``}awaitMatch found matching message, waiting for up-to-date`
446
+ )
447
+ // Mark as matched but don't resolve yet - wait for up-to-date
448
+ pendingMatches.setState((current) => {
449
+ const newMatches = new Map(current)
450
+ const existing = newMatches.get(matchId)
451
+ if (existing) {
452
+ newMatches.set(matchId, { ...existing, matched: true })
453
+ }
454
+ return newMatches
455
+ })
456
+ return true
253
457
  }
458
+ return false
459
+ }
254
460
 
255
- // Handle both single txid and array of txids
256
- if (Array.isArray(txid)) {
257
- await Promise.all(txid.map((id) => awaitTxId(id)))
258
- } else {
259
- await awaitTxId(txid)
461
+ // Check against current batch messages first to handle race conditions
462
+ for (const message of currentBatchMessages.state) {
463
+ if (matchFn(message)) {
464
+ debug(
465
+ `${config.id ? `[${config.id}] ` : ``}awaitMatch found immediate match in current batch, waiting for up-to-date`
466
+ )
467
+ // Register match as already matched
468
+ pendingMatches.setState((current) => {
469
+ const newMatches = new Map(current)
470
+ newMatches.set(matchId, {
471
+ matchFn: checkMatch,
472
+ resolve,
473
+ reject,
474
+ timeoutId,
475
+ matched: true, // Already matched
476
+ })
477
+ return newMatches
478
+ })
479
+ return
260
480
  }
481
+ }
482
+
483
+ // Store the match function for the sync process to use
484
+ // We'll add this to a pending matches store
485
+ pendingMatches.setState((current) => {
486
+ const newMatches = new Map(current)
487
+ newMatches.set(matchId, {
488
+ matchFn: checkMatch,
489
+ resolve,
490
+ reject,
491
+ timeoutId,
492
+ matched: false,
493
+ })
494
+ return newMatches
495
+ })
496
+ })
497
+ }
261
498
 
499
+ /**
500
+ * Process matching strategy and wait for synchronization
501
+ */
502
+ const processMatchingStrategy = async (
503
+ result: MatchingStrategy
504
+ ): Promise<void> => {
505
+ // Only wait if result contains txid
506
+ if (result && `txid` in result) {
507
+ // Handle both single txid and array of txids
508
+ if (Array.isArray(result.txid)) {
509
+ await Promise.all(result.txid.map(awaitTxId))
510
+ } else {
511
+ await awaitTxId(result.txid)
512
+ }
513
+ }
514
+ // If result is void/undefined, don't wait - mutation completes immediately
515
+ }
516
+
517
+ // Create wrapper handlers for direct persistence operations that handle different matching strategies
518
+ const wrappedOnInsert = config.onInsert
519
+ ? async (params: InsertMutationFnParams<any>) => {
520
+ const handlerResult = await config.onInsert!(params)
521
+ await processMatchingStrategy(handlerResult)
262
522
  return handlerResult
263
523
  }
264
524
  : undefined
265
525
 
266
526
  const wrappedOnUpdate = config.onUpdate
267
527
  ? async (params: UpdateMutationFnParams<any>) => {
268
- // Runtime check (that doesn't follow type)
269
-
270
- const handlerResult =
271
- ((await config.onUpdate!(params)) as MaybeTxId) ?? {}
272
- const txid = handlerResult.txid
273
-
274
- if (!txid) {
275
- throw new ElectricUpdateHandlerMustReturnTxIdError()
276
- }
277
-
278
- // Handle both single txid and array of txids
279
- if (Array.isArray(txid)) {
280
- await Promise.all(txid.map((id) => awaitTxId(id)))
281
- } else {
282
- await awaitTxId(txid)
283
- }
284
-
528
+ const handlerResult = await config.onUpdate!(params)
529
+ await processMatchingStrategy(handlerResult)
285
530
  return handlerResult
286
531
  }
287
532
  : undefined
@@ -289,17 +534,7 @@ export function electricCollectionOptions(
289
534
  const wrappedOnDelete = config.onDelete
290
535
  ? async (params: DeleteMutationFnParams<any>) => {
291
536
  const handlerResult = await config.onDelete!(params)
292
- if (!handlerResult.txid) {
293
- throw new ElectricDeleteHandlerMustReturnTxIdError()
294
- }
295
-
296
- // Handle both single txid and array of txids
297
- if (Array.isArray(handlerResult.txid)) {
298
- await Promise.all(handlerResult.txid.map((id) => awaitTxId(id)))
299
- } else {
300
- await awaitTxId(handlerResult.txid)
301
- }
302
-
537
+ await processMatchingStrategy(handlerResult)
303
538
  return handlerResult
304
539
  }
305
540
  : undefined
@@ -321,7 +556,8 @@ export function electricCollectionOptions(
321
556
  onDelete: wrappedOnDelete,
322
557
  utils: {
323
558
  awaitTxId,
324
- },
559
+ awaitMatch,
560
+ } as ElectricCollectionUtils<any>,
325
561
  }
326
562
  }
327
563
 
@@ -333,10 +569,34 @@ function createElectricSync<T extends Row<unknown>>(
333
569
  options: {
334
570
  seenTxids: Store<Set<Txid>>
335
571
  seenSnapshots: Store<Array<PostgresSnapshot>>
572
+ pendingMatches: Store<
573
+ Map<
574
+ string,
575
+ {
576
+ matchFn: (message: Message<T>) => boolean
577
+ resolve: (value: boolean) => void
578
+ reject: (error: Error) => void
579
+ timeoutId: ReturnType<typeof setTimeout>
580
+ matched: boolean
581
+ }
582
+ >
583
+ >
584
+ currentBatchMessages: Store<Array<Message<T>>>
585
+ removePendingMatches: (matchIds: Array<string>) => void
586
+ resolveMatchedPendingMatches: () => void
587
+ collectionId?: string
336
588
  }
337
589
  ): SyncConfig<T> {
338
- const { seenTxids } = options
339
- const { seenSnapshots } = options
590
+ const {
591
+ seenTxids,
592
+ seenSnapshots,
593
+ pendingMatches,
594
+ currentBatchMessages,
595
+ removePendingMatches,
596
+ resolveMatchedPendingMatches,
597
+ collectionId,
598
+ } = options
599
+ const MAX_BATCH_MESSAGES = 1000 // Safety limit for message buffer
340
600
 
341
601
  // Store for the relation schema information
342
602
  const relationSchema = new Store<string | undefined>(undefined)
@@ -380,6 +640,17 @@ function createElectricSync<T extends Row<unknown>>(
380
640
  }
381
641
  }
382
642
 
643
+ // Cleanup pending matches on abort
644
+ abortController.signal.addEventListener(`abort`, () => {
645
+ pendingMatches.setState((current) => {
646
+ current.forEach((match) => {
647
+ clearTimeout(match.timeoutId)
648
+ match.reject(new StreamAbortedError())
649
+ })
650
+ return new Map() // Clear all pending matches
651
+ })
652
+ })
653
+
383
654
  const stream = new ShapeStream({
384
655
  ...shapeOptions,
385
656
  signal: abortController.signal,
@@ -413,11 +684,45 @@ function createElectricSync<T extends Row<unknown>>(
413
684
  let hasUpToDate = false
414
685
 
415
686
  for (const message of messages) {
687
+ // Add message to current batch buffer (for race condition handling)
688
+ if (isChangeMessage(message)) {
689
+ currentBatchMessages.setState((currentBuffer) => {
690
+ const newBuffer = [...currentBuffer, message]
691
+ // Limit buffer size for safety
692
+ if (newBuffer.length > MAX_BATCH_MESSAGES) {
693
+ newBuffer.splice(0, newBuffer.length - MAX_BATCH_MESSAGES)
694
+ }
695
+ return newBuffer
696
+ })
697
+ }
698
+
416
699
  // Check for txids in the message and add them to our store
417
700
  if (hasTxids(message)) {
418
701
  message.headers.txids?.forEach((txid) => newTxids.add(txid))
419
702
  }
420
703
 
704
+ // Check pending matches against this message
705
+ // Note: matchFn will mark matches internally, we don't resolve here
706
+ const matchesToRemove: Array<string> = []
707
+ pendingMatches.state.forEach((match, matchId) => {
708
+ if (!match.matched) {
709
+ try {
710
+ match.matchFn(message)
711
+ } catch (err) {
712
+ // If matchFn throws, clean up and reject the promise
713
+ clearTimeout(match.timeoutId)
714
+ match.reject(
715
+ err instanceof Error ? err : new Error(String(err))
716
+ )
717
+ matchesToRemove.push(matchId)
718
+ debug(`matchFn error: %o`, err)
719
+ }
720
+ }
721
+ })
722
+
723
+ // Remove matches that errored
724
+ removePendingMatches(matchesToRemove)
725
+
421
726
  if (isChangeMessage(message)) {
422
727
  // Check if the message contains schema information
423
728
  const schema = message.headers.schema
@@ -445,7 +750,7 @@ function createElectricSync<T extends Row<unknown>>(
445
750
  hasUpToDate = true
446
751
  } else if (isMustRefetchMessage(message)) {
447
752
  debug(
448
- `Received must-refetch message, starting transaction with truncate`
753
+ `${collectionId ? `[${collectionId}] ` : ``}Received must-refetch message, starting transaction with truncate`
449
754
  )
450
755
 
451
756
  // Start a transaction and truncate the collection
@@ -462,6 +767,9 @@ function createElectricSync<T extends Row<unknown>>(
462
767
  }
463
768
 
464
769
  if (hasUpToDate) {
770
+ // Clear the current batch buffer since we're now up-to-date
771
+ currentBatchMessages.setState(() => [])
772
+
465
773
  // Commit transaction if one was started
466
774
  if (transactionStarted) {
467
775
  commit()
@@ -475,7 +783,10 @@ function createElectricSync<T extends Row<unknown>>(
475
783
  seenTxids.setState((currentTxids) => {
476
784
  const clonedSeen = new Set<Txid>(currentTxids)
477
785
  if (newTxids.size > 0) {
478
- debug(`new txids synced from pg %O`, Array.from(newTxids))
786
+ debug(
787
+ `${collectionId ? `[${collectionId}] ` : ``}new txids synced from pg %O`,
788
+ Array.from(newTxids)
789
+ )
479
790
  }
480
791
  newTxids.forEach((txid) => clonedSeen.add(txid))
481
792
  newTxids.clear()
@@ -486,11 +797,17 @@ function createElectricSync<T extends Row<unknown>>(
486
797
  seenSnapshots.setState((currentSnapshots) => {
487
798
  const seen = [...currentSnapshots, ...newSnapshots]
488
799
  newSnapshots.forEach((snapshot) =>
489
- debug(`new snapshot synced from pg %o`, snapshot)
800
+ debug(
801
+ `${collectionId ? `[${collectionId}] ` : ``}new snapshot synced from pg %o`,
802
+ snapshot
803
+ )
490
804
  )
491
805
  newSnapshots.length = 0
492
806
  return seen
493
807
  })
808
+
809
+ // Resolve all matched pending matches on up-to-date
810
+ resolveMatchedPendingMatches()
494
811
  }
495
812
  })
496
813