@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/src/electric.ts CHANGED
@@ -3,17 +3,17 @@ import {
3
3
  isChangeMessage,
4
4
  isControlMessage,
5
5
  isVisibleInSnapshot,
6
- } from "@electric-sql/client"
7
- import { Store } from "@tanstack/store"
8
- import DebugModule from "debug"
9
- import { DeduplicatedLoadSubset } from "@tanstack/db"
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 "./errors"
16
- import { compileSQL } from "./sql-compiler"
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 "@tanstack/db"
28
- import type { StandardSchemaV1 } from "@standard-schema/spec"
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 "@electric-sql/client"
36
+ } from '@electric-sql/client'
37
37
 
38
38
  // Re-export for user convenience in custom match functions
39
- export { isChangeMessage, isControlMessage } from "@electric-sql/client"
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
- const snapshotParams = compileSQL<T>(opts)
386
- await stream.requestSnapshot(snapshotParams)
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
@@ -1,4 +1,4 @@
1
- import { TanStackDBError } from "@tanstack/db"
1
+ import { TanStackDBError } from '@tanstack/db'
2
2
 
3
3
  // Electric DB Collection Errors
4
4
  export class ElectricDBCollectionError extends TanStackDBError {
package/src/index.ts CHANGED
@@ -4,6 +4,6 @@ export {
4
4
  type ElectricCollectionUtils,
5
5
  type Txid,
6
6
  type AwaitTxIdFn,
7
- } from "./electric"
7
+ } from './electric'
8
8
 
9
- export * from "./errors"
9
+ export * from './errors'
@@ -1,6 +1,6 @@
1
- import { serialize } from "./pg-serializer"
2
- import type { SubsetParams } from "@electric-sql/client"
3
- import type { IR, LoadSubsetOptions } from "@tanstack/db"
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: `=`,