@tanstack/electric-db-collection 0.2.12 → 0.2.13
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 +28 -2
- package/dist/cjs/electric.cjs.map +1 -1
- package/dist/cjs/errors.cjs.map +1 -1
- package/dist/cjs/sql-compiler.cjs +75 -0
- package/dist/cjs/sql-compiler.cjs.map +1 -1
- package/dist/esm/electric.js +29 -3
- package/dist/esm/electric.js.map +1 -1
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/sql-compiler.js +75 -0
- package/dist/esm/sql-compiler.js.map +1 -1
- package/package.json +33 -33
- package/src/electric.ts +101 -54
- package/src/errors.ts +1 -1
- package/src/index.ts +2 -2
- package/src/sql-compiler.ts +132 -10
package/src/electric.ts
CHANGED
|
@@ -3,17 +3,17 @@ import {
|
|
|
3
3
|
isChangeMessage,
|
|
4
4
|
isControlMessage,
|
|
5
5
|
isVisibleInSnapshot,
|
|
6
|
-
} from
|
|
7
|
-
import { Store } from
|
|
8
|
-
import DebugModule from
|
|
9
|
-
import { DeduplicatedLoadSubset } from
|
|
6
|
+
} from '@electric-sql/client'
|
|
7
|
+
import { Store } from '@tanstack/store'
|
|
8
|
+
import DebugModule from 'debug'
|
|
9
|
+
import { DeduplicatedLoadSubset, and } from '@tanstack/db'
|
|
10
10
|
import {
|
|
11
11
|
ExpectedNumberInAwaitTxIdError,
|
|
12
12
|
StreamAbortedError,
|
|
13
13
|
TimeoutWaitingForMatchError,
|
|
14
14
|
TimeoutWaitingForTxIdError,
|
|
15
|
-
} from
|
|
16
|
-
import { compileSQL } from
|
|
15
|
+
} from './errors'
|
|
16
|
+
import { compileSQL } from './sql-compiler'
|
|
17
17
|
import type {
|
|
18
18
|
BaseCollectionConfig,
|
|
19
19
|
CollectionConfig,
|
|
@@ -24,8 +24,8 @@ import type {
|
|
|
24
24
|
SyncMode,
|
|
25
25
|
UpdateMutationFnParams,
|
|
26
26
|
UtilsRecord,
|
|
27
|
-
} from
|
|
28
|
-
import type { StandardSchemaV1 } from
|
|
27
|
+
} from '@tanstack/db'
|
|
28
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
29
29
|
import type {
|
|
30
30
|
ControlMessage,
|
|
31
31
|
GetExtensions,
|
|
@@ -33,10 +33,10 @@ import type {
|
|
|
33
33
|
PostgresSnapshot,
|
|
34
34
|
Row,
|
|
35
35
|
ShapeStreamOptions,
|
|
36
|
-
} from
|
|
36
|
+
} from '@electric-sql/client'
|
|
37
37
|
|
|
38
38
|
// Re-export for user convenience in custom match functions
|
|
39
|
-
export { isChangeMessage, isControlMessage } from
|
|
39
|
+
export { isChangeMessage, isControlMessage } from '@electric-sql/client'
|
|
40
40
|
|
|
41
41
|
const debug = DebugModule.debug(`ts/db:electric`)
|
|
42
42
|
|
|
@@ -66,7 +66,7 @@ export type Txid = number
|
|
|
66
66
|
* indicating if the mutation has been synchronized
|
|
67
67
|
*/
|
|
68
68
|
export type MatchFunction<T extends Row<unknown>> = (
|
|
69
|
-
message: Message<T
|
|
69
|
+
message: Message<T>,
|
|
70
70
|
) => boolean
|
|
71
71
|
|
|
72
72
|
/**
|
|
@@ -197,7 +197,7 @@ export interface ElectricCollectionConfig<
|
|
|
197
197
|
T,
|
|
198
198
|
string | number,
|
|
199
199
|
ElectricCollectionUtils<T>
|
|
200
|
-
|
|
200
|
+
>,
|
|
201
201
|
) => Promise<MatchingStrategy>
|
|
202
202
|
|
|
203
203
|
/**
|
|
@@ -232,7 +232,7 @@ export interface ElectricCollectionConfig<
|
|
|
232
232
|
T,
|
|
233
233
|
string | number,
|
|
234
234
|
ElectricCollectionUtils<T>
|
|
235
|
-
|
|
235
|
+
>,
|
|
236
236
|
) => Promise<MatchingStrategy>
|
|
237
237
|
|
|
238
238
|
/**
|
|
@@ -266,24 +266,24 @@ export interface ElectricCollectionConfig<
|
|
|
266
266
|
T,
|
|
267
267
|
string | number,
|
|
268
268
|
ElectricCollectionUtils<T>
|
|
269
|
-
|
|
269
|
+
>,
|
|
270
270
|
) => Promise<MatchingStrategy>
|
|
271
271
|
}
|
|
272
272
|
|
|
273
273
|
function isUpToDateMessage<T extends Row<unknown>>(
|
|
274
|
-
message: Message<T
|
|
274
|
+
message: Message<T>,
|
|
275
275
|
): message is ControlMessage & { up_to_date: true } {
|
|
276
276
|
return isControlMessage(message) && message.headers.control === `up-to-date`
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
function isMustRefetchMessage<T extends Row<unknown>>(
|
|
280
|
-
message: Message<T
|
|
280
|
+
message: Message<T>,
|
|
281
281
|
): message is ControlMessage & { headers: { control: `must-refetch` } } {
|
|
282
282
|
return isControlMessage(message) && message.headers.control === `must-refetch`
|
|
283
283
|
}
|
|
284
284
|
|
|
285
285
|
function isSnapshotEndMessage<T extends Row<unknown>>(
|
|
286
|
-
message: Message<T
|
|
286
|
+
message: Message<T>,
|
|
287
287
|
): message is SnapshotEndMessage {
|
|
288
288
|
return isControlMessage(message) && message.headers.control === `snapshot-end`
|
|
289
289
|
}
|
|
@@ -298,7 +298,7 @@ function parseSnapshotMessage(message: SnapshotEndMessage): PostgresSnapshot {
|
|
|
298
298
|
|
|
299
299
|
// Check if a message contains txids in its headers
|
|
300
300
|
function hasTxids<T extends Row<unknown>>(
|
|
301
|
-
message: Message<T
|
|
301
|
+
message: Message<T>,
|
|
302
302
|
): message is Message<T> & { headers: { txids?: Array<Txid> } } {
|
|
303
303
|
return `txids` in message.headers && Array.isArray(message.headers.txids)
|
|
304
304
|
}
|
|
@@ -307,7 +307,12 @@ function hasTxids<T extends Row<unknown>>(
|
|
|
307
307
|
* Creates a deduplicated loadSubset handler for progressive/on-demand modes
|
|
308
308
|
* Returns null for eager mode, or a DeduplicatedLoadSubset instance for other modes.
|
|
309
309
|
* Handles fetching snapshots in progressive mode during buffering phase,
|
|
310
|
-
* and requesting snapshots in on-demand mode
|
|
310
|
+
* and requesting snapshots in on-demand mode.
|
|
311
|
+
*
|
|
312
|
+
* When cursor expressions are provided (whereFrom/whereCurrent), makes two
|
|
313
|
+
* requestSnapshot calls:
|
|
314
|
+
* - One for whereFrom (rows > cursor) with limit
|
|
315
|
+
* - One for whereCurrent (rows = cursor, for tie-breaking) without limit
|
|
311
316
|
*/
|
|
312
317
|
function createLoadSubsetDedupe<T extends Row<unknown>>({
|
|
313
318
|
stream,
|
|
@@ -347,7 +352,7 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
|
|
|
347
352
|
// and completed the atomic swap while waiting for the snapshot
|
|
348
353
|
if (!isBufferingInitialSync()) {
|
|
349
354
|
debug(
|
|
350
|
-
`${collectionId ? `[${collectionId}] ` : ``}Ignoring snapshot - sync completed while fetching
|
|
355
|
+
`${collectionId ? `[${collectionId}] ` : ``}Ignoring snapshot - sync completed while fetching`,
|
|
351
356
|
)
|
|
352
357
|
return
|
|
353
358
|
}
|
|
@@ -367,13 +372,13 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
|
|
|
367
372
|
commit()
|
|
368
373
|
|
|
369
374
|
debug(
|
|
370
|
-
`${collectionId ? `[${collectionId}] ` : ``}Applied snapshot with ${rows.length} rows
|
|
375
|
+
`${collectionId ? `[${collectionId}] ` : ``}Applied snapshot with ${rows.length} rows`,
|
|
371
376
|
)
|
|
372
377
|
}
|
|
373
378
|
} catch (error) {
|
|
374
379
|
debug(
|
|
375
380
|
`${collectionId ? `[${collectionId}] ` : ``}Error fetching snapshot: %o`,
|
|
376
|
-
error
|
|
381
|
+
error,
|
|
377
382
|
)
|
|
378
383
|
throw error
|
|
379
384
|
}
|
|
@@ -382,8 +387,50 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
|
|
|
382
387
|
return
|
|
383
388
|
} else {
|
|
384
389
|
// On-demand mode: use requestSnapshot
|
|
385
|
-
|
|
386
|
-
|
|
390
|
+
// When cursor is provided, make two calls:
|
|
391
|
+
// 1. whereCurrent (all ties, no limit)
|
|
392
|
+
// 2. whereFrom (rows > cursor, with limit)
|
|
393
|
+
const { cursor, where, orderBy, limit } = opts
|
|
394
|
+
|
|
395
|
+
if (cursor) {
|
|
396
|
+
// Make parallel requests for cursor-based pagination
|
|
397
|
+
const promises: Array<Promise<unknown>> = []
|
|
398
|
+
|
|
399
|
+
// Request 1: All rows matching whereCurrent (ties at boundary, no limit)
|
|
400
|
+
// Combine main where with cursor.whereCurrent
|
|
401
|
+
const whereCurrentOpts: LoadSubsetOptions = {
|
|
402
|
+
where: where ? and(where, cursor.whereCurrent) : cursor.whereCurrent,
|
|
403
|
+
orderBy,
|
|
404
|
+
// No limit - get all ties
|
|
405
|
+
}
|
|
406
|
+
const whereCurrentParams = compileSQL<T>(whereCurrentOpts)
|
|
407
|
+
promises.push(stream.requestSnapshot(whereCurrentParams))
|
|
408
|
+
|
|
409
|
+
debug(
|
|
410
|
+
`${collectionId ? `[${collectionId}] ` : ``}Requesting cursor.whereCurrent snapshot (all ties)`,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
// Request 2: Rows matching whereFrom (rows > cursor, with limit)
|
|
414
|
+
// Combine main where with cursor.whereFrom
|
|
415
|
+
const whereFromOpts: LoadSubsetOptions = {
|
|
416
|
+
where: where ? and(where, cursor.whereFrom) : cursor.whereFrom,
|
|
417
|
+
orderBy,
|
|
418
|
+
limit,
|
|
419
|
+
}
|
|
420
|
+
const whereFromParams = compileSQL<T>(whereFromOpts)
|
|
421
|
+
promises.push(stream.requestSnapshot(whereFromParams))
|
|
422
|
+
|
|
423
|
+
debug(
|
|
424
|
+
`${collectionId ? `[${collectionId}] ` : ``}Requesting cursor.whereFrom snapshot (with limit ${limit})`,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
// Wait for both requests to complete
|
|
428
|
+
await Promise.all(promises)
|
|
429
|
+
} else {
|
|
430
|
+
// No cursor - standard single request
|
|
431
|
+
const snapshotParams = compileSQL<T>(opts)
|
|
432
|
+
await stream.requestSnapshot(snapshotParams)
|
|
433
|
+
}
|
|
387
434
|
}
|
|
388
435
|
}
|
|
389
436
|
|
|
@@ -400,7 +447,7 @@ export type AwaitTxIdFn = (txId: Txid, timeout?: number) => Promise<boolean>
|
|
|
400
447
|
*/
|
|
401
448
|
export type AwaitMatchFn<T extends Row<unknown>> = (
|
|
402
449
|
matchFn: MatchFunction<T>,
|
|
403
|
-
timeout?: number
|
|
450
|
+
timeout?: number,
|
|
404
451
|
) => Promise<boolean>
|
|
405
452
|
|
|
406
453
|
/**
|
|
@@ -427,7 +474,7 @@ export interface ElectricCollectionUtils<
|
|
|
427
474
|
export function electricCollectionOptions<T extends StandardSchemaV1>(
|
|
428
475
|
config: ElectricCollectionConfig<InferSchemaOutput<T>, T> & {
|
|
429
476
|
schema: T
|
|
430
|
-
}
|
|
477
|
+
},
|
|
431
478
|
): Omit<CollectionConfig<InferSchemaOutput<T>, string | number, T>, `utils`> & {
|
|
432
479
|
id?: string
|
|
433
480
|
utils: ElectricCollectionUtils<InferSchemaOutput<T>>
|
|
@@ -438,7 +485,7 @@ export function electricCollectionOptions<T extends StandardSchemaV1>(
|
|
|
438
485
|
export function electricCollectionOptions<T extends Row<unknown>>(
|
|
439
486
|
config: ElectricCollectionConfig<T> & {
|
|
440
487
|
schema?: never // prohibit schema
|
|
441
|
-
}
|
|
488
|
+
},
|
|
442
489
|
): Omit<CollectionConfig<T, string | number>, `utils`> & {
|
|
443
490
|
id?: string
|
|
444
491
|
utils: ElectricCollectionUtils<T>
|
|
@@ -446,7 +493,7 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
446
493
|
}
|
|
447
494
|
|
|
448
495
|
export function electricCollectionOptions<T extends Row<unknown>>(
|
|
449
|
-
config: ElectricCollectionConfig<T, any
|
|
496
|
+
config: ElectricCollectionConfig<T, any>,
|
|
450
497
|
): Omit<
|
|
451
498
|
CollectionConfig<T, string | number, any, ElectricCollectionUtils<T>>,
|
|
452
499
|
`utils`
|
|
@@ -501,7 +548,7 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
501
548
|
matchesToResolve.push(matchId)
|
|
502
549
|
debug(
|
|
503
550
|
`${config.id ? `[${config.id}] ` : ``}awaitMatch resolved on up-to-date for match %s`,
|
|
504
|
-
matchId
|
|
551
|
+
matchId,
|
|
505
552
|
)
|
|
506
553
|
}
|
|
507
554
|
})
|
|
@@ -527,11 +574,11 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
527
574
|
*/
|
|
528
575
|
const awaitTxId: AwaitTxIdFn = async (
|
|
529
576
|
txId: Txid,
|
|
530
|
-
timeout: number = 5000
|
|
577
|
+
timeout: number = 5000,
|
|
531
578
|
): Promise<boolean> => {
|
|
532
579
|
debug(
|
|
533
580
|
`${config.id ? `[${config.id}] ` : ``}awaitTxId called with txid %d`,
|
|
534
|
-
txId
|
|
581
|
+
txId,
|
|
535
582
|
)
|
|
536
583
|
if (typeof txId !== `number`) {
|
|
537
584
|
throw new ExpectedNumberInAwaitTxIdError(typeof txId, config.id)
|
|
@@ -543,7 +590,7 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
543
590
|
|
|
544
591
|
// Then check if the txid is in any of the seen snapshots
|
|
545
592
|
const hasSnapshot = seenSnapshots.state.some((snapshot) =>
|
|
546
|
-
isVisibleInSnapshot(txId, snapshot)
|
|
593
|
+
isVisibleInSnapshot(txId, snapshot),
|
|
547
594
|
)
|
|
548
595
|
if (hasSnapshot) return true
|
|
549
596
|
|
|
@@ -558,7 +605,7 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
558
605
|
if (seenTxids.state.has(txId)) {
|
|
559
606
|
debug(
|
|
560
607
|
`${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o`,
|
|
561
|
-
txId
|
|
608
|
+
txId,
|
|
562
609
|
)
|
|
563
610
|
clearTimeout(timeoutId)
|
|
564
611
|
unsubscribeSeenTxids()
|
|
@@ -569,13 +616,13 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
569
616
|
|
|
570
617
|
const unsubscribeSeenSnapshots = seenSnapshots.subscribe(() => {
|
|
571
618
|
const visibleSnapshot = seenSnapshots.state.find((snapshot) =>
|
|
572
|
-
isVisibleInSnapshot(txId, snapshot)
|
|
619
|
+
isVisibleInSnapshot(txId, snapshot),
|
|
573
620
|
)
|
|
574
621
|
if (visibleSnapshot) {
|
|
575
622
|
debug(
|
|
576
623
|
`${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o in snapshot %o`,
|
|
577
624
|
txId,
|
|
578
|
-
visibleSnapshot
|
|
625
|
+
visibleSnapshot,
|
|
579
626
|
)
|
|
580
627
|
clearTimeout(timeoutId)
|
|
581
628
|
unsubscribeSeenSnapshots()
|
|
@@ -594,10 +641,10 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
594
641
|
*/
|
|
595
642
|
const awaitMatch: AwaitMatchFn<any> = async (
|
|
596
643
|
matchFn: MatchFunction<any>,
|
|
597
|
-
timeout: number = 3000
|
|
644
|
+
timeout: number = 3000,
|
|
598
645
|
): Promise<boolean> => {
|
|
599
646
|
debug(
|
|
600
|
-
`${config.id ? `[${config.id}] ` : ``}awaitMatch called with custom function
|
|
647
|
+
`${config.id ? `[${config.id}] ` : ``}awaitMatch called with custom function`,
|
|
601
648
|
)
|
|
602
649
|
|
|
603
650
|
return new Promise((resolve, reject) => {
|
|
@@ -623,7 +670,7 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
623
670
|
const checkMatch = (message: Message<any>) => {
|
|
624
671
|
if (matchFn(message)) {
|
|
625
672
|
debug(
|
|
626
|
-
`${config.id ? `[${config.id}] ` : ``}awaitMatch found matching message, waiting for up-to-date
|
|
673
|
+
`${config.id ? `[${config.id}] ` : ``}awaitMatch found matching message, waiting for up-to-date`,
|
|
627
674
|
)
|
|
628
675
|
// Mark as matched but don't resolve yet - wait for up-to-date
|
|
629
676
|
pendingMatches.setState((current) => {
|
|
@@ -643,7 +690,7 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
643
690
|
for (const message of currentBatchMessages.state) {
|
|
644
691
|
if (matchFn(message)) {
|
|
645
692
|
debug(
|
|
646
|
-
`${config.id ? `[${config.id}] ` : ``}awaitMatch found immediate match in current batch, waiting for up-to-date
|
|
693
|
+
`${config.id ? `[${config.id}] ` : ``}awaitMatch found immediate match in current batch, waiting for up-to-date`,
|
|
647
694
|
)
|
|
648
695
|
// Register match as already matched
|
|
649
696
|
pendingMatches.setState((current) => {
|
|
@@ -681,7 +728,7 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
681
728
|
* Process matching strategy and wait for synchronization
|
|
682
729
|
*/
|
|
683
730
|
const processMatchingStrategy = async (
|
|
684
|
-
result: MatchingStrategy
|
|
731
|
+
result: MatchingStrategy,
|
|
685
732
|
): Promise<void> => {
|
|
686
733
|
// Only wait if result contains txid
|
|
687
734
|
if (result && `txid` in result) {
|
|
@@ -703,7 +750,7 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
703
750
|
any,
|
|
704
751
|
string | number,
|
|
705
752
|
ElectricCollectionUtils<T>
|
|
706
|
-
|
|
753
|
+
>,
|
|
707
754
|
) => {
|
|
708
755
|
const handlerResult = await config.onInsert!(params)
|
|
709
756
|
await processMatchingStrategy(handlerResult)
|
|
@@ -717,7 +764,7 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
717
764
|
any,
|
|
718
765
|
string | number,
|
|
719
766
|
ElectricCollectionUtils<T>
|
|
720
|
-
|
|
767
|
+
>,
|
|
721
768
|
) => {
|
|
722
769
|
const handlerResult = await config.onUpdate!(params)
|
|
723
770
|
await processMatchingStrategy(handlerResult)
|
|
@@ -731,7 +778,7 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
731
778
|
any,
|
|
732
779
|
string | number,
|
|
733
780
|
ElectricCollectionUtils<T>
|
|
734
|
-
|
|
781
|
+
>,
|
|
735
782
|
) => {
|
|
736
783
|
const handlerResult = await config.onDelete!(params)
|
|
737
784
|
await processMatchingStrategy(handlerResult)
|
|
@@ -788,7 +835,7 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
788
835
|
resolveMatchedPendingMatches: () => void
|
|
789
836
|
collectionId?: string
|
|
790
837
|
testHooks?: ElectricTestHooks
|
|
791
|
-
}
|
|
838
|
+
},
|
|
792
839
|
): SyncConfig<T> {
|
|
793
840
|
const {
|
|
794
841
|
seenTxids,
|
|
@@ -858,7 +905,7 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
858
905
|
},
|
|
859
906
|
{
|
|
860
907
|
once: true,
|
|
861
|
-
}
|
|
908
|
+
},
|
|
862
909
|
)
|
|
863
910
|
if (shapeOptions.signal.aborted) {
|
|
864
911
|
abortController.abort()
|
|
@@ -900,7 +947,7 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
900
947
|
`An error occurred while syncing collection: ${collection.id}, \n` +
|
|
901
948
|
`it has been marked as ready to avoid blocking apps waiting for '.preload()' to finish. \n` +
|
|
902
949
|
`You can provide an 'onError' handler on the shapeOptions to handle this error, and this message will not be logged.`,
|
|
903
|
-
errorParams
|
|
950
|
+
errorParams,
|
|
904
951
|
)
|
|
905
952
|
}
|
|
906
953
|
|
|
@@ -965,7 +1012,7 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
965
1012
|
// If matchFn throws, clean up and reject the promise
|
|
966
1013
|
clearTimeout(match.timeoutId)
|
|
967
1014
|
match.reject(
|
|
968
|
-
err instanceof Error ? err : new Error(String(err))
|
|
1015
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
969
1016
|
)
|
|
970
1017
|
matchesToRemove.push(matchId)
|
|
971
1018
|
debug(`matchFn error: %o`, err)
|
|
@@ -1013,7 +1060,7 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1013
1060
|
hasUpToDate = true
|
|
1014
1061
|
} else if (isMustRefetchMessage(message)) {
|
|
1015
1062
|
debug(
|
|
1016
|
-
`${collectionId ? `[${collectionId}] ` : ``}Received must-refetch message, starting transaction with truncate
|
|
1063
|
+
`${collectionId ? `[${collectionId}] ` : ``}Received must-refetch message, starting transaction with truncate`,
|
|
1017
1064
|
)
|
|
1018
1065
|
|
|
1019
1066
|
// Start a transaction and truncate the collection
|
|
@@ -1040,7 +1087,7 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1040
1087
|
// PROGRESSIVE MODE: Atomic swap on first up-to-date
|
|
1041
1088
|
if (isBufferingInitialSync() && hasUpToDate) {
|
|
1042
1089
|
debug(
|
|
1043
|
-
`${collectionId ? `[${collectionId}] ` : ``}Progressive mode: Performing atomic swap with ${bufferedMessages.length} buffered messages
|
|
1090
|
+
`${collectionId ? `[${collectionId}] ` : ``}Progressive mode: Performing atomic swap with ${bufferedMessages.length} buffered messages`,
|
|
1044
1091
|
)
|
|
1045
1092
|
|
|
1046
1093
|
// Start atomic swap transaction
|
|
@@ -1063,7 +1110,7 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1063
1110
|
// Extract txids from buffered messages (will be committed to store after transaction)
|
|
1064
1111
|
if (hasTxids(bufferedMsg)) {
|
|
1065
1112
|
bufferedMsg.headers.txids?.forEach((txid) =>
|
|
1066
|
-
newTxids.add(txid)
|
|
1113
|
+
newTxids.add(txid),
|
|
1067
1114
|
)
|
|
1068
1115
|
}
|
|
1069
1116
|
} else if (isSnapshotEndMessage(bufferedMsg)) {
|
|
@@ -1080,7 +1127,7 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1080
1127
|
bufferedMessages.length = 0
|
|
1081
1128
|
|
|
1082
1129
|
debug(
|
|
1083
|
-
`${collectionId ? `[${collectionId}] ` : ``}Progressive mode: Atomic swap complete, now in normal sync mode
|
|
1130
|
+
`${collectionId ? `[${collectionId}] ` : ``}Progressive mode: Atomic swap complete, now in normal sync mode`,
|
|
1084
1131
|
)
|
|
1085
1132
|
} else {
|
|
1086
1133
|
// Normal mode or on-demand: commit transaction if one was started
|
|
@@ -1115,7 +1162,7 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1115
1162
|
if (newTxids.size > 0) {
|
|
1116
1163
|
debug(
|
|
1117
1164
|
`${collectionId ? `[${collectionId}] ` : ``}new txids synced from pg %O`,
|
|
1118
|
-
Array.from(newTxids)
|
|
1165
|
+
Array.from(newTxids),
|
|
1119
1166
|
)
|
|
1120
1167
|
}
|
|
1121
1168
|
newTxids.forEach((txid) => clonedSeen.add(txid))
|
|
@@ -1129,8 +1176,8 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1129
1176
|
newSnapshots.forEach((snapshot) =>
|
|
1130
1177
|
debug(
|
|
1131
1178
|
`${collectionId ? `[${collectionId}] ` : ``}new snapshot synced from pg %o`,
|
|
1132
|
-
snapshot
|
|
1133
|
-
)
|
|
1179
|
+
snapshot,
|
|
1180
|
+
),
|
|
1134
1181
|
)
|
|
1135
1182
|
newSnapshots.length = 0
|
|
1136
1183
|
return seen
|
package/src/errors.ts
CHANGED
package/src/index.ts
CHANGED
package/src/sql-compiler.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { serialize } from
|
|
2
|
-
import type { SubsetParams } from
|
|
3
|
-
import type { IR, LoadSubsetOptions } from
|
|
1
|
+
import { serialize } from './pg-serializer'
|
|
2
|
+
import type { SubsetParams } from '@electric-sql/client'
|
|
3
|
+
import type { IR, LoadSubsetOptions } from '@tanstack/db'
|
|
4
4
|
|
|
5
5
|
export type CompiledSqlRecord = Omit<SubsetParams, `params`> & {
|
|
6
6
|
params?: Array<unknown>
|
|
@@ -45,7 +45,7 @@ export function compileSQL<T>(options: LoadSubsetOptions): SubsetParams {
|
|
|
45
45
|
}
|
|
46
46
|
return acc
|
|
47
47
|
},
|
|
48
|
-
{} as Record<string, string
|
|
48
|
+
{} as Record<string, string>,
|
|
49
49
|
)
|
|
50
50
|
|
|
51
51
|
return {
|
|
@@ -72,7 +72,7 @@ function quoteIdentifier(name: string): string {
|
|
|
72
72
|
*/
|
|
73
73
|
function compileBasicExpression(
|
|
74
74
|
exp: IR.BasicExpression<unknown>,
|
|
75
|
-
params: Array<unknown
|
|
75
|
+
params: Array<unknown>,
|
|
76
76
|
): string {
|
|
77
77
|
switch (exp.type) {
|
|
78
78
|
case `val`:
|
|
@@ -82,7 +82,7 @@ function compileBasicExpression(
|
|
|
82
82
|
// TODO: doesn't yet support JSON(B) values which could be accessed with nested props
|
|
83
83
|
if (exp.path.length !== 1) {
|
|
84
84
|
throw new Error(
|
|
85
|
-
`Compiler can't handle nested properties: ${exp.path.join(`.`)}
|
|
85
|
+
`Compiler can't handle nested properties: ${exp.path.join(`.`)}`,
|
|
86
86
|
)
|
|
87
87
|
}
|
|
88
88
|
return quoteIdentifier(exp.path[0]!)
|
|
@@ -95,14 +95,14 @@ function compileBasicExpression(
|
|
|
95
95
|
|
|
96
96
|
function compileOrderBy(orderBy: IR.OrderBy, params: Array<unknown>): string {
|
|
97
97
|
const compiledOrderByClauses = orderBy.map((clause: IR.OrderByClause) =>
|
|
98
|
-
compileOrderByClause(clause, params)
|
|
98
|
+
compileOrderByClause(clause, params),
|
|
99
99
|
)
|
|
100
100
|
return compiledOrderByClauses.join(`,`)
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
function compileOrderByClause(
|
|
104
104
|
clause: IR.OrderByClause,
|
|
105
|
-
params: Array<unknown
|
|
105
|
+
params: Array<unknown>,
|
|
106
106
|
): string {
|
|
107
107
|
// FIXME: We should handle stringSort and locale.
|
|
108
108
|
// Correctly supporting them is tricky as it depends on Postgres' collation
|
|
@@ -126,14 +126,14 @@ function compileOrderByClause(
|
|
|
126
126
|
|
|
127
127
|
function compileFunction(
|
|
128
128
|
exp: IR.Func<unknown>,
|
|
129
|
-
params: Array<unknown> = []
|
|
129
|
+
params: Array<unknown> = [],
|
|
130
130
|
): string {
|
|
131
131
|
const { name, args } = exp
|
|
132
132
|
|
|
133
133
|
const opName = getOpName(name)
|
|
134
134
|
|
|
135
135
|
const compiledArgs = args.map((arg: IR.BasicExpression) =>
|
|
136
|
-
compileBasicExpression(arg, params)
|
|
136
|
+
compileBasicExpression(arg, params),
|
|
137
137
|
)
|
|
138
138
|
|
|
139
139
|
// Special case for IS NULL / IS NOT NULL - these are postfix operators
|
|
@@ -172,6 +172,120 @@ function compileFunction(
|
|
|
172
172
|
throw new Error(`Binary operator ${name} expects 2 arguments`)
|
|
173
173
|
}
|
|
174
174
|
const [lhs, rhs] = compiledArgs
|
|
175
|
+
|
|
176
|
+
// Special case for comparison operators with boolean values
|
|
177
|
+
// PostgreSQL doesn't support < > <= >= on booleans
|
|
178
|
+
// Transform to equivalent equality checks or constant expressions
|
|
179
|
+
if (isComparisonOp(name)) {
|
|
180
|
+
const lhsArg = args[0]
|
|
181
|
+
const rhsArg = args[1]
|
|
182
|
+
|
|
183
|
+
// Check if RHS is a boolean literal value
|
|
184
|
+
if (
|
|
185
|
+
rhsArg &&
|
|
186
|
+
rhsArg.type === `val` &&
|
|
187
|
+
typeof rhsArg.value === `boolean`
|
|
188
|
+
) {
|
|
189
|
+
const boolValue = rhsArg.value
|
|
190
|
+
// Remove the boolean param we just added since we'll transform the expression
|
|
191
|
+
params.pop()
|
|
192
|
+
|
|
193
|
+
// Transform based on operator and boolean value
|
|
194
|
+
// Boolean ordering: false < true
|
|
195
|
+
if (name === `lt`) {
|
|
196
|
+
if (boolValue === true) {
|
|
197
|
+
// lt(col, true) → col = false (only false is less than true)
|
|
198
|
+
params.push(false)
|
|
199
|
+
return `${lhs} = $${params.length}`
|
|
200
|
+
} else {
|
|
201
|
+
// lt(col, false) → nothing is less than false
|
|
202
|
+
return `false`
|
|
203
|
+
}
|
|
204
|
+
} else if (name === `gt`) {
|
|
205
|
+
if (boolValue === false) {
|
|
206
|
+
// gt(col, false) → col = true (only true is greater than false)
|
|
207
|
+
params.push(true)
|
|
208
|
+
return `${lhs} = $${params.length}`
|
|
209
|
+
} else {
|
|
210
|
+
// gt(col, true) → nothing is greater than true
|
|
211
|
+
return `false`
|
|
212
|
+
}
|
|
213
|
+
} else if (name === `lte`) {
|
|
214
|
+
if (boolValue === true) {
|
|
215
|
+
// lte(col, true) → everything is ≤ true
|
|
216
|
+
return `true`
|
|
217
|
+
} else {
|
|
218
|
+
// lte(col, false) → col = false
|
|
219
|
+
params.push(false)
|
|
220
|
+
return `${lhs} = $${params.length}`
|
|
221
|
+
}
|
|
222
|
+
} else if (name === `gte`) {
|
|
223
|
+
if (boolValue === false) {
|
|
224
|
+
// gte(col, false) → everything is ≥ false
|
|
225
|
+
return `true`
|
|
226
|
+
} else {
|
|
227
|
+
// gte(col, true) → col = true
|
|
228
|
+
params.push(true)
|
|
229
|
+
return `${lhs} = $${params.length}`
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Check if LHS is a boolean literal value (less common but handle it)
|
|
235
|
+
if (
|
|
236
|
+
lhsArg &&
|
|
237
|
+
lhsArg.type === `val` &&
|
|
238
|
+
typeof lhsArg.value === `boolean`
|
|
239
|
+
) {
|
|
240
|
+
const boolValue = lhsArg.value
|
|
241
|
+
// Remove params for this expression and rebuild
|
|
242
|
+
params.pop() // remove RHS
|
|
243
|
+
params.pop() // remove LHS (boolean)
|
|
244
|
+
|
|
245
|
+
// Recompile RHS to get fresh param
|
|
246
|
+
const rhsCompiled = compileBasicExpression(rhsArg!, params)
|
|
247
|
+
|
|
248
|
+
// Transform: flip the comparison (val op col → col flipped_op val)
|
|
249
|
+
if (name === `lt`) {
|
|
250
|
+
// lt(true, col) → gt(col, true) → col > true → nothing is greater than true
|
|
251
|
+
if (boolValue === true) {
|
|
252
|
+
return `false`
|
|
253
|
+
} else {
|
|
254
|
+
// lt(false, col) → gt(col, false) → col = true
|
|
255
|
+
params.push(true)
|
|
256
|
+
return `${rhsCompiled} = $${params.length}`
|
|
257
|
+
}
|
|
258
|
+
} else if (name === `gt`) {
|
|
259
|
+
// gt(true, col) → lt(col, true) → col = false
|
|
260
|
+
if (boolValue === true) {
|
|
261
|
+
params.push(false)
|
|
262
|
+
return `${rhsCompiled} = $${params.length}`
|
|
263
|
+
} else {
|
|
264
|
+
// gt(false, col) → lt(col, false) → nothing is less than false
|
|
265
|
+
return `false`
|
|
266
|
+
}
|
|
267
|
+
} else if (name === `lte`) {
|
|
268
|
+
if (boolValue === false) {
|
|
269
|
+
// lte(false, col) → gte(col, false) → everything
|
|
270
|
+
return `true`
|
|
271
|
+
} else {
|
|
272
|
+
// lte(true, col) → gte(col, true) → col = true
|
|
273
|
+
params.push(true)
|
|
274
|
+
return `${rhsCompiled} = $${params.length}`
|
|
275
|
+
}
|
|
276
|
+
} else if (name === `gte`) {
|
|
277
|
+
if (boolValue === true) {
|
|
278
|
+
// gte(true, col) → lte(col, true) → everything
|
|
279
|
+
return `true`
|
|
280
|
+
} else {
|
|
281
|
+
// gte(false, col) → lte(col, false) → col = false
|
|
282
|
+
params.push(false)
|
|
283
|
+
return `${rhsCompiled} = $${params.length}`
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
175
289
|
// Special case for = ANY operator which needs parentheses around the array parameter
|
|
176
290
|
if (name === `in`) {
|
|
177
291
|
return `${lhs} ${opName}(${rhs})`
|
|
@@ -198,6 +312,14 @@ function isBinaryOp(name: string): boolean {
|
|
|
198
312
|
return binaryOps.includes(name)
|
|
199
313
|
}
|
|
200
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Checks if the operator is a comparison operator (excluding eq)
|
|
317
|
+
* These operators don't work on booleans in PostgreSQL without casting
|
|
318
|
+
*/
|
|
319
|
+
function isComparisonOp(name: string): boolean {
|
|
320
|
+
return [`gt`, `gte`, `lt`, `lte`].includes(name)
|
|
321
|
+
}
|
|
322
|
+
|
|
201
323
|
function getOpName(name: string): string {
|
|
202
324
|
const opNames = {
|
|
203
325
|
eq: `=`,
|