@electric-sql/client 1.1.5 → 1.2.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/dist/index.mjs CHANGED
@@ -291,6 +291,151 @@ function makeNullableParser(parser, columnInfo, columnName) {
291
291
  };
292
292
  }
293
293
 
294
+ // src/column-mapper.ts
295
+ function snakeToCamel(str) {
296
+ var _a, _b, _c, _d;
297
+ const leadingUnderscores = (_b = (_a = str.match(/^_+/)) == null ? void 0 : _a[0]) != null ? _b : ``;
298
+ const withoutLeading = str.slice(leadingUnderscores.length);
299
+ const trailingUnderscores = (_d = (_c = withoutLeading.match(/_+$/)) == null ? void 0 : _c[0]) != null ? _d : ``;
300
+ const core = trailingUnderscores ? withoutLeading.slice(
301
+ 0,
302
+ withoutLeading.length - trailingUnderscores.length
303
+ ) : withoutLeading;
304
+ const normalized = core.toLowerCase();
305
+ const camelCased = normalized.replace(
306
+ /_+([a-z])/g,
307
+ (_, letter) => letter.toUpperCase()
308
+ );
309
+ return leadingUnderscores + camelCased + trailingUnderscores;
310
+ }
311
+ function camelToSnake(str) {
312
+ return str.replace(/([a-z])([A-Z])/g, `$1_$2`).replace(/([A-Z]+)([A-Z][a-z])/g, `$1_$2`).toLowerCase();
313
+ }
314
+ function createColumnMapper(mapping) {
315
+ const reverseMapping = {};
316
+ for (const [dbName, appName] of Object.entries(mapping)) {
317
+ reverseMapping[appName] = dbName;
318
+ }
319
+ return {
320
+ decode: (dbColumnName) => {
321
+ var _a;
322
+ return (_a = mapping[dbColumnName]) != null ? _a : dbColumnName;
323
+ },
324
+ encode: (appColumnName) => {
325
+ var _a;
326
+ return (_a = reverseMapping[appColumnName]) != null ? _a : appColumnName;
327
+ }
328
+ };
329
+ }
330
+ function encodeWhereClause(whereClause, encode) {
331
+ if (!whereClause || !encode) return whereClause != null ? whereClause : ``;
332
+ const sqlKeywords = /* @__PURE__ */ new Set([
333
+ `SELECT`,
334
+ `FROM`,
335
+ `WHERE`,
336
+ `AND`,
337
+ `OR`,
338
+ `NOT`,
339
+ `IN`,
340
+ `IS`,
341
+ `NULL`,
342
+ `NULLS`,
343
+ `FIRST`,
344
+ `LAST`,
345
+ `TRUE`,
346
+ `FALSE`,
347
+ `LIKE`,
348
+ `ILIKE`,
349
+ `BETWEEN`,
350
+ `ASC`,
351
+ `DESC`,
352
+ `LIMIT`,
353
+ `OFFSET`,
354
+ `ORDER`,
355
+ `BY`,
356
+ `GROUP`,
357
+ `HAVING`,
358
+ `DISTINCT`,
359
+ `AS`,
360
+ `ON`,
361
+ `JOIN`,
362
+ `LEFT`,
363
+ `RIGHT`,
364
+ `INNER`,
365
+ `OUTER`,
366
+ `CROSS`,
367
+ `CASE`,
368
+ `WHEN`,
369
+ `THEN`,
370
+ `ELSE`,
371
+ `END`,
372
+ `CAST`,
373
+ `LOWER`,
374
+ `UPPER`,
375
+ `COALESCE`,
376
+ `NULLIF`
377
+ ]);
378
+ const quotedRanges = [];
379
+ let pos = 0;
380
+ while (pos < whereClause.length) {
381
+ const ch = whereClause[pos];
382
+ if (ch === `'` || ch === `"`) {
383
+ const start = pos;
384
+ const quoteChar = ch;
385
+ pos++;
386
+ while (pos < whereClause.length) {
387
+ if (whereClause[pos] === quoteChar) {
388
+ if (whereClause[pos + 1] === quoteChar) {
389
+ pos += 2;
390
+ } else {
391
+ pos++;
392
+ break;
393
+ }
394
+ } else {
395
+ pos++;
396
+ }
397
+ }
398
+ quotedRanges.push({ start, end: pos });
399
+ } else {
400
+ pos++;
401
+ }
402
+ }
403
+ const isInQuotedString = (pos2) => {
404
+ return quotedRanges.some((range) => pos2 >= range.start && pos2 < range.end);
405
+ };
406
+ const identifierPattern = new RegExp("(?<![a-zA-Z0-9_])([a-zA-Z_][a-zA-Z0-9_]*)(?![a-zA-Z0-9_])", "g");
407
+ return whereClause.replace(identifierPattern, (match, _p1, offset) => {
408
+ if (isInQuotedString(offset)) {
409
+ return match;
410
+ }
411
+ if (sqlKeywords.has(match.toUpperCase())) {
412
+ return match;
413
+ }
414
+ if (match.startsWith(`$`)) {
415
+ return match;
416
+ }
417
+ const encoded = encode(match);
418
+ return encoded;
419
+ });
420
+ }
421
+ function snakeCamelMapper(schema) {
422
+ if (schema) {
423
+ const mapping = {};
424
+ for (const dbColumn of Object.keys(schema)) {
425
+ mapping[dbColumn] = snakeToCamel(dbColumn);
426
+ }
427
+ return createColumnMapper(mapping);
428
+ }
429
+ return {
430
+ decode: (dbColumnName) => {
431
+ return snakeToCamel(dbColumnName);
432
+ },
433
+ encode: (appColumnName) => {
434
+ return camelToSnake(appColumnName);
435
+ }
436
+ };
437
+ }
438
+
294
439
  // src/helpers.ts
295
440
  function isChangeMessage(message) {
296
441
  return `key` in message;
@@ -686,6 +831,127 @@ var ExpiredShapesCache = class {
686
831
  };
687
832
  var expiredShapesCache = new ExpiredShapesCache();
688
833
 
834
+ // src/up-to-date-tracker.ts
835
+ var UpToDateTracker = class {
836
+ constructor() {
837
+ this.data = {};
838
+ this.storageKey = `electric_up_to_date_tracker`;
839
+ this.cacheTTL = 6e4;
840
+ // 60s to match typical CDN s-maxage cache duration
841
+ this.maxEntries = 250;
842
+ this.writeThrottleMs = 6e4;
843
+ // Throttle localStorage writes to once per 60s
844
+ this.lastWriteTime = 0;
845
+ this.load();
846
+ this.cleanup();
847
+ }
848
+ /**
849
+ * Records that a shape received an up-to-date message with a specific cursor.
850
+ * This timestamp and cursor are used to detect cache replay scenarios.
851
+ * Updates in-memory immediately, but throttles localStorage writes.
852
+ */
853
+ recordUpToDate(shapeKey, cursor) {
854
+ this.data[shapeKey] = {
855
+ timestamp: Date.now(),
856
+ cursor
857
+ };
858
+ const keys = Object.keys(this.data);
859
+ if (keys.length > this.maxEntries) {
860
+ const oldest = keys.reduce(
861
+ (min, k) => this.data[k].timestamp < this.data[min].timestamp ? k : min
862
+ );
863
+ delete this.data[oldest];
864
+ }
865
+ this.scheduleSave();
866
+ }
867
+ /**
868
+ * Schedules a throttled save to localStorage.
869
+ * Writes immediately if enough time has passed, otherwise schedules for later.
870
+ */
871
+ scheduleSave() {
872
+ const now = Date.now();
873
+ const timeSinceLastWrite = now - this.lastWriteTime;
874
+ if (timeSinceLastWrite >= this.writeThrottleMs) {
875
+ this.lastWriteTime = now;
876
+ this.save();
877
+ } else if (!this.pendingSaveTimer) {
878
+ const delay = this.writeThrottleMs - timeSinceLastWrite;
879
+ this.pendingSaveTimer = setTimeout(() => {
880
+ this.lastWriteTime = Date.now();
881
+ this.pendingSaveTimer = void 0;
882
+ this.save();
883
+ }, delay);
884
+ }
885
+ }
886
+ /**
887
+ * Checks if we should enter replay mode for this shape.
888
+ * Returns the last seen cursor if there's a recent up-to-date (< 60s),
889
+ * which means we'll likely be replaying cached responses.
890
+ * Returns null if no recent up-to-date exists.
891
+ */
892
+ shouldEnterReplayMode(shapeKey) {
893
+ const entry = this.data[shapeKey];
894
+ if (!entry) {
895
+ return null;
896
+ }
897
+ const age = Date.now() - entry.timestamp;
898
+ if (age >= this.cacheTTL) {
899
+ return null;
900
+ }
901
+ return entry.cursor;
902
+ }
903
+ /**
904
+ * Cleans up expired entries from the cache.
905
+ * Called on initialization and can be called periodically.
906
+ */
907
+ cleanup() {
908
+ const now = Date.now();
909
+ const keys = Object.keys(this.data);
910
+ let modified = false;
911
+ for (const key of keys) {
912
+ const age = now - this.data[key].timestamp;
913
+ if (age > this.cacheTTL) {
914
+ delete this.data[key];
915
+ modified = true;
916
+ }
917
+ }
918
+ if (modified) {
919
+ this.save();
920
+ }
921
+ }
922
+ save() {
923
+ if (typeof localStorage === `undefined`) return;
924
+ try {
925
+ localStorage.setItem(this.storageKey, JSON.stringify(this.data));
926
+ } catch (e) {
927
+ }
928
+ }
929
+ load() {
930
+ if (typeof localStorage === `undefined`) return;
931
+ try {
932
+ const stored = localStorage.getItem(this.storageKey);
933
+ if (stored) {
934
+ this.data = JSON.parse(stored);
935
+ }
936
+ } catch (e) {
937
+ this.data = {};
938
+ }
939
+ }
940
+ /**
941
+ * Clears all tracked up-to-date timestamps.
942
+ * Useful for testing or manual cache invalidation.
943
+ */
944
+ clear() {
945
+ this.data = {};
946
+ if (this.pendingSaveTimer) {
947
+ clearTimeout(this.pendingSaveTimer);
948
+ this.pendingSaveTimer = void 0;
949
+ }
950
+ this.save();
951
+ }
952
+ };
953
+ var upToDateTracker = new UpToDateTracker();
954
+
689
955
  // src/snapshot-tracker.ts
690
956
  var SnapshotTracker = class {
691
957
  constructor() {
@@ -803,7 +1069,7 @@ function canonicalShapeKey(url) {
803
1069
  cleanUrl.searchParams.sort();
804
1070
  return cleanUrl.toString();
805
1071
  }
806
- 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;
1072
+ 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;
807
1073
  var ShapeStream = class {
808
1074
  constructor(options) {
809
1075
  __privateAdd(this, _ShapeStream_instances);
@@ -838,6 +1104,10 @@ var ShapeStream = class {
838
1104
  // counter for concurrent snapshot requests
839
1105
  __privateAdd(this, _midStreamPromise);
840
1106
  __privateAdd(this, _midStreamPromiseResolver);
1107
+ __privateAdd(this, _lastSeenCursor);
1108
+ // Last seen cursor from previous session (used to detect cached responses)
1109
+ __privateAdd(this, _currentFetchUrl);
1110
+ // Current fetch URL for computing shape key
841
1111
  __privateAdd(this, _lastSseConnectionStartTime);
842
1112
  __privateAdd(this, _minSseConnectionDuration, 1e3);
843
1113
  // Minimum expected SSE connection duration (1 second)
@@ -856,10 +1126,21 @@ var ShapeStream = class {
856
1126
  __privateSet(this, _lastOffset, (_a = this.options.offset) != null ? _a : `-1`);
857
1127
  __privateSet(this, _liveCacheBuster, ``);
858
1128
  __privateSet(this, _shapeHandle, this.options.handle);
859
- __privateSet(this, _messageParser, new MessageParser(
860
- options.parser,
861
- options.transformer
862
- ));
1129
+ let transformer;
1130
+ if (options.columnMapper) {
1131
+ const applyColumnMapper = (row) => {
1132
+ const result = {};
1133
+ for (const [dbKey, value] of Object.entries(row)) {
1134
+ const appKey = options.columnMapper.decode(dbKey);
1135
+ result[appKey] = value;
1136
+ }
1137
+ return result;
1138
+ };
1139
+ transformer = options.transformer ? (row) => options.transformer(applyColumnMapper(row)) : applyColumnMapper;
1140
+ } else {
1141
+ transformer = options.transformer;
1142
+ }
1143
+ __privateSet(this, _messageParser, new MessageParser(options.parser, transformer));
863
1144
  __privateSet(this, _onError, this.options.onError);
864
1145
  __privateSet(this, _mode, (_b = this.options.log) != null ? _b : `full`);
865
1146
  const baseFetchClient = (_c = options.fetchClient) != null ? _c : (...args) => fetch(...args);
@@ -1064,6 +1345,8 @@ _snapshotTracker = new WeakMap();
1064
1345
  _activeSnapshotRequests = new WeakMap();
1065
1346
  _midStreamPromise = new WeakMap();
1066
1347
  _midStreamPromiseResolver = new WeakMap();
1348
+ _lastSeenCursor = new WeakMap();
1349
+ _currentFetchUrl = new WeakMap();
1067
1350
  _lastSseConnectionStartTime = new WeakMap();
1068
1351
  _minSseConnectionDuration = new WeakMap();
1069
1352
  _consecutiveShortSseConnections = new WeakMap();
@@ -1073,6 +1356,9 @@ _sseBackoffBaseDelay = new WeakMap();
1073
1356
  _sseBackoffMaxDelay = new WeakMap();
1074
1357
  _unsubscribeFromVisibilityChanges = new WeakMap();
1075
1358
  _ShapeStream_instances = new WeakSet();
1359
+ replayMode_get = function() {
1360
+ return __privateGet(this, _lastSeenCursor) !== void 0;
1361
+ };
1076
1362
  start_fn = function() {
1077
1363
  return __async(this, null, function* () {
1078
1364
  var _a, _b, _c, _d, _e;
@@ -1172,6 +1458,7 @@ requestShape_fn = function() {
1172
1458
  };
1173
1459
  constructUrl_fn = function(url, resumingFromPause, subsetParams) {
1174
1460
  return __async(this, null, function* () {
1461
+ var _a, _b, _c;
1175
1462
  const [requestHeaders, params] = yield Promise.all([
1176
1463
  resolveHeaders(this.options.headers),
1177
1464
  this.options.params ? toInternalParams(convertWhereParamsToObj(this.options.params)) : void 0
@@ -1180,7 +1467,13 @@ constructUrl_fn = function(url, resumingFromPause, subsetParams) {
1180
1467
  const fetchUrl = new URL(url);
1181
1468
  if (params) {
1182
1469
  if (params.table) setQueryParam(fetchUrl, TABLE_QUERY_PARAM, params.table);
1183
- if (params.where) setQueryParam(fetchUrl, WHERE_QUERY_PARAM, params.where);
1470
+ if (params.where && typeof params.where === `string`) {
1471
+ const encodedWhere = encodeWhereClause(
1472
+ params.where,
1473
+ (_a = this.options.columnMapper) == null ? void 0 : _a.encode
1474
+ );
1475
+ setQueryParam(fetchUrl, WHERE_QUERY_PARAM, encodedWhere);
1476
+ }
1184
1477
  if (params.columns)
1185
1478
  setQueryParam(fetchUrl, COLUMNS_QUERY_PARAM, params.columns);
1186
1479
  if (params.replica) setQueryParam(fetchUrl, REPLICA_PARAM, params.replica);
@@ -1197,16 +1490,29 @@ constructUrl_fn = function(url, resumingFromPause, subsetParams) {
1197
1490
  }
1198
1491
  }
1199
1492
  if (subsetParams) {
1200
- if (subsetParams.where)
1201
- setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, subsetParams.where);
1493
+ if (subsetParams.where && typeof subsetParams.where === `string`) {
1494
+ const encodedWhere = encodeWhereClause(
1495
+ subsetParams.where,
1496
+ (_b = this.options.columnMapper) == null ? void 0 : _b.encode
1497
+ );
1498
+ setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, encodedWhere);
1499
+ }
1202
1500
  if (subsetParams.params)
1203
- setQueryParam(fetchUrl, SUBSET_PARAM_WHERE_PARAMS, subsetParams.params);
1501
+ fetchUrl.searchParams.set(
1502
+ SUBSET_PARAM_WHERE_PARAMS,
1503
+ JSON.stringify(subsetParams.params)
1504
+ );
1204
1505
  if (subsetParams.limit)
1205
1506
  setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit);
1206
1507
  if (subsetParams.offset)
1207
1508
  setQueryParam(fetchUrl, SUBSET_PARAM_OFFSET, subsetParams.offset);
1208
- if (subsetParams.orderBy)
1209
- setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, subsetParams.orderBy);
1509
+ if (subsetParams.orderBy && typeof subsetParams.orderBy === `string`) {
1510
+ const encodedOrderBy = encodeWhereClause(
1511
+ subsetParams.orderBy,
1512
+ (_c = this.options.columnMapper) == null ? void 0 : _c.encode
1513
+ );
1514
+ setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, encodedOrderBy);
1515
+ }
1210
1516
  }
1211
1517
  fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, __privateGet(this, _lastOffset));
1212
1518
  fetchUrl.searchParams.set(LOG_MODE_QUERY_PARAM, __privateGet(this, _mode));
@@ -1291,6 +1597,17 @@ onMessages_fn = function(batch, isSseMessage = false) {
1291
1597
  __privateSet(this, _isUpToDate, true);
1292
1598
  __privateSet(this, _isMidStream, false);
1293
1599
  (_a = __privateGet(this, _midStreamPromiseResolver)) == null ? void 0 : _a.call(this);
1600
+ if (__privateGet(this, _ShapeStream_instances, replayMode_get) && !isSseMessage) {
1601
+ const currentCursor = __privateGet(this, _liveCacheBuster);
1602
+ if (currentCursor === __privateGet(this, _lastSeenCursor)) {
1603
+ return;
1604
+ }
1605
+ }
1606
+ __privateSet(this, _lastSeenCursor, void 0);
1607
+ if (__privateGet(this, _currentFetchUrl)) {
1608
+ const shapeKey = canonicalShapeKey(__privateGet(this, _currentFetchUrl));
1609
+ upToDateTracker.recordUpToDate(shapeKey, __privateGet(this, _liveCacheBuster));
1610
+ }
1294
1611
  }
1295
1612
  const messagesToProcess = batch.filter((message) => {
1296
1613
  if (isChangeMessage(message)) {
@@ -1305,6 +1622,14 @@ onMessages_fn = function(batch, isSseMessage = false) {
1305
1622
  fetchShape_fn = function(opts) {
1306
1623
  return __async(this, null, function* () {
1307
1624
  var _a;
1625
+ __privateSet(this, _currentFetchUrl, opts.fetchUrl);
1626
+ if (!__privateGet(this, _isUpToDate) && !__privateGet(this, _ShapeStream_instances, replayMode_get)) {
1627
+ const shapeKey = canonicalShapeKey(opts.fetchUrl);
1628
+ const lastSeenCursor = upToDateTracker.shouldEnterReplayMode(shapeKey);
1629
+ if (lastSeenCursor) {
1630
+ __privateSet(this, _lastSeenCursor, lastSeenCursor);
1631
+ }
1632
+ }
1308
1633
  const useSse = (_a = this.options.liveSse) != null ? _a : this.options.experimentalLiveSse;
1309
1634
  if (__privateGet(this, _isUpToDate) && useSse && !__privateGet(this, _isRefreshing) && !opts.resumingFromPause && !__privateGet(this, _sseFallbackToLongPolling)) {
1310
1635
  opts.fetchUrl.searchParams.set(EXPERIMENTAL_LIVE_SSE_QUERY_PARAM, `true`);
@@ -1335,10 +1660,13 @@ requestShapeSSE_fn = function(opts) {
1335
1660
  const { fetchUrl, requestAbortController, headers } = opts;
1336
1661
  const fetch2 = __privateGet(this, _sseFetchClient);
1337
1662
  __privateSet(this, _lastSseConnectionStartTime, Date.now());
1663
+ const sseHeaders = __spreadProps(__spreadValues({}, headers), {
1664
+ Accept: `text/event-stream`
1665
+ });
1338
1666
  try {
1339
1667
  let buffer = [];
1340
1668
  yield fetchEventSource(fetchUrl.toString(), {
1341
- headers,
1669
+ headers: sseHeaders,
1342
1670
  fetch: fetch2,
1343
1671
  onopen: (response) => __async(this, null, function* () {
1344
1672
  __privateSet(this, _connected, true);
@@ -1774,9 +2102,13 @@ export {
1774
2102
  FetchError,
1775
2103
  Shape,
1776
2104
  ShapeStream,
2105
+ camelToSnake,
2106
+ createColumnMapper,
1777
2107
  isChangeMessage,
1778
2108
  isControlMessage,
1779
2109
  isVisibleInSnapshot,
1780
- resolveValue
2110
+ resolveValue,
2111
+ snakeCamelMapper,
2112
+ snakeToCamel
1781
2113
  };
1782
2114
  //# sourceMappingURL=index.mjs.map