@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/index.d.ts CHANGED
@@ -45,12 +45,45 @@ type MoveOutPattern = {
45
45
  pos: number;
46
46
  value: string;
47
47
  };
48
+ /**
49
+ * Serialized expression types for structured subset queries.
50
+ * These allow Electric to properly apply columnMapper transformations
51
+ * before generating the final SQL.
52
+ */
53
+ type SerializedExpression = {
54
+ type: `ref`;
55
+ column: string;
56
+ } | {
57
+ type: `val`;
58
+ paramIndex: number;
59
+ } | {
60
+ type: `func`;
61
+ name: string;
62
+ args: SerializedExpression[];
63
+ };
64
+ /**
65
+ * Serialized ORDER BY clause for structured subset queries.
66
+ */
67
+ type SerializedOrderByClause = {
68
+ column: string;
69
+ direction?: `asc` | `desc`;
70
+ nulls?: `first` | `last`;
71
+ };
48
72
  type SubsetParams = {
73
+ /** Legacy string format WHERE clause */
49
74
  where?: string;
75
+ /** Positional parameter values for WHERE clause */
50
76
  params?: Record<string, string>;
77
+ /** Maximum number of rows to return */
51
78
  limit?: number;
79
+ /** Number of rows to skip */
52
80
  offset?: number;
81
+ /** Legacy string format ORDER BY clause */
53
82
  orderBy?: string;
83
+ /** Structured WHERE expression (preferred when available) */
84
+ whereExpr?: SerializedExpression;
85
+ /** Structured ORDER BY clauses (preferred when available) */
86
+ orderByExpr?: SerializedOrderByClause[];
54
87
  };
55
88
  type ControlMessage = {
56
89
  headers: (Header & {
@@ -841,4 +874,35 @@ declare function isControlMessage<T extends Row<unknown> = Row>(message: Message
841
874
  */
842
875
  declare function isVisibleInSnapshot(txid: number | bigint | `${bigint}`, snapshot: PostgresSnapshot | NormalizedPgSnapshot): boolean;
843
876
 
844
- export { BackoffDefaults, type BackoffOptions, type BitColumn, type BpcharColumn, type ChangeMessage, type ColumnInfo, type ColumnMapper, type CommonColumnProps, type ControlMessage, ELECTRIC_PROTOCOL_QUERY_PARAMS, type EventMessage, type ExternalHeadersRecord, type ExternalParamsRecord, FetchError, type GetExtensions, type IntervalColumn, type IntervalColumnWithPrecision, type LogMode, type MaybePromise, type Message, type MoveOutPattern, type MoveTag, type NormalizedPgSnapshot, type NumericColumn, type Offset, type Operation, type PostgresParams, type PostgresSnapshot, type RegularColumn, type Row, type Schema, Shape, type ShapeChangedCallback, type ShapeData, ShapeStream, type ShapeStreamInterface, type ShapeStreamOptions, type SnapshotMetadata, type SubsetParams, type TimeColumn, type TypedMessages, type Value, type VarcharColumn, camelToSnake, createColumnMapper, isChangeMessage, isControlMessage, isVisibleInSnapshot, resolveValue, snakeCamelMapper, snakeToCamel };
877
+ /**
878
+ * Compiles a serialized expression into a SQL string.
879
+ * Applies columnMapper transformations to column references.
880
+ *
881
+ * @param expr - The serialized expression to compile
882
+ * @param columnMapper - Optional function to transform column names (e.g., camelCase to snake_case)
883
+ * @returns The compiled SQL string
884
+ *
885
+ * @example
886
+ * ```typescript
887
+ * const expr = { type: 'ref', column: 'userId' }
888
+ * compileExpression(expr, camelToSnake) // '"user_id"'
889
+ * ```
890
+ */
891
+ declare function compileExpression(expr: SerializedExpression, columnMapper?: (col: string) => string): string;
892
+ /**
893
+ * Compiles serialized ORDER BY clauses into a SQL string.
894
+ * Applies columnMapper transformations to column references.
895
+ *
896
+ * @param clauses - The serialized ORDER BY clauses to compile
897
+ * @param columnMapper - Optional function to transform column names
898
+ * @returns The compiled SQL ORDER BY string
899
+ *
900
+ * @example
901
+ * ```typescript
902
+ * const clauses = [{ column: 'createdAt', direction: 'desc', nulls: 'first' }]
903
+ * compileOrderBy(clauses, camelToSnake) // '"created_at" DESC NULLS FIRST'
904
+ * ```
905
+ */
906
+ declare function compileOrderBy(clauses: SerializedOrderByClause[], columnMapper?: (col: string) => string): string;
907
+
908
+ export { BackoffDefaults, type BackoffOptions, type BitColumn, type BpcharColumn, type ChangeMessage, type ColumnInfo, type ColumnMapper, type CommonColumnProps, type ControlMessage, ELECTRIC_PROTOCOL_QUERY_PARAMS, type EventMessage, type ExternalHeadersRecord, type ExternalParamsRecord, FetchError, type GetExtensions, type IntervalColumn, type IntervalColumnWithPrecision, type LogMode, type MaybePromise, type Message, type MoveOutPattern, type MoveTag, type NormalizedPgSnapshot, type NumericColumn, type Offset, type Operation, type PostgresParams, type PostgresSnapshot, type RegularColumn, type Row, type Schema, type SerializedExpression, type SerializedOrderByClause, Shape, type ShapeChangedCallback, type ShapeData, ShapeStream, type ShapeStreamInterface, type ShapeStreamOptions, type SnapshotMetadata, type SubsetParams, type TimeColumn, type TypedMessages, type Value, type VarcharColumn, camelToSnake, compileExpression, compileOrderBy, createColumnMapper, isChangeMessage, isControlMessage, isVisibleInSnapshot, resolveValue, snakeCamelMapper, snakeToCamel };
@@ -467,6 +467,8 @@ var SUBSET_PARAM_LIMIT = `subset__limit`;
467
467
  var SUBSET_PARAM_OFFSET = `subset__offset`;
468
468
  var SUBSET_PARAM_ORDER_BY = `subset__order_by`;
469
469
  var SUBSET_PARAM_WHERE_PARAMS = `subset__params`;
470
+ var SUBSET_PARAM_WHERE_EXPR = `subset__where_expr`;
471
+ var SUBSET_PARAM_ORDER_BY_EXPR = `subset__order_by_expr`;
470
472
  var ELECTRIC_PROTOCOL_QUERY_PARAMS = [
471
473
  LIVE_QUERY_PARAM,
472
474
  LIVE_SSE_QUERY_PARAM,
@@ -479,7 +481,9 @@ var ELECTRIC_PROTOCOL_QUERY_PARAMS = [
479
481
  SUBSET_PARAM_LIMIT,
480
482
  SUBSET_PARAM_OFFSET,
481
483
  SUBSET_PARAM_ORDER_BY,
482
- SUBSET_PARAM_WHERE_PARAMS
484
+ SUBSET_PARAM_WHERE_PARAMS,
485
+ SUBSET_PARAM_WHERE_EXPR,
486
+ SUBSET_PARAM_ORDER_BY_EXPR
483
487
  ];
484
488
 
485
489
  // src/fetch.ts
@@ -733,6 +737,13 @@ function getNextChunkUrl(url, res) {
733
737
  if (!shapeHandle || !lastOffset || isUpToDate) return;
734
738
  const nextUrl = new URL(url);
735
739
  if (nextUrl.searchParams.has(LIVE_QUERY_PARAM)) return;
740
+ const expiredHandle = nextUrl.searchParams.get(EXPIRED_HANDLE_QUERY_PARAM);
741
+ if (expiredHandle && shapeHandle === expiredHandle) {
742
+ console.warn(
743
+ `[Electric] Received stale cached response with expired shape handle. This should not happen and indicates a proxy/CDN caching misconfiguration. The response contained handle "${shapeHandle}" which was previously marked as expired. Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. Skipping prefetch to prevent infinite 409 loop.`
744
+ );
745
+ return;
746
+ }
736
747
  nextUrl.searchParams.set(SHAPE_HANDLE_QUERY_PARAM, shapeHandle);
737
748
  nextUrl.searchParams.set(OFFSET_QUERY_PARAM, lastOffset);
738
749
  nextUrl.searchParams.sort();
@@ -759,6 +770,81 @@ function chainAborter(aborter, sourceSignal) {
759
770
  function noop() {
760
771
  }
761
772
 
773
+ // src/expression-compiler.ts
774
+ function compileExpression(expr, columnMapper) {
775
+ switch (expr.type) {
776
+ case `ref`: {
777
+ const mappedColumn = columnMapper ? columnMapper(expr.column) : expr.column;
778
+ return quoteIdentifier(mappedColumn);
779
+ }
780
+ case `val`:
781
+ return `$${expr.paramIndex}`;
782
+ case `func`:
783
+ return compileFunction(expr, columnMapper);
784
+ default: {
785
+ const _exhaustive = expr;
786
+ throw new Error(`Unknown expression type: ${JSON.stringify(_exhaustive)}`);
787
+ }
788
+ }
789
+ }
790
+ function compileFunction(expr, columnMapper) {
791
+ const args = expr.args.map((arg) => compileExpression(arg, columnMapper));
792
+ switch (expr.name) {
793
+ // Binary comparison operators
794
+ case `eq`:
795
+ return `${args[0]} = ${args[1]}`;
796
+ case `gt`:
797
+ return `${args[0]} > ${args[1]}`;
798
+ case `gte`:
799
+ return `${args[0]} >= ${args[1]}`;
800
+ case `lt`:
801
+ return `${args[0]} < ${args[1]}`;
802
+ case `lte`:
803
+ return `${args[0]} <= ${args[1]}`;
804
+ // Logical operators
805
+ case `and`:
806
+ return args.map((a) => `(${a})`).join(` AND `);
807
+ case `or`:
808
+ return args.map((a) => `(${a})`).join(` OR `);
809
+ case `not`:
810
+ return `NOT (${args[0]})`;
811
+ // Special operators
812
+ case `in`:
813
+ return `${args[0]} = ANY(${args[1]})`;
814
+ case `like`:
815
+ return `${args[0]} LIKE ${args[1]}`;
816
+ case `ilike`:
817
+ return `${args[0]} ILIKE ${args[1]}`;
818
+ case `isNull`:
819
+ case `isUndefined`:
820
+ return `${args[0]} IS NULL`;
821
+ // String functions
822
+ case `upper`:
823
+ return `UPPER(${args[0]})`;
824
+ case `lower`:
825
+ return `LOWER(${args[0]})`;
826
+ case `length`:
827
+ return `LENGTH(${args[0]})`;
828
+ case `concat`:
829
+ return `CONCAT(${args.join(`, `)})`;
830
+ // Other functions
831
+ case `coalesce`:
832
+ return `COALESCE(${args.join(`, `)})`;
833
+ default:
834
+ throw new Error(`Unknown function: ${expr.name}`);
835
+ }
836
+ }
837
+ function compileOrderBy(clauses, columnMapper) {
838
+ return clauses.map((clause) => {
839
+ const mappedColumn = columnMapper ? columnMapper(clause.column) : clause.column;
840
+ let sql = quoteIdentifier(mappedColumn);
841
+ if (clause.direction === `desc`) sql += ` DESC`;
842
+ if (clause.nulls === `first`) sql += ` NULLS FIRST`;
843
+ if (clause.nulls === `last`) sql += ` NULLS LAST`;
844
+ return sql;
845
+ }).join(`, `);
846
+ }
847
+
762
848
  // src/client.ts
763
849
  import {
764
850
  fetchEventSource
@@ -1426,7 +1512,7 @@ requestShape_fn = async function() {
1426
1512
  return __privateMethod(this, _ShapeStream_instances, requestShape_fn).call(this);
1427
1513
  };
1428
1514
  constructUrl_fn = async function(url, resumingFromPause, subsetParams) {
1429
- var _a, _b, _c, _d;
1515
+ var _a, _b, _c, _d, _e, _f;
1430
1516
  const [requestHeaders, params] = await Promise.all([
1431
1517
  resolveHeaders(this.options.headers),
1432
1518
  this.options.params ? toInternalParams(convertWhereParamsToObj(this.options.params)) : void 0
@@ -1471,10 +1557,20 @@ constructUrl_fn = async function(url, resumingFromPause, subsetParams) {
1471
1557
  }
1472
1558
  }
1473
1559
  if (subsetParams) {
1474
- if (subsetParams.where && typeof subsetParams.where === `string`) {
1560
+ if (subsetParams.whereExpr) {
1561
+ const compiledWhere = compileExpression(
1562
+ subsetParams.whereExpr,
1563
+ (_c = this.options.columnMapper) == null ? void 0 : _c.encode
1564
+ );
1565
+ setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, compiledWhere);
1566
+ fetchUrl.searchParams.set(
1567
+ SUBSET_PARAM_WHERE_EXPR,
1568
+ JSON.stringify(subsetParams.whereExpr)
1569
+ );
1570
+ } else if (subsetParams.where && typeof subsetParams.where === `string`) {
1475
1571
  const encodedWhere = encodeWhereClause(
1476
1572
  subsetParams.where,
1477
- (_c = this.options.columnMapper) == null ? void 0 : _c.encode
1573
+ (_d = this.options.columnMapper) == null ? void 0 : _d.encode
1478
1574
  );
1479
1575
  setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, encodedWhere);
1480
1576
  }
@@ -1487,10 +1583,20 @@ constructUrl_fn = async function(url, resumingFromPause, subsetParams) {
1487
1583
  setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit);
1488
1584
  if (subsetParams.offset)
1489
1585
  setQueryParam(fetchUrl, SUBSET_PARAM_OFFSET, subsetParams.offset);
1490
- if (subsetParams.orderBy && typeof subsetParams.orderBy === `string`) {
1586
+ if (subsetParams.orderByExpr) {
1587
+ const compiledOrderBy = compileOrderBy(
1588
+ subsetParams.orderByExpr,
1589
+ (_e = this.options.columnMapper) == null ? void 0 : _e.encode
1590
+ );
1591
+ setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, compiledOrderBy);
1592
+ fetchUrl.searchParams.set(
1593
+ SUBSET_PARAM_ORDER_BY_EXPR,
1594
+ JSON.stringify(subsetParams.orderByExpr)
1595
+ );
1596
+ } else if (subsetParams.orderBy && typeof subsetParams.orderBy === `string`) {
1491
1597
  const encodedOrderBy = encodeWhereClause(
1492
1598
  subsetParams.orderBy,
1493
- (_d = this.options.columnMapper) == null ? void 0 : _d.encode
1599
+ (_f = this.options.columnMapper) == null ? void 0 : _f.encode
1494
1600
  );
1495
1601
  setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, encodedOrderBy);
1496
1602
  }
@@ -1541,7 +1647,15 @@ onInitialResponse_fn = async function(response) {
1541
1647
  const { headers, status } = response;
1542
1648
  const shapeHandle = headers.get(SHAPE_HANDLE_HEADER);
1543
1649
  if (shapeHandle) {
1544
- __privateSet(this, _shapeHandle, shapeHandle);
1650
+ const shapeKey = __privateGet(this, _currentFetchUrl) ? canonicalShapeKey(__privateGet(this, _currentFetchUrl)) : null;
1651
+ const expiredHandle = shapeKey ? expiredShapesCache.getExpiredHandle(shapeKey) : null;
1652
+ if (shapeHandle !== expiredHandle) {
1653
+ __privateSet(this, _shapeHandle, shapeHandle);
1654
+ } else {
1655
+ console.warn(
1656
+ `[Electric] Received stale cached response with expired shape handle. This should not happen and indicates a proxy/CDN caching misconfiguration. The response contained handle "${shapeHandle}" which was previously marked as expired. Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. Ignoring the stale handle and continuing with handle "${__privateGet(this, _shapeHandle)}".`
1657
+ );
1658
+ }
1545
1659
  }
1546
1660
  const lastOffset = headers.get(CHUNK_LAST_OFFSET_HEADER);
1547
1661
  if (lastOffset) {
@@ -2063,6 +2177,8 @@ export {
2063
2177
  Shape,
2064
2178
  ShapeStream,
2065
2179
  camelToSnake,
2180
+ compileExpression,
2181
+ compileOrderBy,
2066
2182
  createColumnMapper,
2067
2183
  isChangeMessage,
2068
2184
  isControlMessage,