@electric-sql/client 1.3.1 → 1.4.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@electric-sql/client",
3
3
  "description": "Postgres everywhere - your data, in sync, wherever you need it.",
4
- "version": "1.3.1",
4
+ "version": "1.4.1",
5
5
  "author": "ElectricSQL team and contributors.",
6
6
  "bugs": {
7
7
  "url": "https://github.com/electric-sql/electric/issues"
package/src/client.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  MissingShapeHandleError,
25
25
  ReservedParamError,
26
26
  MissingHeadersError,
27
+ StaleCacheError,
27
28
  } from './error'
28
29
  import {
29
30
  BackoffDefaults,
@@ -59,7 +60,11 @@ import {
59
60
  SUBSET_PARAM_LIMIT,
60
61
  SUBSET_PARAM_OFFSET,
61
62
  SUBSET_PARAM_ORDER_BY,
63
+ SUBSET_PARAM_WHERE_EXPR,
64
+ SUBSET_PARAM_ORDER_BY_EXPR,
65
+ CACHE_BUSTER_QUERY_PARAM,
62
66
  } from './constants'
67
+ import { compileExpression, compileOrderBy } from './expression-compiler'
63
68
  import {
64
69
  EventSourceMessage,
65
70
  fetchEventSource,
@@ -73,6 +78,7 @@ const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
73
78
  SHAPE_HANDLE_QUERY_PARAM,
74
79
  LIVE_QUERY_PARAM,
75
80
  OFFSET_QUERY_PARAM,
81
+ CACHE_BUSTER_QUERY_PARAM,
76
82
  ])
77
83
 
78
84
  type Replica = `full` | `default`
@@ -138,6 +144,7 @@ type ReservedParamKeys =
138
144
  | typeof SHAPE_HANDLE_QUERY_PARAM
139
145
  | typeof LIVE_QUERY_PARAM
140
146
  | typeof OFFSET_QUERY_PARAM
147
+ | typeof CACHE_BUSTER_QUERY_PARAM
141
148
  | `subset__${string}`
142
149
 
143
150
  /**
@@ -570,6 +577,9 @@ export class ShapeStream<T extends Row<unknown> = Row>
570
577
  #sseBackoffBaseDelay = 100 // Base delay for exponential backoff (ms)
571
578
  #sseBackoffMaxDelay = 5000 // Maximum delay cap (ms)
572
579
  #unsubscribeFromVisibilityChanges?: () => void
580
+ #staleCacheBuster?: string // Cache buster set when stale CDN response detected, used on retry requests to bypass cache
581
+ #staleCacheRetryCount = 0
582
+ #maxStaleCacheRetries = 3
573
583
 
574
584
  // Derived state: we're in replay mode if we have a last seen cursor
575
585
  get #replayMode(): boolean {
@@ -669,7 +679,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
669
679
  if (this.#onError) {
670
680
  const retryOpts = await this.#onError(err as Error)
671
681
  // Guard against null (typeof null === "object" in JavaScript)
672
- if (retryOpts && typeof retryOpts === `object`) {
682
+ const isRetryable = !(err instanceof MissingHeadersError)
683
+ if (retryOpts && typeof retryOpts === `object` && isRetryable) {
673
684
  // Update params/headers but don't reset offset
674
685
  // We want to continue from where we left off, not refetch everything
675
686
  if (retryOpts.params) {
@@ -780,6 +791,15 @@ export class ShapeStream<T extends Row<unknown> = Row>
780
791
  }
781
792
  return // interrupted
782
793
  }
794
+
795
+ if (e instanceof StaleCacheError) {
796
+ // Received a stale cached response from CDN with an expired handle.
797
+ // The #staleCacheBuster has been set in #onInitialResponse, so retry
798
+ // the request which will include a random cache buster to bypass the
799
+ // misconfigured CDN cache.
800
+ return this.#requestShape()
801
+ }
802
+
783
803
  if (!(e instanceof FetchError)) throw e // should never happen
784
804
 
785
805
  if (e.status == 409) {
@@ -890,13 +910,29 @@ export class ShapeStream<T extends Row<unknown> = Row>
890
910
  }
891
911
 
892
912
  if (subsetParams) {
893
- if (subsetParams.where && typeof subsetParams.where === `string`) {
913
+ // Prefer structured expressions when available (allows proper columnMapper application)
914
+ // Fall back to legacy string format for backwards compatibility
915
+ if (subsetParams.whereExpr) {
916
+ // Compile structured expression with columnMapper applied
917
+ const compiledWhere = compileExpression(
918
+ subsetParams.whereExpr,
919
+ this.options.columnMapper?.encode
920
+ )
921
+ setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, compiledWhere)
922
+ // Also send the structured expression for servers that support it
923
+ fetchUrl.searchParams.set(
924
+ SUBSET_PARAM_WHERE_EXPR,
925
+ JSON.stringify(subsetParams.whereExpr)
926
+ )
927
+ } else if (subsetParams.where && typeof subsetParams.where === `string`) {
928
+ // Legacy string format (no columnMapper applied to already-compiled SQL)
894
929
  const encodedWhere = encodeWhereClause(
895
930
  subsetParams.where,
896
931
  this.options.columnMapper?.encode
897
932
  )
898
933
  setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, encodedWhere)
899
934
  }
935
+
900
936
  if (subsetParams.params)
901
937
  // Serialize params as JSON to keep the parameter name constant for proxy configs
902
938
  fetchUrl.searchParams.set(
@@ -907,7 +943,25 @@ export class ShapeStream<T extends Row<unknown> = Row>
907
943
  setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit)
908
944
  if (subsetParams.offset)
909
945
  setQueryParam(fetchUrl, SUBSET_PARAM_OFFSET, subsetParams.offset)
910
- if (subsetParams.orderBy && typeof subsetParams.orderBy === `string`) {
946
+
947
+ // Prefer structured ORDER BY expressions when available
948
+ if (subsetParams.orderByExpr) {
949
+ // Compile structured ORDER BY with columnMapper applied
950
+ const compiledOrderBy = compileOrderBy(
951
+ subsetParams.orderByExpr,
952
+ this.options.columnMapper?.encode
953
+ )
954
+ setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, compiledOrderBy)
955
+ // Also send the structured expression for servers that support it
956
+ fetchUrl.searchParams.set(
957
+ SUBSET_PARAM_ORDER_BY_EXPR,
958
+ JSON.stringify(subsetParams.orderByExpr)
959
+ )
960
+ } else if (
961
+ subsetParams.orderBy &&
962
+ typeof subsetParams.orderBy === `string`
963
+ ) {
964
+ // Legacy string format
911
965
  const encodedOrderBy = encodeWhereClause(
912
966
  subsetParams.orderBy,
913
967
  this.options.columnMapper?.encode
@@ -948,6 +1002,15 @@ export class ShapeStream<T extends Row<unknown> = Row>
948
1002
  fetchUrl.searchParams.set(EXPIRED_HANDLE_QUERY_PARAM, expiredHandle)
949
1003
  }
950
1004
 
1005
+ // Add random cache buster if we received a stale response from CDN
1006
+ // This forces a fresh request bypassing the misconfigured CDN cache
1007
+ if (this.#staleCacheBuster) {
1008
+ fetchUrl.searchParams.set(
1009
+ CACHE_BUSTER_QUERY_PARAM,
1010
+ this.#staleCacheBuster
1011
+ )
1012
+ }
1013
+
951
1014
  // sort query params in-place for stable URLs and improved cache hits
952
1015
  fetchUrl.searchParams.sort()
953
1016
 
@@ -993,6 +1056,46 @@ export class ShapeStream<T extends Row<unknown> = Row>
993
1056
  : null
994
1057
  if (shapeHandle !== expiredHandle) {
995
1058
  this.#shapeHandle = shapeHandle
1059
+ // Clear cache buster after successful response with valid handle
1060
+ if (this.#staleCacheBuster) {
1061
+ this.#staleCacheBuster = undefined
1062
+ this.#staleCacheRetryCount = 0
1063
+ }
1064
+ } else if (this.#shapeHandle === undefined) {
1065
+ // We received a stale response from cache and don't have a handle yet.
1066
+ // Instead of accepting the stale handle, throw an error to trigger a retry
1067
+ // with a random cache buster to bypass the CDN cache.
1068
+ this.#staleCacheRetryCount++
1069
+ // Cancel the response body to release the connection before retrying
1070
+ await response.body?.cancel()
1071
+ if (this.#staleCacheRetryCount > this.#maxStaleCacheRetries) {
1072
+ throw new FetchError(
1073
+ 502,
1074
+ undefined,
1075
+ undefined,
1076
+ {},
1077
+ this.#currentFetchUrl?.toString() ?? ``,
1078
+ `CDN continues serving stale cached responses after ${this.#maxStaleCacheRetries} retry attempts. ` +
1079
+ `This indicates a severe proxy/CDN misconfiguration. ` +
1080
+ `Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
1081
+ `For more information visit the troubleshooting guide: https://electric-sql.com/docs/guides/troubleshooting`
1082
+ )
1083
+ }
1084
+ console.warn(
1085
+ `[Electric] Received stale cached response with expired shape handle. ` +
1086
+ `This should not happen and indicates a proxy/CDN caching misconfiguration. ` +
1087
+ `The response contained handle "${shapeHandle}" which was previously marked as expired. ` +
1088
+ `Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
1089
+ `For more information visit the troubleshooting guide: https://electric-sql.com/docs/guides/troubleshooting ` +
1090
+ `Retrying with a random cache buster to bypass the stale cache (attempt ${this.#staleCacheRetryCount}/${this.#maxStaleCacheRetries}).`
1091
+ )
1092
+ // Generate a random cache buster for the retry
1093
+ this.#staleCacheBuster = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
1094
+ throw new StaleCacheError(
1095
+ `Received stale cached response with expired handle "${shapeHandle}". ` +
1096
+ `This indicates a proxy/CDN caching misconfiguration. ` +
1097
+ `Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key.`
1098
+ )
996
1099
  } else {
997
1100
  console.warn(
998
1101
  `[Electric] Received stale cached response with expired shape handle. ` +
@@ -1453,6 +1556,9 @@ export class ShapeStream<T extends Row<unknown> = Row>
1453
1556
  // Reset SSE fallback state to try SSE again after reset
1454
1557
  this.#consecutiveShortSseConnections = 0
1455
1558
  this.#sseFallbackToLongPolling = false
1559
+ // Reset stale cache retry state
1560
+ this.#staleCacheBuster = undefined
1561
+ this.#staleCacheRetryCount = 0
1456
1562
  }
1457
1563
 
1458
1564
  /**
package/src/constants.ts CHANGED
@@ -26,6 +26,9 @@ export const SUBSET_PARAM_LIMIT = `subset__limit`
26
26
  export const SUBSET_PARAM_OFFSET = `subset__offset`
27
27
  export const SUBSET_PARAM_ORDER_BY = `subset__order_by`
28
28
  export const SUBSET_PARAM_WHERE_PARAMS = `subset__params`
29
+ export const SUBSET_PARAM_WHERE_EXPR = `subset__where_expr`
30
+ export const SUBSET_PARAM_ORDER_BY_EXPR = `subset__order_by_expr`
31
+ export const CACHE_BUSTER_QUERY_PARAM = `cache-buster` // Random cache buster to bypass stale CDN responses
29
32
 
30
33
  // Query parameters that should be passed through when proxying Electric requests
31
34
  export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
@@ -41,4 +44,7 @@ export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
41
44
  SUBSET_PARAM_OFFSET,
42
45
  SUBSET_PARAM_ORDER_BY,
43
46
  SUBSET_PARAM_WHERE_PARAMS,
47
+ SUBSET_PARAM_WHERE_EXPR,
48
+ SUBSET_PARAM_ORDER_BY_EXPR,
49
+ CACHE_BUSTER_QUERY_PARAM,
44
50
  ]
package/src/error.ts CHANGED
@@ -116,3 +116,10 @@ export class MissingHeadersError extends Error {
116
116
  super(msg)
117
117
  }
118
118
  }
119
+
120
+ export class StaleCacheError extends Error {
121
+ constructor(message: string) {
122
+ super(message)
123
+ this.name = `StaleCacheError`
124
+ }
125
+ }
@@ -0,0 +1,132 @@
1
+ import { SerializedExpression, SerializedOrderByClause } from './types'
2
+ import { quoteIdentifier } from './column-mapper'
3
+
4
+ /**
5
+ * Compiles a serialized expression into a SQL string.
6
+ * Applies columnMapper transformations to column references.
7
+ *
8
+ * @param expr - The serialized expression to compile
9
+ * @param columnMapper - Optional function to transform column names (e.g., camelCase to snake_case)
10
+ * @returns The compiled SQL string
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * const expr = { type: 'ref', column: 'userId' }
15
+ * compileExpression(expr, camelToSnake) // '"user_id"'
16
+ * ```
17
+ */
18
+ export function compileExpression(
19
+ expr: SerializedExpression,
20
+ columnMapper?: (col: string) => string
21
+ ): string {
22
+ switch (expr.type) {
23
+ case `ref`: {
24
+ // Apply columnMapper, then quote
25
+ const mappedColumn = columnMapper
26
+ ? columnMapper(expr.column)
27
+ : expr.column
28
+ return quoteIdentifier(mappedColumn)
29
+ }
30
+ case `val`:
31
+ return `$${expr.paramIndex}`
32
+ case `func`:
33
+ return compileFunction(expr, columnMapper)
34
+ default: {
35
+ // TypeScript exhaustiveness check
36
+ const _exhaustive: never = expr
37
+ throw new Error(`Unknown expression type: ${JSON.stringify(_exhaustive)}`)
38
+ }
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Compiles a function expression into SQL.
44
+ */
45
+ function compileFunction(
46
+ expr: { type: `func`; name: string; args: SerializedExpression[] },
47
+ columnMapper?: (col: string) => string
48
+ ): string {
49
+ const args = expr.args.map((arg) => compileExpression(arg, columnMapper))
50
+
51
+ switch (expr.name) {
52
+ // Binary comparison operators
53
+ case `eq`:
54
+ return `${args[0]} = ${args[1]}`
55
+ case `gt`:
56
+ return `${args[0]} > ${args[1]}`
57
+ case `gte`:
58
+ return `${args[0]} >= ${args[1]}`
59
+ case `lt`:
60
+ return `${args[0]} < ${args[1]}`
61
+ case `lte`:
62
+ return `${args[0]} <= ${args[1]}`
63
+
64
+ // Logical operators
65
+ case `and`:
66
+ return args.map((a) => `(${a})`).join(` AND `)
67
+ case `or`:
68
+ return args.map((a) => `(${a})`).join(` OR `)
69
+ case `not`:
70
+ return `NOT (${args[0]})`
71
+
72
+ // Special operators
73
+ case `in`:
74
+ return `${args[0]} = ANY(${args[1]})`
75
+ case `like`:
76
+ return `${args[0]} LIKE ${args[1]}`
77
+ case `ilike`:
78
+ return `${args[0]} ILIKE ${args[1]}`
79
+ case `isNull`:
80
+ case `isUndefined`:
81
+ return `${args[0]} IS NULL`
82
+
83
+ // String functions
84
+ case `upper`:
85
+ return `UPPER(${args[0]})`
86
+ case `lower`:
87
+ return `LOWER(${args[0]})`
88
+ case `length`:
89
+ return `LENGTH(${args[0]})`
90
+ case `concat`:
91
+ return `CONCAT(${args.join(`, `)})`
92
+
93
+ // Other functions
94
+ case `coalesce`:
95
+ return `COALESCE(${args.join(`, `)})`
96
+
97
+ default:
98
+ throw new Error(`Unknown function: ${expr.name}`)
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Compiles serialized ORDER BY clauses into a SQL string.
104
+ * Applies columnMapper transformations to column references.
105
+ *
106
+ * @param clauses - The serialized ORDER BY clauses to compile
107
+ * @param columnMapper - Optional function to transform column names
108
+ * @returns The compiled SQL ORDER BY string
109
+ *
110
+ * @example
111
+ * ```typescript
112
+ * const clauses = [{ column: 'createdAt', direction: 'desc', nulls: 'first' }]
113
+ * compileOrderBy(clauses, camelToSnake) // '"created_at" DESC NULLS FIRST'
114
+ * ```
115
+ */
116
+ export function compileOrderBy(
117
+ clauses: SerializedOrderByClause[],
118
+ columnMapper?: (col: string) => string
119
+ ): string {
120
+ return clauses
121
+ .map((clause) => {
122
+ const mappedColumn = columnMapper
123
+ ? columnMapper(clause.column)
124
+ : clause.column
125
+ let sql = quoteIdentifier(mappedColumn)
126
+ if (clause.direction === `desc`) sql += ` DESC`
127
+ if (clause.nulls === `first`) sql += ` NULLS FIRST`
128
+ if (clause.nulls === `last`) sql += ` NULLS LAST`
129
+ return sql
130
+ })
131
+ .join(`, `)
132
+ }
package/src/index.ts CHANGED
@@ -16,3 +16,4 @@ export {
16
16
  snakeToCamel,
17
17
  camelToSnake,
18
18
  } from './column-mapper'
19
+ export { compileExpression, compileOrderBy } from './expression-compiler'
package/src/types.ts CHANGED
@@ -68,12 +68,40 @@ export type MoveTag = string
68
68
  */
69
69
  export type MoveOutPattern = { pos: number; value: string }
70
70
 
71
+ /**
72
+ * Serialized expression types for structured subset queries.
73
+ * These allow Electric to properly apply columnMapper transformations
74
+ * before generating the final SQL.
75
+ */
76
+ export type SerializedExpression =
77
+ | { type: `ref`; column: string } // Column reference
78
+ | { type: `val`; paramIndex: number } // Parameter placeholder ($1, $2, etc.)
79
+ | { type: `func`; name: string; args: SerializedExpression[] } // Operator/function
80
+
81
+ /**
82
+ * Serialized ORDER BY clause for structured subset queries.
83
+ */
84
+ export type SerializedOrderByClause = {
85
+ column: string
86
+ direction?: `asc` | `desc` // omitted means 'asc'
87
+ nulls?: `first` | `last`
88
+ }
89
+
71
90
  export type SubsetParams = {
91
+ /** Legacy string format WHERE clause */
72
92
  where?: string
93
+ /** Positional parameter values for WHERE clause */
73
94
  params?: Record<string, string>
95
+ /** Maximum number of rows to return */
74
96
  limit?: number
97
+ /** Number of rows to skip */
75
98
  offset?: number
99
+ /** Legacy string format ORDER BY clause */
76
100
  orderBy?: string
101
+ /** Structured WHERE expression (preferred when available) */
102
+ whereExpr?: SerializedExpression
103
+ /** Structured ORDER BY clauses (preferred when available) */
104
+ orderByExpr?: SerializedOrderByClause[]
77
105
  }
78
106
 
79
107
  export type ControlMessage = {