@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/dist/cjs/electric.cjs +191 -41
- package/dist/cjs/electric.cjs.map +1 -1
- package/dist/cjs/electric.d.cts +116 -7
- package/dist/cjs/errors.cjs +16 -29
- package/dist/cjs/errors.cjs.map +1 -1
- package/dist/cjs/errors.d.cts +7 -10
- package/dist/cjs/index.cjs +2 -3
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/esm/electric.d.ts +116 -7
- package/dist/esm/electric.js +188 -43
- package/dist/esm/electric.js.map +1 -1
- package/dist/esm/errors.d.ts +7 -10
- package/dist/esm/errors.js +16 -29
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.js +3 -4
- package/package.json +2 -2
- package/src/electric.ts +391 -74
- package/src/errors.ts +14 -27
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
|
-
*
|
|
45
|
+
* Custom match function type - receives stream messages and returns boolean
|
|
46
|
+
* indicating if the mutation has been synchronized
|
|
45
47
|
*/
|
|
46
|
-
type
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
78
|
-
T,
|
|
79
|
-
|
|
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
|
|
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
|
|
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 =
|
|
349
|
+
timeout: number = 5000
|
|
190
350
|
): Promise<boolean> => {
|
|
191
|
-
debug(
|
|
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(
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
248
|
-
((await config.onInsert!(params)) as MaybeTxId) ?? {}
|
|
249
|
-
const txid = handlerResult.txid
|
|
438
|
+
const timeoutId = setTimeout(onTimeout, timeout)
|
|
250
439
|
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
339
|
-
|
|
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(
|
|
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(
|
|
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
|
|