@electric-sql/client 1.3.0 → 1.4.0
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/index.cjs +125 -7
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +65 -1
- package/dist/index.browser.mjs +3 -3
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +65 -1
- package/dist/index.legacy-esm.js +123 -7
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +123 -7
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +59 -3
- package/src/constants.ts +4 -0
- package/src/expression-compiler.ts +132 -0
- package/src/fetch.ts +16 -0
- package/src/index.ts +1 -0
- package/src/types.ts +28 -0
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.
|
|
4
|
+
"version": "1.4.0",
|
|
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
|
@@ -59,7 +59,10 @@ import {
|
|
|
59
59
|
SUBSET_PARAM_LIMIT,
|
|
60
60
|
SUBSET_PARAM_OFFSET,
|
|
61
61
|
SUBSET_PARAM_ORDER_BY,
|
|
62
|
+
SUBSET_PARAM_WHERE_EXPR,
|
|
63
|
+
SUBSET_PARAM_ORDER_BY_EXPR,
|
|
62
64
|
} from './constants'
|
|
65
|
+
import { compileExpression, compileOrderBy } from './expression-compiler'
|
|
63
66
|
import {
|
|
64
67
|
EventSourceMessage,
|
|
65
68
|
fetchEventSource,
|
|
@@ -890,13 +893,29 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
890
893
|
}
|
|
891
894
|
|
|
892
895
|
if (subsetParams) {
|
|
893
|
-
|
|
896
|
+
// Prefer structured expressions when available (allows proper columnMapper application)
|
|
897
|
+
// Fall back to legacy string format for backwards compatibility
|
|
898
|
+
if (subsetParams.whereExpr) {
|
|
899
|
+
// Compile structured expression with columnMapper applied
|
|
900
|
+
const compiledWhere = compileExpression(
|
|
901
|
+
subsetParams.whereExpr,
|
|
902
|
+
this.options.columnMapper?.encode
|
|
903
|
+
)
|
|
904
|
+
setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, compiledWhere)
|
|
905
|
+
// Also send the structured expression for servers that support it
|
|
906
|
+
fetchUrl.searchParams.set(
|
|
907
|
+
SUBSET_PARAM_WHERE_EXPR,
|
|
908
|
+
JSON.stringify(subsetParams.whereExpr)
|
|
909
|
+
)
|
|
910
|
+
} else if (subsetParams.where && typeof subsetParams.where === `string`) {
|
|
911
|
+
// Legacy string format (no columnMapper applied to already-compiled SQL)
|
|
894
912
|
const encodedWhere = encodeWhereClause(
|
|
895
913
|
subsetParams.where,
|
|
896
914
|
this.options.columnMapper?.encode
|
|
897
915
|
)
|
|
898
916
|
setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, encodedWhere)
|
|
899
917
|
}
|
|
918
|
+
|
|
900
919
|
if (subsetParams.params)
|
|
901
920
|
// Serialize params as JSON to keep the parameter name constant for proxy configs
|
|
902
921
|
fetchUrl.searchParams.set(
|
|
@@ -907,7 +926,25 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
907
926
|
setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit)
|
|
908
927
|
if (subsetParams.offset)
|
|
909
928
|
setQueryParam(fetchUrl, SUBSET_PARAM_OFFSET, subsetParams.offset)
|
|
910
|
-
|
|
929
|
+
|
|
930
|
+
// Prefer structured ORDER BY expressions when available
|
|
931
|
+
if (subsetParams.orderByExpr) {
|
|
932
|
+
// Compile structured ORDER BY with columnMapper applied
|
|
933
|
+
const compiledOrderBy = compileOrderBy(
|
|
934
|
+
subsetParams.orderByExpr,
|
|
935
|
+
this.options.columnMapper?.encode
|
|
936
|
+
)
|
|
937
|
+
setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, compiledOrderBy)
|
|
938
|
+
// Also send the structured expression for servers that support it
|
|
939
|
+
fetchUrl.searchParams.set(
|
|
940
|
+
SUBSET_PARAM_ORDER_BY_EXPR,
|
|
941
|
+
JSON.stringify(subsetParams.orderByExpr)
|
|
942
|
+
)
|
|
943
|
+
} else if (
|
|
944
|
+
subsetParams.orderBy &&
|
|
945
|
+
typeof subsetParams.orderBy === `string`
|
|
946
|
+
) {
|
|
947
|
+
// Legacy string format
|
|
911
948
|
const encodedOrderBy = encodeWhereClause(
|
|
912
949
|
subsetParams.orderBy,
|
|
913
950
|
this.options.columnMapper?.encode
|
|
@@ -982,7 +1019,26 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
982
1019
|
const { headers, status } = response
|
|
983
1020
|
const shapeHandle = headers.get(SHAPE_HANDLE_HEADER)
|
|
984
1021
|
if (shapeHandle) {
|
|
985
|
-
this
|
|
1022
|
+
// Don't accept a handle we know is expired - this can happen if a
|
|
1023
|
+
// proxy serves a stale cached response despite the expired_handle
|
|
1024
|
+
// cache buster parameter
|
|
1025
|
+
const shapeKey = this.#currentFetchUrl
|
|
1026
|
+
? canonicalShapeKey(this.#currentFetchUrl)
|
|
1027
|
+
: null
|
|
1028
|
+
const expiredHandle = shapeKey
|
|
1029
|
+
? expiredShapesCache.getExpiredHandle(shapeKey)
|
|
1030
|
+
: null
|
|
1031
|
+
if (shapeHandle !== expiredHandle) {
|
|
1032
|
+
this.#shapeHandle = shapeHandle
|
|
1033
|
+
} else {
|
|
1034
|
+
console.warn(
|
|
1035
|
+
`[Electric] Received stale cached response with expired shape handle. ` +
|
|
1036
|
+
`This should not happen and indicates a proxy/CDN caching misconfiguration. ` +
|
|
1037
|
+
`The response contained handle "${shapeHandle}" which was previously marked as expired. ` +
|
|
1038
|
+
`Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
|
|
1039
|
+
`Ignoring the stale handle and continuing with handle "${this.#shapeHandle}".`
|
|
1040
|
+
)
|
|
1041
|
+
}
|
|
986
1042
|
}
|
|
987
1043
|
|
|
988
1044
|
const lastOffset = headers.get(CHUNK_LAST_OFFSET_HEADER)
|
package/src/constants.ts
CHANGED
|
@@ -26,6 +26,8 @@ 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`
|
|
29
31
|
|
|
30
32
|
// Query parameters that should be passed through when proxying Electric requests
|
|
31
33
|
export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
|
|
@@ -41,4 +43,6 @@ export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
|
|
|
41
43
|
SUBSET_PARAM_OFFSET,
|
|
42
44
|
SUBSET_PARAM_ORDER_BY,
|
|
43
45
|
SUBSET_PARAM_WHERE_PARAMS,
|
|
46
|
+
SUBSET_PARAM_WHERE_EXPR,
|
|
47
|
+
SUBSET_PARAM_ORDER_BY_EXPR,
|
|
44
48
|
]
|
|
@@ -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/fetch.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CHUNK_LAST_OFFSET_HEADER,
|
|
3
3
|
CHUNK_UP_TO_DATE_HEADER,
|
|
4
|
+
EXPIRED_HANDLE_QUERY_PARAM,
|
|
4
5
|
LIVE_QUERY_PARAM,
|
|
5
6
|
OFFSET_QUERY_PARAM,
|
|
6
7
|
SHAPE_HANDLE_HEADER,
|
|
@@ -436,6 +437,21 @@ function getNextChunkUrl(url: string, res: Response): string | void {
|
|
|
436
437
|
// potentially miss more recent data
|
|
437
438
|
if (nextUrl.searchParams.has(LIVE_QUERY_PARAM)) return
|
|
438
439
|
|
|
440
|
+
// don't prefetch if the response handle is the expired handle from the request
|
|
441
|
+
// this can happen when a proxy serves a stale cached response despite the
|
|
442
|
+
// expired_handle cache buster parameter
|
|
443
|
+
const expiredHandle = nextUrl.searchParams.get(EXPIRED_HANDLE_QUERY_PARAM)
|
|
444
|
+
if (expiredHandle && shapeHandle === expiredHandle) {
|
|
445
|
+
console.warn(
|
|
446
|
+
`[Electric] Received stale cached response with expired shape handle. ` +
|
|
447
|
+
`This should not happen and indicates a proxy/CDN caching misconfiguration. ` +
|
|
448
|
+
`The response contained handle "${shapeHandle}" which was previously marked as expired. ` +
|
|
449
|
+
`Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
|
|
450
|
+
`Skipping prefetch to prevent infinite 409 loop.`
|
|
451
|
+
)
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
|
|
439
455
|
nextUrl.searchParams.set(SHAPE_HANDLE_QUERY_PARAM, shapeHandle)
|
|
440
456
|
nextUrl.searchParams.set(OFFSET_QUERY_PARAM, lastOffset)
|
|
441
457
|
nextUrl.searchParams.sort()
|
package/src/index.ts
CHANGED
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 = {
|