@electric-sql/client 1.1.4 → 1.2.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/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.1.4",
4
+ "version": "1.2.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
@@ -9,6 +9,7 @@ import {
9
9
  SnapshotMetadata,
10
10
  } from './types'
11
11
  import { MessageParser, Parser, TransformFunction } from './parser'
12
+ import { ColumnMapper, encodeWhereClause } from './column-mapper'
12
13
  import { getOffset, isUpToDateMessage, isChangeMessage } from './helpers'
13
14
  import {
14
15
  FetchError,
@@ -17,6 +18,7 @@ import {
17
18
  InvalidSignalError,
18
19
  MissingShapeHandleError,
19
20
  ReservedParamError,
21
+ MissingHeadersError,
20
22
  } from './error'
21
23
  import {
22
24
  BackoffDefaults,
@@ -58,6 +60,7 @@ import {
58
60
  fetchEventSource,
59
61
  } from '@microsoft/fetch-event-source'
60
62
  import { expiredShapesCache } from './expired-shapes-cache'
63
+ import { upToDateTracker } from './up-to-date-tracker'
61
64
  import { SnapshotTracker } from './snapshot-tracker'
62
65
 
63
66
  const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
@@ -292,8 +295,87 @@ export interface ShapeStreamOptions<T = never> {
292
295
  fetchClient?: typeof fetch
293
296
  backoffOptions?: BackoffOptions
294
297
  parser?: Parser<T>
298
+
299
+ /**
300
+ * Function to transform rows after parsing (e.g., for encryption, type coercion).
301
+ * Applied to data received from Electric.
302
+ *
303
+ * **Note**: If you're using `transformer` solely for column name transformation
304
+ * (e.g., snake_case → camelCase), consider using `columnMapper` instead, which
305
+ * provides bidirectional transformation and automatically encodes WHERE clauses.
306
+ *
307
+ * **Execution order** when both are provided:
308
+ * 1. `columnMapper.decode` runs first (renames columns)
309
+ * 2. `transformer` runs second (transforms values)
310
+ *
311
+ * @example
312
+ * ```typescript
313
+ * // For column renaming only - use columnMapper
314
+ * import { snakeCamelMapper } from '@electric-sql/client'
315
+ * const stream = new ShapeStream({ columnMapper: snakeCamelMapper() })
316
+ * ```
317
+ *
318
+ * @example
319
+ * ```typescript
320
+ * // For value transformation (encryption, etc.) - use transformer
321
+ * const stream = new ShapeStream({
322
+ * transformer: (row) => ({
323
+ * ...row,
324
+ * encrypted_field: decrypt(row.encrypted_field)
325
+ * })
326
+ * })
327
+ * ```
328
+ *
329
+ * @example
330
+ * ```typescript
331
+ * // Use both together
332
+ * const stream = new ShapeStream({
333
+ * columnMapper: snakeCamelMapper(), // Runs first: renames columns
334
+ * transformer: (row) => ({ // Runs second: transforms values
335
+ * ...row,
336
+ * encryptedData: decrypt(row.encryptedData)
337
+ * })
338
+ * })
339
+ * ```
340
+ */
295
341
  transformer?: TransformFunction<T>
296
342
 
343
+ /**
344
+ * Bidirectional column name mapper for transforming between database column names
345
+ * (e.g., snake_case) and application column names (e.g., camelCase).
346
+ *
347
+ * The mapper handles both:
348
+ * - **Decoding**: Database → Application (applied to query results)
349
+ * - **Encoding**: Application → Database (applied to WHERE clauses)
350
+ *
351
+ * @example
352
+ * ```typescript
353
+ * // Most common case: snake_case ↔ camelCase
354
+ * import { snakeCamelMapper } from '@electric-sql/client'
355
+ *
356
+ * const stream = new ShapeStream({
357
+ * url: 'http://localhost:3000/v1/shape',
358
+ * params: { table: 'todos' },
359
+ * columnMapper: snakeCamelMapper()
360
+ * })
361
+ * ```
362
+ *
363
+ * @example
364
+ * ```typescript
365
+ * // Custom mapping
366
+ * import { createColumnMapper } from '@electric-sql/client'
367
+ *
368
+ * const stream = new ShapeStream({
369
+ * columnMapper: createColumnMapper({
370
+ * user_id: 'userId',
371
+ * project_id: 'projectId',
372
+ * created_at: 'createdAt'
373
+ * })
374
+ * })
375
+ * ```
376
+ */
377
+ columnMapper?: ColumnMapper
378
+
297
379
  /**
298
380
  * A function for handling shapestream errors.
299
381
  *
@@ -368,16 +450,15 @@ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
368
450
 
369
451
  forceDisconnectAndRefresh(): Promise<void>
370
452
 
371
- requestSnapshot(params: {
372
- where?: string
373
- params?: Record<string, string>
374
- limit: number
375
- offset?: number
376
- orderBy: string
377
- }): Promise<{
453
+ requestSnapshot(params: SubsetParams): Promise<{
378
454
  metadata: SnapshotMetadata
379
455
  data: Array<Message<T>>
380
456
  }>
457
+
458
+ fetchSnapshot(opts: SubsetParams): Promise<{
459
+ metadata: SnapshotMetadata
460
+ data: Array<ChangeMessage<T>>
461
+ }>
381
462
  }
382
463
 
383
464
  /**
@@ -482,6 +563,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
482
563
  #activeSnapshotRequests = 0 // counter for concurrent snapshot requests
483
564
  #midStreamPromise?: Promise<void>
484
565
  #midStreamPromiseResolver?: () => void
566
+ #lastSeenCursor?: string // Last seen cursor from previous session (used to detect cached responses)
567
+ #currentFetchUrl?: URL // Current fetch URL for computing shape key
485
568
  #lastSseConnectionStartTime?: number
486
569
  #minSseConnectionDuration = 1000 // Minimum expected SSE connection duration (1 second)
487
570
  #consecutiveShortSseConnections = 0
@@ -491,16 +574,44 @@ export class ShapeStream<T extends Row<unknown> = Row>
491
574
  #sseBackoffMaxDelay = 5000 // Maximum delay cap (ms)
492
575
  #unsubscribeFromVisibilityChanges?: () => void
493
576
 
577
+ // Derived state: we're in replay mode if we have a last seen cursor
578
+ get #replayMode(): boolean {
579
+ return this.#lastSeenCursor !== undefined
580
+ }
581
+
494
582
  constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
495
583
  this.options = { subscribe: true, ...options }
496
584
  validateOptions(this.options)
497
585
  this.#lastOffset = this.options.offset ?? `-1`
498
586
  this.#liveCacheBuster = ``
499
587
  this.#shapeHandle = this.options.handle
500
- this.#messageParser = new MessageParser<T>(
501
- options.parser,
502
- options.transformer
503
- )
588
+
589
+ // Build transformer chain: columnMapper.decode -> transformer
590
+ // columnMapper transforms column names, transformer transforms values
591
+ let transformer: TransformFunction<GetExtensions<T>> | undefined
592
+
593
+ if (options.columnMapper) {
594
+ const applyColumnMapper = (
595
+ row: Row<GetExtensions<T>>
596
+ ): Row<GetExtensions<T>> => {
597
+ const result: Record<string, unknown> = {}
598
+ for (const [dbKey, value] of Object.entries(row)) {
599
+ const appKey = options.columnMapper!.decode(dbKey)
600
+ result[appKey] = value
601
+ }
602
+ return result as Row<GetExtensions<T>>
603
+ }
604
+
605
+ transformer = options.transformer
606
+ ? (row: Row<GetExtensions<T>>) =>
607
+ options.transformer!(applyColumnMapper(row))
608
+ : applyColumnMapper
609
+ } else {
610
+ transformer = options.transformer
611
+ }
612
+
613
+ this.#messageParser = new MessageParser<T>(options.parser, transformer)
614
+
504
615
  this.#onError = this.options.onError
505
616
  this.#mode = this.options.log ?? `full`
506
617
 
@@ -737,7 +848,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
737
848
  // Add PostgreSQL-specific parameters
738
849
  if (params) {
739
850
  if (params.table) setQueryParam(fetchUrl, TABLE_QUERY_PARAM, params.table)
740
- if (params.where) setQueryParam(fetchUrl, WHERE_QUERY_PARAM, params.where)
851
+ if (params.where && typeof params.where === `string`) {
852
+ const encodedWhere = encodeWhereClause(
853
+ params.where,
854
+ this.options.columnMapper?.encode
855
+ )
856
+ setQueryParam(fetchUrl, WHERE_QUERY_PARAM, encodedWhere)
857
+ }
741
858
  if (params.columns)
742
859
  setQueryParam(fetchUrl, COLUMNS_QUERY_PARAM, params.columns)
743
860
  if (params.replica) setQueryParam(fetchUrl, REPLICA_PARAM, params.replica)
@@ -758,23 +875,40 @@ export class ShapeStream<T extends Row<unknown> = Row>
758
875
  }
759
876
 
760
877
  if (subsetParams) {
761
- if (subsetParams.where)
762
- setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, subsetParams.where)
878
+ if (subsetParams.where && typeof subsetParams.where === `string`) {
879
+ const encodedWhere = encodeWhereClause(
880
+ subsetParams.where,
881
+ this.options.columnMapper?.encode
882
+ )
883
+ setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, encodedWhere)
884
+ }
763
885
  if (subsetParams.params)
764
- setQueryParam(fetchUrl, SUBSET_PARAM_WHERE_PARAMS, subsetParams.params)
886
+ // Serialize params as JSON to keep the parameter name constant for proxy configs
887
+ fetchUrl.searchParams.set(
888
+ SUBSET_PARAM_WHERE_PARAMS,
889
+ JSON.stringify(subsetParams.params)
890
+ )
765
891
  if (subsetParams.limit)
766
892
  setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit)
767
893
  if (subsetParams.offset)
768
894
  setQueryParam(fetchUrl, SUBSET_PARAM_OFFSET, subsetParams.offset)
769
- if (subsetParams.orderBy)
770
- setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, subsetParams.orderBy)
895
+ if (subsetParams.orderBy && typeof subsetParams.orderBy === `string`) {
896
+ const encodedOrderBy = encodeWhereClause(
897
+ subsetParams.orderBy,
898
+ this.options.columnMapper?.encode
899
+ )
900
+ setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, encodedOrderBy)
901
+ }
771
902
  }
772
903
 
773
904
  // Add Electric's internal parameters
774
905
  fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, this.#lastOffset)
775
906
  fetchUrl.searchParams.set(LOG_MODE_QUERY_PARAM, this.#mode)
776
907
 
777
- if (this.#isUpToDate) {
908
+ // Snapshot requests (with subsetParams) should never use live polling
909
+ const isSnapshotRequest = subsetParams !== undefined
910
+
911
+ if (this.#isUpToDate && !isSnapshotRequest) {
778
912
  // If we are resuming from a paused state, we don't want to perform a live request
779
913
  // because it could be a long poll that holds for 20sec
780
914
  // and during all that time `isConnected` will be false
@@ -846,11 +980,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
846
980
  this.#liveCacheBuster = liveCacheBuster
847
981
  }
848
982
 
849
- const getSchema = (): Schema => {
850
- const schemaHeader = headers.get(SHAPE_SCHEMA_HEADER)
851
- return schemaHeader ? JSON.parse(schemaHeader) : {}
852
- }
853
- this.#schema = this.#schema ?? getSchema()
983
+ this.#schema = this.#schema ?? getSchemaFromHeaders(headers)
854
984
 
855
985
  // NOTE: 204s are deprecated, the Electric server should not
856
986
  // send these in latest versions but this is here for backwards
@@ -884,6 +1014,33 @@ export class ShapeStream<T extends Row<unknown> = Row>
884
1014
  this.#isMidStream = false
885
1015
  // Resolve the promise waiting for mid-stream to end
886
1016
  this.#midStreamPromiseResolver?.()
1017
+
1018
+ // Check if we should suppress this up-to-date notification
1019
+ // to prevent multiple renders from cached responses
1020
+ if (this.#replayMode && !isSseMessage) {
1021
+ // We're in replay mode (replaying cached responses during initial sync).
1022
+ // Check if the cursor has changed - cursors are time-based and always
1023
+ // increment, so a new cursor means fresh data from the server.
1024
+ const currentCursor = this.#liveCacheBuster
1025
+
1026
+ if (currentCursor === this.#lastSeenCursor) {
1027
+ // Same cursor = still replaying cached responses
1028
+ // Suppress this up-to-date notification
1029
+ return
1030
+ }
1031
+ }
1032
+
1033
+ // We're either:
1034
+ // 1. Not in replay mode (normal operation), or
1035
+ // 2. This is a live/SSE message (always fresh), or
1036
+ // 3. Cursor has changed (exited replay mode with fresh data)
1037
+ // In all cases, notify subscribers and record the up-to-date.
1038
+ this.#lastSeenCursor = undefined // Exit replay mode
1039
+
1040
+ if (this.#currentFetchUrl) {
1041
+ const shapeKey = canonicalShapeKey(this.#currentFetchUrl)
1042
+ upToDateTracker.recordUpToDate(shapeKey, this.#liveCacheBuster)
1043
+ }
887
1044
  }
888
1045
 
889
1046
  // Filter messages using snapshot tracker
@@ -911,6 +1068,21 @@ export class ShapeStream<T extends Row<unknown> = Row>
911
1068
  headers: Record<string, string>
912
1069
  resumingFromPause?: boolean
913
1070
  }): Promise<void> {
1071
+ // Store current fetch URL for shape key computation
1072
+ this.#currentFetchUrl = opts.fetchUrl
1073
+
1074
+ // Check if we should enter replay mode (replaying cached responses)
1075
+ // This happens when we're starting fresh (offset=-1 or before first up-to-date)
1076
+ // and there's a recent up-to-date in localStorage (< 60s)
1077
+ if (!this.#isUpToDate && !this.#replayMode) {
1078
+ const shapeKey = canonicalShapeKey(opts.fetchUrl)
1079
+ const lastSeenCursor = upToDateTracker.shouldEnterReplayMode(shapeKey)
1080
+ if (lastSeenCursor) {
1081
+ // Enter replay mode and store the last seen cursor
1082
+ this.#lastSeenCursor = lastSeenCursor
1083
+ }
1084
+ }
1085
+
914
1086
  const useSse = this.options.liveSse ?? this.options.experimentalLiveSse
915
1087
  if (
916
1088
  this.#isUpToDate &&
@@ -1237,7 +1409,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1237
1409
  }
1238
1410
 
1239
1411
  /**
1240
- * Request a snapshot for subset of data.
1412
+ * Request a snapshot for subset of data and inject it into the subscribed data stream.
1241
1413
  *
1242
1414
  * Only available when mode is `changes_only`.
1243
1415
  * Returns the insertion point & the data, but more importantly injects the data
@@ -1275,16 +1447,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1275
1447
  this.#pause()
1276
1448
  }
1277
1449
 
1278
- const { fetchUrl, requestHeaders } = await this.#constructUrl(
1279
- this.options.url,
1280
- true,
1281
- opts
1282
- )
1283
-
1284
- const { metadata, data } = await this.#fetchSnapshot(
1285
- fetchUrl,
1286
- requestHeaders
1287
- )
1450
+ const { metadata, data } = await this.fetchSnapshot(opts)
1288
1451
 
1289
1452
  const dataWithEndBoundary = (data as Array<Message<T>>).concat([
1290
1453
  { headers: { control: `snapshot-end`, ...metadata } },
@@ -1309,8 +1472,26 @@ export class ShapeStream<T extends Row<unknown> = Row>
1309
1472
  }
1310
1473
  }
1311
1474
 
1312
- async #fetchSnapshot(url: URL, headers: Record<string, string>) {
1313
- const response = await this.#fetchClient(url.toString(), { headers })
1475
+ /**
1476
+ * Fetch a snapshot for subset of data.
1477
+ * Returns the metadata and the data, but does not inject it into the subscribed data stream.
1478
+ *
1479
+ * @param opts - The options for the snapshot request.
1480
+ * @returns The metadata and the data for the snapshot.
1481
+ */
1482
+ async fetchSnapshot(opts: SubsetParams): Promise<{
1483
+ metadata: SnapshotMetadata
1484
+ data: Array<ChangeMessage<T>>
1485
+ }> {
1486
+ const { fetchUrl, requestHeaders } = await this.#constructUrl(
1487
+ this.options.url,
1488
+ true,
1489
+ opts
1490
+ )
1491
+
1492
+ const response = await this.#fetchClient(fetchUrl.toString(), {
1493
+ headers: requestHeaders,
1494
+ })
1314
1495
 
1315
1496
  if (!response.ok) {
1316
1497
  throw new FetchError(
@@ -1318,21 +1499,52 @@ export class ShapeStream<T extends Row<unknown> = Row>
1318
1499
  undefined,
1319
1500
  undefined,
1320
1501
  Object.fromEntries([...response.headers.entries()]),
1321
- url.toString()
1502
+ fetchUrl.toString()
1322
1503
  )
1323
1504
  }
1324
1505
 
1325
- const { metadata, data } = await response.json()
1326
- const batch = this.#messageParser.parse<Array<ChangeMessage<T>>>(
1327
- JSON.stringify(data),
1328
- this.#schema!
1506
+ // Use schema from stream if available, otherwise extract from response header
1507
+ const schema: Schema =
1508
+ this.#schema ??
1509
+ getSchemaFromHeaders(response.headers, {
1510
+ required: true,
1511
+ url: fetchUrl.toString(),
1512
+ })
1513
+
1514
+ const { metadata, data: rawData } = await response.json()
1515
+ const data = this.#messageParser.parseSnapshotData<ChangeMessage<T>>(
1516
+ rawData,
1517
+ schema
1329
1518
  )
1330
1519
 
1331
1520
  return {
1332
1521
  metadata,
1333
- data: batch,
1522
+ data,
1523
+ }
1524
+ }
1525
+ }
1526
+
1527
+ /**
1528
+ * Extracts the schema from response headers.
1529
+ * @param headers - The response headers
1530
+ * @param options - Options for schema extraction
1531
+ * @param options.required - If true, throws MissingHeadersError when header is missing. Defaults to false.
1532
+ * @param options.url - The URL to include in the error message if required is true
1533
+ * @returns The parsed schema, or an empty object if not required and header is missing
1534
+ * @throws {MissingHeadersError} if required is true and the header is missing
1535
+ */
1536
+ function getSchemaFromHeaders(
1537
+ headers: Headers,
1538
+ options?: { required?: boolean; url?: string }
1539
+ ): Schema {
1540
+ const schemaHeader = headers.get(SHAPE_SCHEMA_HEADER)
1541
+ if (!schemaHeader) {
1542
+ if (options?.required && options?.url) {
1543
+ throw new MissingHeadersError(options.url, [SHAPE_SCHEMA_HEADER])
1334
1544
  }
1545
+ return {}
1335
1546
  }
1547
+ return JSON.parse(schemaHeader)
1336
1548
  }
1337
1549
 
1338
1550
  /**