@electric-sql/client 1.1.5 → 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.
@@ -90,10 +90,14 @@ __export(src_exports, {
90
90
  FetchError: () => FetchError,
91
91
  Shape: () => Shape,
92
92
  ShapeStream: () => ShapeStream,
93
+ camelToSnake: () => camelToSnake,
94
+ createColumnMapper: () => createColumnMapper,
93
95
  isChangeMessage: () => isChangeMessage,
94
96
  isControlMessage: () => isControlMessage,
95
97
  isVisibleInSnapshot: () => isVisibleInSnapshot,
96
- resolveValue: () => resolveValue
98
+ resolveValue: () => resolveValue,
99
+ snakeCamelMapper: () => snakeCamelMapper,
100
+ snakeToCamel: () => snakeToCamel
97
101
  });
98
102
  module.exports = __toCommonJS(src_exports);
99
103
 
@@ -322,6 +326,151 @@ function makeNullableParser(parser, columnInfo, columnName) {
322
326
  };
323
327
  }
324
328
 
329
+ // src/column-mapper.ts
330
+ function snakeToCamel(str) {
331
+ var _a, _b, _c, _d;
332
+ const leadingUnderscores = (_b = (_a = str.match(/^_+/)) == null ? void 0 : _a[0]) != null ? _b : ``;
333
+ const withoutLeading = str.slice(leadingUnderscores.length);
334
+ const trailingUnderscores = (_d = (_c = withoutLeading.match(/_+$/)) == null ? void 0 : _c[0]) != null ? _d : ``;
335
+ const core = trailingUnderscores ? withoutLeading.slice(
336
+ 0,
337
+ withoutLeading.length - trailingUnderscores.length
338
+ ) : withoutLeading;
339
+ const normalized = core.toLowerCase();
340
+ const camelCased = normalized.replace(
341
+ /_+([a-z])/g,
342
+ (_, letter) => letter.toUpperCase()
343
+ );
344
+ return leadingUnderscores + camelCased + trailingUnderscores;
345
+ }
346
+ function camelToSnake(str) {
347
+ return str.replace(/([a-z])([A-Z])/g, `$1_$2`).replace(/([A-Z]+)([A-Z][a-z])/g, `$1_$2`).toLowerCase();
348
+ }
349
+ function createColumnMapper(mapping) {
350
+ const reverseMapping = {};
351
+ for (const [dbName, appName] of Object.entries(mapping)) {
352
+ reverseMapping[appName] = dbName;
353
+ }
354
+ return {
355
+ decode: (dbColumnName) => {
356
+ var _a;
357
+ return (_a = mapping[dbColumnName]) != null ? _a : dbColumnName;
358
+ },
359
+ encode: (appColumnName) => {
360
+ var _a;
361
+ return (_a = reverseMapping[appColumnName]) != null ? _a : appColumnName;
362
+ }
363
+ };
364
+ }
365
+ function encodeWhereClause(whereClause, encode) {
366
+ if (!whereClause || !encode) return whereClause != null ? whereClause : ``;
367
+ const sqlKeywords = /* @__PURE__ */ new Set([
368
+ `SELECT`,
369
+ `FROM`,
370
+ `WHERE`,
371
+ `AND`,
372
+ `OR`,
373
+ `NOT`,
374
+ `IN`,
375
+ `IS`,
376
+ `NULL`,
377
+ `NULLS`,
378
+ `FIRST`,
379
+ `LAST`,
380
+ `TRUE`,
381
+ `FALSE`,
382
+ `LIKE`,
383
+ `ILIKE`,
384
+ `BETWEEN`,
385
+ `ASC`,
386
+ `DESC`,
387
+ `LIMIT`,
388
+ `OFFSET`,
389
+ `ORDER`,
390
+ `BY`,
391
+ `GROUP`,
392
+ `HAVING`,
393
+ `DISTINCT`,
394
+ `AS`,
395
+ `ON`,
396
+ `JOIN`,
397
+ `LEFT`,
398
+ `RIGHT`,
399
+ `INNER`,
400
+ `OUTER`,
401
+ `CROSS`,
402
+ `CASE`,
403
+ `WHEN`,
404
+ `THEN`,
405
+ `ELSE`,
406
+ `END`,
407
+ `CAST`,
408
+ `LOWER`,
409
+ `UPPER`,
410
+ `COALESCE`,
411
+ `NULLIF`
412
+ ]);
413
+ const quotedRanges = [];
414
+ let pos = 0;
415
+ while (pos < whereClause.length) {
416
+ const ch = whereClause[pos];
417
+ if (ch === `'` || ch === `"`) {
418
+ const start = pos;
419
+ const quoteChar = ch;
420
+ pos++;
421
+ while (pos < whereClause.length) {
422
+ if (whereClause[pos] === quoteChar) {
423
+ if (whereClause[pos + 1] === quoteChar) {
424
+ pos += 2;
425
+ } else {
426
+ pos++;
427
+ break;
428
+ }
429
+ } else {
430
+ pos++;
431
+ }
432
+ }
433
+ quotedRanges.push({ start, end: pos });
434
+ } else {
435
+ pos++;
436
+ }
437
+ }
438
+ const isInQuotedString = (pos2) => {
439
+ return quotedRanges.some((range) => pos2 >= range.start && pos2 < range.end);
440
+ };
441
+ const identifierPattern = new RegExp("(?<![a-zA-Z0-9_])([a-zA-Z_][a-zA-Z0-9_]*)(?![a-zA-Z0-9_])", "g");
442
+ return whereClause.replace(identifierPattern, (match, _p1, offset) => {
443
+ if (isInQuotedString(offset)) {
444
+ return match;
445
+ }
446
+ if (sqlKeywords.has(match.toUpperCase())) {
447
+ return match;
448
+ }
449
+ if (match.startsWith(`$`)) {
450
+ return match;
451
+ }
452
+ const encoded = encode(match);
453
+ return encoded;
454
+ });
455
+ }
456
+ function snakeCamelMapper(schema) {
457
+ if (schema) {
458
+ const mapping = {};
459
+ for (const dbColumn of Object.keys(schema)) {
460
+ mapping[dbColumn] = snakeToCamel(dbColumn);
461
+ }
462
+ return createColumnMapper(mapping);
463
+ }
464
+ return {
465
+ decode: (dbColumnName) => {
466
+ return snakeToCamel(dbColumnName);
467
+ },
468
+ encode: (appColumnName) => {
469
+ return camelToSnake(appColumnName);
470
+ }
471
+ };
472
+ }
473
+
325
474
  // src/helpers.ts
326
475
  function isChangeMessage(message) {
327
476
  return `key` in message;
@@ -715,6 +864,127 @@ var ExpiredShapesCache = class {
715
864
  };
716
865
  var expiredShapesCache = new ExpiredShapesCache();
717
866
 
867
+ // src/up-to-date-tracker.ts
868
+ var UpToDateTracker = class {
869
+ constructor() {
870
+ this.data = {};
871
+ this.storageKey = `electric_up_to_date_tracker`;
872
+ this.cacheTTL = 6e4;
873
+ // 60s to match typical CDN s-maxage cache duration
874
+ this.maxEntries = 250;
875
+ this.writeThrottleMs = 6e4;
876
+ // Throttle localStorage writes to once per 60s
877
+ this.lastWriteTime = 0;
878
+ this.load();
879
+ this.cleanup();
880
+ }
881
+ /**
882
+ * Records that a shape received an up-to-date message with a specific cursor.
883
+ * This timestamp and cursor are used to detect cache replay scenarios.
884
+ * Updates in-memory immediately, but throttles localStorage writes.
885
+ */
886
+ recordUpToDate(shapeKey, cursor) {
887
+ this.data[shapeKey] = {
888
+ timestamp: Date.now(),
889
+ cursor
890
+ };
891
+ const keys = Object.keys(this.data);
892
+ if (keys.length > this.maxEntries) {
893
+ const oldest = keys.reduce(
894
+ (min, k) => this.data[k].timestamp < this.data[min].timestamp ? k : min
895
+ );
896
+ delete this.data[oldest];
897
+ }
898
+ this.scheduleSave();
899
+ }
900
+ /**
901
+ * Schedules a throttled save to localStorage.
902
+ * Writes immediately if enough time has passed, otherwise schedules for later.
903
+ */
904
+ scheduleSave() {
905
+ const now = Date.now();
906
+ const timeSinceLastWrite = now - this.lastWriteTime;
907
+ if (timeSinceLastWrite >= this.writeThrottleMs) {
908
+ this.lastWriteTime = now;
909
+ this.save();
910
+ } else if (!this.pendingSaveTimer) {
911
+ const delay = this.writeThrottleMs - timeSinceLastWrite;
912
+ this.pendingSaveTimer = setTimeout(() => {
913
+ this.lastWriteTime = Date.now();
914
+ this.pendingSaveTimer = void 0;
915
+ this.save();
916
+ }, delay);
917
+ }
918
+ }
919
+ /**
920
+ * Checks if we should enter replay mode for this shape.
921
+ * Returns the last seen cursor if there's a recent up-to-date (< 60s),
922
+ * which means we'll likely be replaying cached responses.
923
+ * Returns null if no recent up-to-date exists.
924
+ */
925
+ shouldEnterReplayMode(shapeKey) {
926
+ const entry = this.data[shapeKey];
927
+ if (!entry) {
928
+ return null;
929
+ }
930
+ const age = Date.now() - entry.timestamp;
931
+ if (age >= this.cacheTTL) {
932
+ return null;
933
+ }
934
+ return entry.cursor;
935
+ }
936
+ /**
937
+ * Cleans up expired entries from the cache.
938
+ * Called on initialization and can be called periodically.
939
+ */
940
+ cleanup() {
941
+ const now = Date.now();
942
+ const keys = Object.keys(this.data);
943
+ let modified = false;
944
+ for (const key of keys) {
945
+ const age = now - this.data[key].timestamp;
946
+ if (age > this.cacheTTL) {
947
+ delete this.data[key];
948
+ modified = true;
949
+ }
950
+ }
951
+ if (modified) {
952
+ this.save();
953
+ }
954
+ }
955
+ save() {
956
+ if (typeof localStorage === `undefined`) return;
957
+ try {
958
+ localStorage.setItem(this.storageKey, JSON.stringify(this.data));
959
+ } catch (e) {
960
+ }
961
+ }
962
+ load() {
963
+ if (typeof localStorage === `undefined`) return;
964
+ try {
965
+ const stored = localStorage.getItem(this.storageKey);
966
+ if (stored) {
967
+ this.data = JSON.parse(stored);
968
+ }
969
+ } catch (e) {
970
+ this.data = {};
971
+ }
972
+ }
973
+ /**
974
+ * Clears all tracked up-to-date timestamps.
975
+ * Useful for testing or manual cache invalidation.
976
+ */
977
+ clear() {
978
+ this.data = {};
979
+ if (this.pendingSaveTimer) {
980
+ clearTimeout(this.pendingSaveTimer);
981
+ this.pendingSaveTimer = void 0;
982
+ }
983
+ this.save();
984
+ }
985
+ };
986
+ var upToDateTracker = new UpToDateTracker();
987
+
718
988
  // src/snapshot-tracker.ts
719
989
  var SnapshotTracker = class {
720
990
  constructor() {
@@ -832,7 +1102,7 @@ function canonicalShapeKey(url) {
832
1102
  cleanUrl.searchParams.sort();
833
1103
  return cleanUrl.toString();
834
1104
  }
835
- var _error, _fetchClient2, _sseFetchClient, _messageParser, _subscribers, _started, _state, _lastOffset, _liveCacheBuster, _lastSyncedAt, _isUpToDate, _isMidStream, _connected, _shapeHandle, _mode, _schema, _onError, _requestAbortController, _isRefreshing, _tickPromise, _tickPromiseResolver, _tickPromiseRejecter, _messageChain, _snapshotTracker, _activeSnapshotRequests, _midStreamPromise, _midStreamPromiseResolver, _lastSseConnectionStartTime, _minSseConnectionDuration, _consecutiveShortSseConnections, _maxShortSseConnections, _sseFallbackToLongPolling, _sseBackoffBaseDelay, _sseBackoffMaxDelay, _unsubscribeFromVisibilityChanges, _ShapeStream_instances, start_fn, requestShape_fn, constructUrl_fn, createAbortListener_fn, onInitialResponse_fn, onMessages_fn, fetchShape_fn, requestShapeLongPoll_fn, requestShapeSSE_fn, pause_fn, resume_fn, nextTick_fn, waitForStreamEnd_fn, publish_fn, sendErrorToSubscribers_fn, subscribeToVisibilityChanges_fn, reset_fn;
1105
+ var _error, _fetchClient2, _sseFetchClient, _messageParser, _subscribers, _started, _state, _lastOffset, _liveCacheBuster, _lastSyncedAt, _isUpToDate, _isMidStream, _connected, _shapeHandle, _mode, _schema, _onError, _requestAbortController, _isRefreshing, _tickPromise, _tickPromiseResolver, _tickPromiseRejecter, _messageChain, _snapshotTracker, _activeSnapshotRequests, _midStreamPromise, _midStreamPromiseResolver, _lastSeenCursor, _currentFetchUrl, _lastSseConnectionStartTime, _minSseConnectionDuration, _consecutiveShortSseConnections, _maxShortSseConnections, _sseFallbackToLongPolling, _sseBackoffBaseDelay, _sseBackoffMaxDelay, _unsubscribeFromVisibilityChanges, _ShapeStream_instances, replayMode_get, start_fn, requestShape_fn, constructUrl_fn, createAbortListener_fn, onInitialResponse_fn, onMessages_fn, fetchShape_fn, requestShapeLongPoll_fn, requestShapeSSE_fn, pause_fn, resume_fn, nextTick_fn, waitForStreamEnd_fn, publish_fn, sendErrorToSubscribers_fn, subscribeToVisibilityChanges_fn, reset_fn;
836
1106
  var ShapeStream = class {
837
1107
  constructor(options) {
838
1108
  __privateAdd(this, _ShapeStream_instances);
@@ -867,6 +1137,10 @@ var ShapeStream = class {
867
1137
  // counter for concurrent snapshot requests
868
1138
  __privateAdd(this, _midStreamPromise);
869
1139
  __privateAdd(this, _midStreamPromiseResolver);
1140
+ __privateAdd(this, _lastSeenCursor);
1141
+ // Last seen cursor from previous session (used to detect cached responses)
1142
+ __privateAdd(this, _currentFetchUrl);
1143
+ // Current fetch URL for computing shape key
870
1144
  __privateAdd(this, _lastSseConnectionStartTime);
871
1145
  __privateAdd(this, _minSseConnectionDuration, 1e3);
872
1146
  // Minimum expected SSE connection duration (1 second)
@@ -885,10 +1159,21 @@ var ShapeStream = class {
885
1159
  __privateSet(this, _lastOffset, (_a = this.options.offset) != null ? _a : `-1`);
886
1160
  __privateSet(this, _liveCacheBuster, ``);
887
1161
  __privateSet(this, _shapeHandle, this.options.handle);
888
- __privateSet(this, _messageParser, new MessageParser(
889
- options.parser,
890
- options.transformer
891
- ));
1162
+ let transformer;
1163
+ if (options.columnMapper) {
1164
+ const applyColumnMapper = (row) => {
1165
+ const result = {};
1166
+ for (const [dbKey, value] of Object.entries(row)) {
1167
+ const appKey = options.columnMapper.decode(dbKey);
1168
+ result[appKey] = value;
1169
+ }
1170
+ return result;
1171
+ };
1172
+ transformer = options.transformer ? (row) => options.transformer(applyColumnMapper(row)) : applyColumnMapper;
1173
+ } else {
1174
+ transformer = options.transformer;
1175
+ }
1176
+ __privateSet(this, _messageParser, new MessageParser(options.parser, transformer));
892
1177
  __privateSet(this, _onError, this.options.onError);
893
1178
  __privateSet(this, _mode, (_b = this.options.log) != null ? _b : `full`);
894
1179
  const baseFetchClient = (_c = options.fetchClient) != null ? _c : (...args) => fetch(...args);
@@ -1093,6 +1378,8 @@ _snapshotTracker = new WeakMap();
1093
1378
  _activeSnapshotRequests = new WeakMap();
1094
1379
  _midStreamPromise = new WeakMap();
1095
1380
  _midStreamPromiseResolver = new WeakMap();
1381
+ _lastSeenCursor = new WeakMap();
1382
+ _currentFetchUrl = new WeakMap();
1096
1383
  _lastSseConnectionStartTime = new WeakMap();
1097
1384
  _minSseConnectionDuration = new WeakMap();
1098
1385
  _consecutiveShortSseConnections = new WeakMap();
@@ -1102,6 +1389,9 @@ _sseBackoffBaseDelay = new WeakMap();
1102
1389
  _sseBackoffMaxDelay = new WeakMap();
1103
1390
  _unsubscribeFromVisibilityChanges = new WeakMap();
1104
1391
  _ShapeStream_instances = new WeakSet();
1392
+ replayMode_get = function() {
1393
+ return __privateGet(this, _lastSeenCursor) !== void 0;
1394
+ };
1105
1395
  start_fn = function() {
1106
1396
  return __async(this, null, function* () {
1107
1397
  var _a, _b, _c, _d, _e;
@@ -1201,6 +1491,7 @@ requestShape_fn = function() {
1201
1491
  };
1202
1492
  constructUrl_fn = function(url, resumingFromPause, subsetParams) {
1203
1493
  return __async(this, null, function* () {
1494
+ var _a, _b, _c;
1204
1495
  const [requestHeaders, params] = yield Promise.all([
1205
1496
  resolveHeaders(this.options.headers),
1206
1497
  this.options.params ? toInternalParams(convertWhereParamsToObj(this.options.params)) : void 0
@@ -1209,7 +1500,13 @@ constructUrl_fn = function(url, resumingFromPause, subsetParams) {
1209
1500
  const fetchUrl = new URL(url);
1210
1501
  if (params) {
1211
1502
  if (params.table) setQueryParam(fetchUrl, TABLE_QUERY_PARAM, params.table);
1212
- if (params.where) setQueryParam(fetchUrl, WHERE_QUERY_PARAM, params.where);
1503
+ if (params.where && typeof params.where === `string`) {
1504
+ const encodedWhere = encodeWhereClause(
1505
+ params.where,
1506
+ (_a = this.options.columnMapper) == null ? void 0 : _a.encode
1507
+ );
1508
+ setQueryParam(fetchUrl, WHERE_QUERY_PARAM, encodedWhere);
1509
+ }
1213
1510
  if (params.columns)
1214
1511
  setQueryParam(fetchUrl, COLUMNS_QUERY_PARAM, params.columns);
1215
1512
  if (params.replica) setQueryParam(fetchUrl, REPLICA_PARAM, params.replica);
@@ -1226,16 +1523,29 @@ constructUrl_fn = function(url, resumingFromPause, subsetParams) {
1226
1523
  }
1227
1524
  }
1228
1525
  if (subsetParams) {
1229
- if (subsetParams.where)
1230
- setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, subsetParams.where);
1526
+ if (subsetParams.where && typeof subsetParams.where === `string`) {
1527
+ const encodedWhere = encodeWhereClause(
1528
+ subsetParams.where,
1529
+ (_b = this.options.columnMapper) == null ? void 0 : _b.encode
1530
+ );
1531
+ setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, encodedWhere);
1532
+ }
1231
1533
  if (subsetParams.params)
1232
- setQueryParam(fetchUrl, SUBSET_PARAM_WHERE_PARAMS, subsetParams.params);
1534
+ fetchUrl.searchParams.set(
1535
+ SUBSET_PARAM_WHERE_PARAMS,
1536
+ JSON.stringify(subsetParams.params)
1537
+ );
1233
1538
  if (subsetParams.limit)
1234
1539
  setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit);
1235
1540
  if (subsetParams.offset)
1236
1541
  setQueryParam(fetchUrl, SUBSET_PARAM_OFFSET, subsetParams.offset);
1237
- if (subsetParams.orderBy)
1238
- setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, subsetParams.orderBy);
1542
+ if (subsetParams.orderBy && typeof subsetParams.orderBy === `string`) {
1543
+ const encodedOrderBy = encodeWhereClause(
1544
+ subsetParams.orderBy,
1545
+ (_c = this.options.columnMapper) == null ? void 0 : _c.encode
1546
+ );
1547
+ setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, encodedOrderBy);
1548
+ }
1239
1549
  }
1240
1550
  fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, __privateGet(this, _lastOffset));
1241
1551
  fetchUrl.searchParams.set(LOG_MODE_QUERY_PARAM, __privateGet(this, _mode));
@@ -1320,6 +1630,17 @@ onMessages_fn = function(batch, isSseMessage = false) {
1320
1630
  __privateSet(this, _isUpToDate, true);
1321
1631
  __privateSet(this, _isMidStream, false);
1322
1632
  (_a = __privateGet(this, _midStreamPromiseResolver)) == null ? void 0 : _a.call(this);
1633
+ if (__privateGet(this, _ShapeStream_instances, replayMode_get) && !isSseMessage) {
1634
+ const currentCursor = __privateGet(this, _liveCacheBuster);
1635
+ if (currentCursor === __privateGet(this, _lastSeenCursor)) {
1636
+ return;
1637
+ }
1638
+ }
1639
+ __privateSet(this, _lastSeenCursor, void 0);
1640
+ if (__privateGet(this, _currentFetchUrl)) {
1641
+ const shapeKey = canonicalShapeKey(__privateGet(this, _currentFetchUrl));
1642
+ upToDateTracker.recordUpToDate(shapeKey, __privateGet(this, _liveCacheBuster));
1643
+ }
1323
1644
  }
1324
1645
  const messagesToProcess = batch.filter((message) => {
1325
1646
  if (isChangeMessage(message)) {
@@ -1334,6 +1655,14 @@ onMessages_fn = function(batch, isSseMessage = false) {
1334
1655
  fetchShape_fn = function(opts) {
1335
1656
  return __async(this, null, function* () {
1336
1657
  var _a;
1658
+ __privateSet(this, _currentFetchUrl, opts.fetchUrl);
1659
+ if (!__privateGet(this, _isUpToDate) && !__privateGet(this, _ShapeStream_instances, replayMode_get)) {
1660
+ const shapeKey = canonicalShapeKey(opts.fetchUrl);
1661
+ const lastSeenCursor = upToDateTracker.shouldEnterReplayMode(shapeKey);
1662
+ if (lastSeenCursor) {
1663
+ __privateSet(this, _lastSeenCursor, lastSeenCursor);
1664
+ }
1665
+ }
1337
1666
  const useSse = (_a = this.options.liveSse) != null ? _a : this.options.experimentalLiveSse;
1338
1667
  if (__privateGet(this, _isUpToDate) && useSse && !__privateGet(this, _isRefreshing) && !opts.resumingFromPause && !__privateGet(this, _sseFallbackToLongPolling)) {
1339
1668
  opts.fetchUrl.searchParams.set(EXPERIMENTAL_LIVE_SSE_QUERY_PARAM, `true`);
@@ -1804,9 +2133,13 @@ notify_fn = function() {
1804
2133
  FetchError,
1805
2134
  Shape,
1806
2135
  ShapeStream,
2136
+ camelToSnake,
2137
+ createColumnMapper,
1807
2138
  isChangeMessage,
1808
2139
  isControlMessage,
1809
2140
  isVisibleInSnapshot,
1810
- resolveValue
2141
+ resolveValue,
2142
+ snakeCamelMapper,
2143
+ snakeToCamel
1811
2144
  });
1812
2145
  //# sourceMappingURL=index.cjs.map