@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.
@@ -269,6 +269,151 @@ function makeNullableParser(parser, columnInfo, columnName) {
269
269
  };
270
270
  }
271
271
 
272
+ // src/column-mapper.ts
273
+ function snakeToCamel(str) {
274
+ var _a, _b, _c, _d;
275
+ const leadingUnderscores = (_b = (_a = str.match(/^_+/)) == null ? void 0 : _a[0]) != null ? _b : ``;
276
+ const withoutLeading = str.slice(leadingUnderscores.length);
277
+ const trailingUnderscores = (_d = (_c = withoutLeading.match(/_+$/)) == null ? void 0 : _c[0]) != null ? _d : ``;
278
+ const core = trailingUnderscores ? withoutLeading.slice(
279
+ 0,
280
+ withoutLeading.length - trailingUnderscores.length
281
+ ) : withoutLeading;
282
+ const normalized = core.toLowerCase();
283
+ const camelCased = normalized.replace(
284
+ /_+([a-z])/g,
285
+ (_, letter) => letter.toUpperCase()
286
+ );
287
+ return leadingUnderscores + camelCased + trailingUnderscores;
288
+ }
289
+ function camelToSnake(str) {
290
+ return str.replace(/([a-z])([A-Z])/g, `$1_$2`).replace(/([A-Z]+)([A-Z][a-z])/g, `$1_$2`).toLowerCase();
291
+ }
292
+ function createColumnMapper(mapping) {
293
+ const reverseMapping = {};
294
+ for (const [dbName, appName] of Object.entries(mapping)) {
295
+ reverseMapping[appName] = dbName;
296
+ }
297
+ return {
298
+ decode: (dbColumnName) => {
299
+ var _a;
300
+ return (_a = mapping[dbColumnName]) != null ? _a : dbColumnName;
301
+ },
302
+ encode: (appColumnName) => {
303
+ var _a;
304
+ return (_a = reverseMapping[appColumnName]) != null ? _a : appColumnName;
305
+ }
306
+ };
307
+ }
308
+ function encodeWhereClause(whereClause, encode) {
309
+ if (!whereClause || !encode) return whereClause != null ? whereClause : ``;
310
+ const sqlKeywords = /* @__PURE__ */ new Set([
311
+ `SELECT`,
312
+ `FROM`,
313
+ `WHERE`,
314
+ `AND`,
315
+ `OR`,
316
+ `NOT`,
317
+ `IN`,
318
+ `IS`,
319
+ `NULL`,
320
+ `NULLS`,
321
+ `FIRST`,
322
+ `LAST`,
323
+ `TRUE`,
324
+ `FALSE`,
325
+ `LIKE`,
326
+ `ILIKE`,
327
+ `BETWEEN`,
328
+ `ASC`,
329
+ `DESC`,
330
+ `LIMIT`,
331
+ `OFFSET`,
332
+ `ORDER`,
333
+ `BY`,
334
+ `GROUP`,
335
+ `HAVING`,
336
+ `DISTINCT`,
337
+ `AS`,
338
+ `ON`,
339
+ `JOIN`,
340
+ `LEFT`,
341
+ `RIGHT`,
342
+ `INNER`,
343
+ `OUTER`,
344
+ `CROSS`,
345
+ `CASE`,
346
+ `WHEN`,
347
+ `THEN`,
348
+ `ELSE`,
349
+ `END`,
350
+ `CAST`,
351
+ `LOWER`,
352
+ `UPPER`,
353
+ `COALESCE`,
354
+ `NULLIF`
355
+ ]);
356
+ const quotedRanges = [];
357
+ let pos = 0;
358
+ while (pos < whereClause.length) {
359
+ const ch = whereClause[pos];
360
+ if (ch === `'` || ch === `"`) {
361
+ const start = pos;
362
+ const quoteChar = ch;
363
+ pos++;
364
+ while (pos < whereClause.length) {
365
+ if (whereClause[pos] === quoteChar) {
366
+ if (whereClause[pos + 1] === quoteChar) {
367
+ pos += 2;
368
+ } else {
369
+ pos++;
370
+ break;
371
+ }
372
+ } else {
373
+ pos++;
374
+ }
375
+ }
376
+ quotedRanges.push({ start, end: pos });
377
+ } else {
378
+ pos++;
379
+ }
380
+ }
381
+ const isInQuotedString = (pos2) => {
382
+ return quotedRanges.some((range) => pos2 >= range.start && pos2 < range.end);
383
+ };
384
+ const identifierPattern = new RegExp("(?<![a-zA-Z0-9_])([a-zA-Z_][a-zA-Z0-9_]*)(?![a-zA-Z0-9_])", "g");
385
+ return whereClause.replace(identifierPattern, (match, _p1, offset) => {
386
+ if (isInQuotedString(offset)) {
387
+ return match;
388
+ }
389
+ if (sqlKeywords.has(match.toUpperCase())) {
390
+ return match;
391
+ }
392
+ if (match.startsWith(`$`)) {
393
+ return match;
394
+ }
395
+ const encoded = encode(match);
396
+ return encoded;
397
+ });
398
+ }
399
+ function snakeCamelMapper(schema) {
400
+ if (schema) {
401
+ const mapping = {};
402
+ for (const dbColumn of Object.keys(schema)) {
403
+ mapping[dbColumn] = snakeToCamel(dbColumn);
404
+ }
405
+ return createColumnMapper(mapping);
406
+ }
407
+ return {
408
+ decode: (dbColumnName) => {
409
+ return snakeToCamel(dbColumnName);
410
+ },
411
+ encode: (appColumnName) => {
412
+ return camelToSnake(appColumnName);
413
+ }
414
+ };
415
+ }
416
+
272
417
  // src/helpers.ts
273
418
  function isChangeMessage(message) {
274
419
  return `key` in message;
@@ -664,6 +809,127 @@ var ExpiredShapesCache = class {
664
809
  };
665
810
  var expiredShapesCache = new ExpiredShapesCache();
666
811
 
812
+ // src/up-to-date-tracker.ts
813
+ var UpToDateTracker = class {
814
+ constructor() {
815
+ this.data = {};
816
+ this.storageKey = `electric_up_to_date_tracker`;
817
+ this.cacheTTL = 6e4;
818
+ // 60s to match typical CDN s-maxage cache duration
819
+ this.maxEntries = 250;
820
+ this.writeThrottleMs = 6e4;
821
+ // Throttle localStorage writes to once per 60s
822
+ this.lastWriteTime = 0;
823
+ this.load();
824
+ this.cleanup();
825
+ }
826
+ /**
827
+ * Records that a shape received an up-to-date message with a specific cursor.
828
+ * This timestamp and cursor are used to detect cache replay scenarios.
829
+ * Updates in-memory immediately, but throttles localStorage writes.
830
+ */
831
+ recordUpToDate(shapeKey, cursor) {
832
+ this.data[shapeKey] = {
833
+ timestamp: Date.now(),
834
+ cursor
835
+ };
836
+ const keys = Object.keys(this.data);
837
+ if (keys.length > this.maxEntries) {
838
+ const oldest = keys.reduce(
839
+ (min, k) => this.data[k].timestamp < this.data[min].timestamp ? k : min
840
+ );
841
+ delete this.data[oldest];
842
+ }
843
+ this.scheduleSave();
844
+ }
845
+ /**
846
+ * Schedules a throttled save to localStorage.
847
+ * Writes immediately if enough time has passed, otherwise schedules for later.
848
+ */
849
+ scheduleSave() {
850
+ const now = Date.now();
851
+ const timeSinceLastWrite = now - this.lastWriteTime;
852
+ if (timeSinceLastWrite >= this.writeThrottleMs) {
853
+ this.lastWriteTime = now;
854
+ this.save();
855
+ } else if (!this.pendingSaveTimer) {
856
+ const delay = this.writeThrottleMs - timeSinceLastWrite;
857
+ this.pendingSaveTimer = setTimeout(() => {
858
+ this.lastWriteTime = Date.now();
859
+ this.pendingSaveTimer = void 0;
860
+ this.save();
861
+ }, delay);
862
+ }
863
+ }
864
+ /**
865
+ * Checks if we should enter replay mode for this shape.
866
+ * Returns the last seen cursor if there's a recent up-to-date (< 60s),
867
+ * which means we'll likely be replaying cached responses.
868
+ * Returns null if no recent up-to-date exists.
869
+ */
870
+ shouldEnterReplayMode(shapeKey) {
871
+ const entry = this.data[shapeKey];
872
+ if (!entry) {
873
+ return null;
874
+ }
875
+ const age = Date.now() - entry.timestamp;
876
+ if (age >= this.cacheTTL) {
877
+ return null;
878
+ }
879
+ return entry.cursor;
880
+ }
881
+ /**
882
+ * Cleans up expired entries from the cache.
883
+ * Called on initialization and can be called periodically.
884
+ */
885
+ cleanup() {
886
+ const now = Date.now();
887
+ const keys = Object.keys(this.data);
888
+ let modified = false;
889
+ for (const key of keys) {
890
+ const age = now - this.data[key].timestamp;
891
+ if (age > this.cacheTTL) {
892
+ delete this.data[key];
893
+ modified = true;
894
+ }
895
+ }
896
+ if (modified) {
897
+ this.save();
898
+ }
899
+ }
900
+ save() {
901
+ if (typeof localStorage === `undefined`) return;
902
+ try {
903
+ localStorage.setItem(this.storageKey, JSON.stringify(this.data));
904
+ } catch (e) {
905
+ }
906
+ }
907
+ load() {
908
+ if (typeof localStorage === `undefined`) return;
909
+ try {
910
+ const stored = localStorage.getItem(this.storageKey);
911
+ if (stored) {
912
+ this.data = JSON.parse(stored);
913
+ }
914
+ } catch (e) {
915
+ this.data = {};
916
+ }
917
+ }
918
+ /**
919
+ * Clears all tracked up-to-date timestamps.
920
+ * Useful for testing or manual cache invalidation.
921
+ */
922
+ clear() {
923
+ this.data = {};
924
+ if (this.pendingSaveTimer) {
925
+ clearTimeout(this.pendingSaveTimer);
926
+ this.pendingSaveTimer = void 0;
927
+ }
928
+ this.save();
929
+ }
930
+ };
931
+ var upToDateTracker = new UpToDateTracker();
932
+
667
933
  // src/snapshot-tracker.ts
668
934
  var SnapshotTracker = class {
669
935
  constructor() {
@@ -773,7 +1039,7 @@ function canonicalShapeKey(url) {
773
1039
  cleanUrl.searchParams.sort();
774
1040
  return cleanUrl.toString();
775
1041
  }
776
- 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;
1042
+ 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;
777
1043
  var ShapeStream = class {
778
1044
  constructor(options) {
779
1045
  __privateAdd(this, _ShapeStream_instances);
@@ -808,6 +1074,10 @@ var ShapeStream = class {
808
1074
  // counter for concurrent snapshot requests
809
1075
  __privateAdd(this, _midStreamPromise);
810
1076
  __privateAdd(this, _midStreamPromiseResolver);
1077
+ __privateAdd(this, _lastSeenCursor);
1078
+ // Last seen cursor from previous session (used to detect cached responses)
1079
+ __privateAdd(this, _currentFetchUrl);
1080
+ // Current fetch URL for computing shape key
811
1081
  __privateAdd(this, _lastSseConnectionStartTime);
812
1082
  __privateAdd(this, _minSseConnectionDuration, 1e3);
813
1083
  // Minimum expected SSE connection duration (1 second)
@@ -826,10 +1096,21 @@ var ShapeStream = class {
826
1096
  __privateSet(this, _lastOffset, (_a = this.options.offset) != null ? _a : `-1`);
827
1097
  __privateSet(this, _liveCacheBuster, ``);
828
1098
  __privateSet(this, _shapeHandle, this.options.handle);
829
- __privateSet(this, _messageParser, new MessageParser(
830
- options.parser,
831
- options.transformer
832
- ));
1099
+ let transformer;
1100
+ if (options.columnMapper) {
1101
+ const applyColumnMapper = (row) => {
1102
+ const result = {};
1103
+ for (const [dbKey, value] of Object.entries(row)) {
1104
+ const appKey = options.columnMapper.decode(dbKey);
1105
+ result[appKey] = value;
1106
+ }
1107
+ return result;
1108
+ };
1109
+ transformer = options.transformer ? (row) => options.transformer(applyColumnMapper(row)) : applyColumnMapper;
1110
+ } else {
1111
+ transformer = options.transformer;
1112
+ }
1113
+ __privateSet(this, _messageParser, new MessageParser(options.parser, transformer));
833
1114
  __privateSet(this, _onError, this.options.onError);
834
1115
  __privateSet(this, _mode, (_b = this.options.log) != null ? _b : `full`);
835
1116
  const baseFetchClient = (_c = options.fetchClient) != null ? _c : (...args) => fetch(...args);
@@ -1028,6 +1309,8 @@ _snapshotTracker = new WeakMap();
1028
1309
  _activeSnapshotRequests = new WeakMap();
1029
1310
  _midStreamPromise = new WeakMap();
1030
1311
  _midStreamPromiseResolver = new WeakMap();
1312
+ _lastSeenCursor = new WeakMap();
1313
+ _currentFetchUrl = new WeakMap();
1031
1314
  _lastSseConnectionStartTime = new WeakMap();
1032
1315
  _minSseConnectionDuration = new WeakMap();
1033
1316
  _consecutiveShortSseConnections = new WeakMap();
@@ -1037,6 +1320,9 @@ _sseBackoffBaseDelay = new WeakMap();
1037
1320
  _sseBackoffMaxDelay = new WeakMap();
1038
1321
  _unsubscribeFromVisibilityChanges = new WeakMap();
1039
1322
  _ShapeStream_instances = new WeakSet();
1323
+ replayMode_get = function() {
1324
+ return __privateGet(this, _lastSeenCursor) !== void 0;
1325
+ };
1040
1326
  start_fn = async function() {
1041
1327
  var _a, _b, _c, _d, _e;
1042
1328
  __privateSet(this, _started, true);
@@ -1131,6 +1417,7 @@ requestShape_fn = async function() {
1131
1417
  return __privateMethod(this, _ShapeStream_instances, requestShape_fn).call(this);
1132
1418
  };
1133
1419
  constructUrl_fn = async function(url, resumingFromPause, subsetParams) {
1420
+ var _a, _b, _c;
1134
1421
  const [requestHeaders, params] = await Promise.all([
1135
1422
  resolveHeaders(this.options.headers),
1136
1423
  this.options.params ? toInternalParams(convertWhereParamsToObj(this.options.params)) : void 0
@@ -1139,7 +1426,13 @@ constructUrl_fn = async function(url, resumingFromPause, subsetParams) {
1139
1426
  const fetchUrl = new URL(url);
1140
1427
  if (params) {
1141
1428
  if (params.table) setQueryParam(fetchUrl, TABLE_QUERY_PARAM, params.table);
1142
- if (params.where) setQueryParam(fetchUrl, WHERE_QUERY_PARAM, params.where);
1429
+ if (params.where && typeof params.where === `string`) {
1430
+ const encodedWhere = encodeWhereClause(
1431
+ params.where,
1432
+ (_a = this.options.columnMapper) == null ? void 0 : _a.encode
1433
+ );
1434
+ setQueryParam(fetchUrl, WHERE_QUERY_PARAM, encodedWhere);
1435
+ }
1143
1436
  if (params.columns)
1144
1437
  setQueryParam(fetchUrl, COLUMNS_QUERY_PARAM, params.columns);
1145
1438
  if (params.replica) setQueryParam(fetchUrl, REPLICA_PARAM, params.replica);
@@ -1156,16 +1449,29 @@ constructUrl_fn = async function(url, resumingFromPause, subsetParams) {
1156
1449
  }
1157
1450
  }
1158
1451
  if (subsetParams) {
1159
- if (subsetParams.where)
1160
- setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, subsetParams.where);
1452
+ if (subsetParams.where && typeof subsetParams.where === `string`) {
1453
+ const encodedWhere = encodeWhereClause(
1454
+ subsetParams.where,
1455
+ (_b = this.options.columnMapper) == null ? void 0 : _b.encode
1456
+ );
1457
+ setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, encodedWhere);
1458
+ }
1161
1459
  if (subsetParams.params)
1162
- setQueryParam(fetchUrl, SUBSET_PARAM_WHERE_PARAMS, subsetParams.params);
1460
+ fetchUrl.searchParams.set(
1461
+ SUBSET_PARAM_WHERE_PARAMS,
1462
+ JSON.stringify(subsetParams.params)
1463
+ );
1163
1464
  if (subsetParams.limit)
1164
1465
  setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit);
1165
1466
  if (subsetParams.offset)
1166
1467
  setQueryParam(fetchUrl, SUBSET_PARAM_OFFSET, subsetParams.offset);
1167
- if (subsetParams.orderBy)
1168
- setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, subsetParams.orderBy);
1468
+ if (subsetParams.orderBy && typeof subsetParams.orderBy === `string`) {
1469
+ const encodedOrderBy = encodeWhereClause(
1470
+ subsetParams.orderBy,
1471
+ (_c = this.options.columnMapper) == null ? void 0 : _c.encode
1472
+ );
1473
+ setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, encodedOrderBy);
1474
+ }
1169
1475
  }
1170
1476
  fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, __privateGet(this, _lastOffset));
1171
1477
  fetchUrl.searchParams.set(LOG_MODE_QUERY_PARAM, __privateGet(this, _mode));
@@ -1244,6 +1550,17 @@ onMessages_fn = async function(batch, isSseMessage = false) {
1244
1550
  __privateSet(this, _isUpToDate, true);
1245
1551
  __privateSet(this, _isMidStream, false);
1246
1552
  (_a = __privateGet(this, _midStreamPromiseResolver)) == null ? void 0 : _a.call(this);
1553
+ if (__privateGet(this, _ShapeStream_instances, replayMode_get) && !isSseMessage) {
1554
+ const currentCursor = __privateGet(this, _liveCacheBuster);
1555
+ if (currentCursor === __privateGet(this, _lastSeenCursor)) {
1556
+ return;
1557
+ }
1558
+ }
1559
+ __privateSet(this, _lastSeenCursor, void 0);
1560
+ if (__privateGet(this, _currentFetchUrl)) {
1561
+ const shapeKey = canonicalShapeKey(__privateGet(this, _currentFetchUrl));
1562
+ upToDateTracker.recordUpToDate(shapeKey, __privateGet(this, _liveCacheBuster));
1563
+ }
1247
1564
  }
1248
1565
  const messagesToProcess = batch.filter((message) => {
1249
1566
  if (isChangeMessage(message)) {
@@ -1256,6 +1573,14 @@ onMessages_fn = async function(batch, isSseMessage = false) {
1256
1573
  };
1257
1574
  fetchShape_fn = async function(opts) {
1258
1575
  var _a;
1576
+ __privateSet(this, _currentFetchUrl, opts.fetchUrl);
1577
+ if (!__privateGet(this, _isUpToDate) && !__privateGet(this, _ShapeStream_instances, replayMode_get)) {
1578
+ const shapeKey = canonicalShapeKey(opts.fetchUrl);
1579
+ const lastSeenCursor = upToDateTracker.shouldEnterReplayMode(shapeKey);
1580
+ if (lastSeenCursor) {
1581
+ __privateSet(this, _lastSeenCursor, lastSeenCursor);
1582
+ }
1583
+ }
1259
1584
  const useSse = (_a = this.options.liveSse) != null ? _a : this.options.experimentalLiveSse;
1260
1585
  if (__privateGet(this, _isUpToDate) && useSse && !__privateGet(this, _isRefreshing) && !opts.resumingFromPause && !__privateGet(this, _sseFallbackToLongPolling)) {
1261
1586
  opts.fetchUrl.searchParams.set(EXPERIMENTAL_LIVE_SSE_QUERY_PARAM, `true`);
@@ -1282,10 +1607,13 @@ requestShapeSSE_fn = async function(opts) {
1282
1607
  const { fetchUrl, requestAbortController, headers } = opts;
1283
1608
  const fetch2 = __privateGet(this, _sseFetchClient);
1284
1609
  __privateSet(this, _lastSseConnectionStartTime, Date.now());
1610
+ const sseHeaders = __spreadProps(__spreadValues({}, headers), {
1611
+ Accept: `text/event-stream`
1612
+ });
1285
1613
  try {
1286
1614
  let buffer = [];
1287
1615
  await fetchEventSource(fetchUrl.toString(), {
1288
- headers,
1616
+ headers: sseHeaders,
1289
1617
  fetch: fetch2,
1290
1618
  onopen: async (response) => {
1291
1619
  __privateSet(this, _connected, true);
@@ -1708,9 +2036,13 @@ export {
1708
2036
  FetchError,
1709
2037
  Shape,
1710
2038
  ShapeStream,
2039
+ camelToSnake,
2040
+ createColumnMapper,
1711
2041
  isChangeMessage,
1712
2042
  isControlMessage,
1713
2043
  isVisibleInSnapshot,
1714
- resolveValue
2044
+ resolveValue,
2045
+ snakeCamelMapper,
2046
+ snakeToCamel
1715
2047
  };
1716
2048
  //# sourceMappingURL=index.legacy-esm.js.map