2
2
ShapeStream ,
3
3
isChangeMessage ,
4
4
isControlMessage ,
5
+ isVisibleInSnapshot ,
5
6
} from "@electric-sql/client"
6
7
import { Store } from "@tanstack/store"
7
8
import DebugModule from "debug"
@@ -27,6 +28,7 @@ import type {
27
28
ControlMessage ,
28
29
GetExtensions ,
29
30
Message ,
31
+ PostgresSnapshot ,
30
32
Row ,
31
33
ShapeStreamOptions ,
32
34
} from "@electric-sql/client"
@@ -38,6 +40,23 @@ const debug = DebugModule.debug(`ts/db:electric`)
38
40
*/
39
41
export type Txid = number
40
42
43
+ /**
44
+ * Type representing the result of an insert, update, or delete handler
45
+ */
46
+ type MaybeTxId =
47
+ | {
48
+ txid ?: Txid | Array < Txid >
49
+ }
50
+ | undefined
51
+ | null
52
+
53
+ /**
54
+ * Type representing a snapshot end message
55
+ */
56
+ type SnapshotEndMessage = ControlMessage & {
57
+ headers : { control : `snapshot-end` }
58
+ }
59
+
41
60
// The `InferSchemaOutput` and `ResolveType` are copied from the `@tanstack/db` package
42
61
// but we modified `InferSchemaOutput` slightly to restrict the schema output to `Row<unknown>`
43
62
// This is needed in order for `GetExtensions` to be able to infer the parser extensions type from the schema
@@ -80,6 +99,20 @@ function isMustRefetchMessage<T extends Row<unknown>>(
80
99
return isControlMessage ( message ) && message . headers . control === `must-refetch`
81
100
}
82
101
102
+ function isSnapshotEndMessage < T extends Row < unknown > > (
103
+ message : Message < T >
104
+ ) : message is SnapshotEndMessage {
105
+ return isControlMessage ( message ) && message . headers . control === `snapshot-end`
106
+ }
107
+
108
+ function parseSnapshotMessage ( message : SnapshotEndMessage ) : PostgresSnapshot {
109
+ return {
110
+ xmin : message . headers . xmin ,
111
+ xmax : message . headers . xmax ,
112
+ xip_list : message . headers . xip_list ,
113
+ }
114
+ }
115
+
83
116
// Check if a message contains txids in its headers
84
117
function hasTxids < T extends Row < unknown > > (
85
118
message : Message < T >
@@ -139,8 +172,10 @@ export function electricCollectionOptions(
139
172
schema ?: any
140
173
} {
141
174
const seenTxids = new Store < Set < Txid > > ( new Set ( [ ] ) )
175
+ const seenSnapshots = new Store < Array < PostgresSnapshot > > ( [ ] )
142
176
const sync = createElectricSync < any > ( config . shapeOptions , {
143
177
seenTxids,
178
+ seenSnapshots,
144
179
} )
145
180
146
181
/**
@@ -158,20 +193,46 @@ export function electricCollectionOptions(
158
193
throw new ExpectedNumberInAwaitTxIdError ( typeof txId )
159
194
}
160
195
196
+ // First check if the txid is in the seenTxids store
161
197
const hasTxid = seenTxids . state . has ( txId )
162
198
if ( hasTxid ) return true
163
199
200
+ // Then check if the txid is in any of the seen snapshots
201
+ const hasSnapshot = seenSnapshots . state . some ( ( snapshot ) =>
202
+ isVisibleInSnapshot ( txId , snapshot )
203
+ )
204
+ if ( hasSnapshot ) return true
205
+
164
206
return new Promise ( ( resolve , reject ) => {
165
207
const timeoutId = setTimeout ( ( ) => {
166
- unsubscribe ( )
208
+ unsubscribeSeenTxids ( )
209
+ unsubscribeSeenSnapshots ( )
167
210
reject ( new TimeoutWaitingForTxIdError ( txId ) )
168
211
} , timeout )
169
212
170
- const unsubscribe = seenTxids . subscribe ( ( ) => {
213
+ const unsubscribeSeenTxids = seenTxids . subscribe ( ( ) => {
171
214
if ( seenTxids . state . has ( txId ) ) {
172
215
debug ( `awaitTxId found match for txid %o` , txId )
173
216
clearTimeout ( timeoutId )
174
- unsubscribe ( )
217
+ unsubscribeSeenTxids ( )
218
+ unsubscribeSeenSnapshots ( )
219
+ resolve ( true )
220
+ }
221
+ } )
222
+
223
+ const unsubscribeSeenSnapshots = seenSnapshots . subscribe ( ( ) => {
224
+ const visibleSnapshot = seenSnapshots . state . find ( ( snapshot ) =>
225
+ isVisibleInSnapshot ( txId , snapshot )
226
+ )
227
+ if ( visibleSnapshot ) {
228
+ debug (
229
+ `awaitTxId found match for txid %o in snapshot %o` ,
230
+ txId ,
231
+ visibleSnapshot
232
+ )
233
+ clearTimeout ( timeoutId )
234
+ unsubscribeSeenSnapshots ( )
235
+ unsubscribeSeenTxids ( )
175
236
resolve ( true )
176
237
}
177
238
} )
@@ -183,8 +244,9 @@ export function electricCollectionOptions(
183
244
? async ( params : InsertMutationFnParams < any > ) => {
184
245
// Runtime check (that doesn't follow type)
185
246
186
- const handlerResult = ( await config . onInsert ! ( params ) ) ?? { }
187
- const txid = ( handlerResult as { txid ?: Txid | Array < Txid > } ) . txid
247
+ const handlerResult =
248
+ ( ( await config . onInsert ! ( params ) ) as MaybeTxId ) ?? { }
249
+ const txid = handlerResult . txid
188
250
189
251
if ( ! txid ) {
190
252
throw new ElectricInsertHandlerMustReturnTxIdError ( )
@@ -205,8 +267,9 @@ export function electricCollectionOptions(
205
267
? async ( params : UpdateMutationFnParams < any > ) => {
206
268
// Runtime check (that doesn't follow type)
207
269
208
- const handlerResult = ( await config . onUpdate ! ( params ) ) ?? { }
209
- const txid = ( handlerResult as { txid ?: Txid | Array < Txid > } ) . txid
270
+ const handlerResult =
271
+ ( ( await config . onUpdate ! ( params ) ) as MaybeTxId ) ?? { }
272
+ const txid = handlerResult . txid
210
273
211
274
if ( ! txid ) {
212
275
throw new ElectricUpdateHandlerMustReturnTxIdError ( )
@@ -269,9 +332,11 @@ function createElectricSync<T extends Row<unknown>>(
269
332
shapeOptions : ShapeStreamOptions < GetExtensions < T > > ,
270
333
options : {
271
334
seenTxids : Store < Set < Txid > >
335
+ seenSnapshots : Store < Array < PostgresSnapshot > >
272
336
}
273
337
) : SyncConfig < T > {
274
338
const { seenTxids } = options
339
+ const { seenSnapshots } = options
275
340
276
341
// Store for the relation schema information
277
342
const relationSchema = new Store < string | undefined > ( undefined )
@@ -342,6 +407,7 @@ function createElectricSync<T extends Row<unknown>>(
342
407
} )
343
408
let transactionStarted = false
344
409
const newTxids = new Set < Txid > ( )
410
+ const newSnapshots : Array < PostgresSnapshot > = [ ]
345
411
346
412
unsubscribeStream = stream . subscribe ( ( messages : Array < Message < T > > ) => {
347
413
let hasUpToDate = false
@@ -373,6 +439,8 @@ function createElectricSync<T extends Row<unknown>>(
373
439
...message . headers ,
374
440
} ,
375
441
} )
442
+ } else if ( isSnapshotEndMessage ( message ) ) {
443
+ newSnapshots . push ( parseSnapshotMessage ( message ) )
376
444
} else if ( isUpToDateMessage ( message ) ) {
377
445
hasUpToDate = true
378
446
} else if ( isMustRefetchMessage ( message ) ) {
@@ -413,6 +481,16 @@ function createElectricSync<T extends Row<unknown>>(
413
481
newTxids . clear ( )
414
482
return clonedSeen
415
483
} )
484
+
485
+ // Always commit snapshots when we receive up-to-date, regardless of transaction state
486
+ seenSnapshots . setState ( ( currentSnapshots ) => {
487
+ const seen = [ ...currentSnapshots , ...newSnapshots ]
488
+ newSnapshots . forEach ( ( snapshot ) =>
489
+ debug ( `new snapshot synced from pg %o` , snapshot )
490
+ )
491
+ newSnapshots . length = 0
492
+ return seen
493
+ } )
416
494
}
417
495
} )
418
496
0 commit comments