@electric-sql/client 1.5.0 → 1.5.2

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.5.0",
4
+ "version": "1.5.2",
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
@@ -51,6 +51,7 @@ import {
51
51
  REPLICA_PARAM,
52
52
  FORCE_DISCONNECT_AND_REFRESH,
53
53
  PAUSE_STREAM,
54
+ SYSTEM_WAKE,
54
55
  EXPERIMENTAL_LIVE_SSE_QUERY_PARAM,
55
56
  LIVE_SSE_QUERY_PARAM,
56
57
  ELECTRIC_PROTOCOL_QUERY_PARAMS,
@@ -598,6 +599,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
598
599
  #sseBackoffBaseDelay = 100 // Base delay for exponential backoff (ms)
599
600
  #sseBackoffMaxDelay = 5000 // Maximum delay cap (ms)
600
601
  #unsubscribeFromVisibilityChanges?: () => void
602
+ #unsubscribeFromWakeDetection?: () => void
601
603
  #staleCacheBuster?: string // Cache buster set when stale CDN response detected, used on retry requests to bypass cache
602
604
  #staleCacheRetryCount = 0
603
605
  #maxStaleCacheRetries = 3
@@ -666,6 +668,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
666
668
  this.#fetchClient = createFetchWithConsumedMessages(this.#sseFetchClient)
667
669
 
668
670
  this.#subscribeToVisibilityChanges()
671
+ this.#subscribeToWakeDetection()
669
672
  }
670
673
 
671
674
  get shapeHandle() {
@@ -735,6 +738,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
735
738
  }
736
739
  this.#connected = false
737
740
  this.#tickPromiseRejecter?.()
741
+ this.#unsubscribeFromWakeDetection?.()
738
742
  return
739
743
  }
740
744
 
@@ -745,12 +749,14 @@ export class ShapeStream<T extends Row<unknown> = Row>
745
749
  }
746
750
  this.#connected = false
747
751
  this.#tickPromiseRejecter?.()
752
+ this.#unsubscribeFromWakeDetection?.()
748
753
  throw err
749
754
  }
750
755
 
751
756
  // Normal completion, clean up
752
757
  this.#connected = false
753
758
  this.#tickPromiseRejecter?.()
759
+ this.#unsubscribeFromWakeDetection?.()
754
760
  }
755
761
 
756
762
  async #requestShape(): Promise<void> {
@@ -785,13 +791,16 @@ export class ShapeStream<T extends Row<unknown> = Row>
785
791
  resumingFromPause,
786
792
  })
787
793
  } catch (e) {
788
- // Handle abort error triggered by refresh
794
+ const abortReason = requestAbortController.signal.reason
795
+ const isRestartAbort =
796
+ requestAbortController.signal.aborted &&
797
+ (abortReason === FORCE_DISCONNECT_AND_REFRESH ||
798
+ abortReason === SYSTEM_WAKE)
799
+
789
800
  if (
790
801
  (e instanceof FetchError || e instanceof FetchBackoffAbortError) &&
791
- requestAbortController.signal.aborted &&
792
- requestAbortController.signal.reason === FORCE_DISCONNECT_AND_REFRESH
802
+ isRestartAbort
793
803
  ) {
794
- // Start a new request
795
804
  return this.#requestShape()
796
805
  }
797
806
 
@@ -1118,13 +1127,16 @@ export class ShapeStream<T extends Row<unknown> = Row>
1118
1127
  `Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key.`
1119
1128
  )
1120
1129
  } else {
1130
+ // We already have a valid handle, so ignore the stale response entirely
1131
+ // to prevent a mismatch between our current handle and the stale offset.
1121
1132
  console.warn(
1122
1133
  `[Electric] Received stale cached response with expired shape handle. ` +
1123
1134
  `This should not happen and indicates a proxy/CDN caching misconfiguration. ` +
1124
1135
  `The response contained handle "${shapeHandle}" which was previously marked as expired. ` +
1125
1136
  `Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
1126
- `Ignoring the stale handle and continuing with handle "${this.#shapeHandle}".`
1137
+ `Ignoring the stale response and continuing with handle "${this.#shapeHandle}".`
1127
1138
  )
1139
+ return
1128
1140
  }
1129
1141
  }
1130
1142
 
@@ -1429,6 +1441,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1429
1441
  unsubscribeAll(): void {
1430
1442
  this.#subscribers.clear()
1431
1443
  this.#unsubscribeFromVisibilityChanges?.()
1444
+ this.#unsubscribeFromWakeDetection?.()
1432
1445
  }
1433
1446
 
1434
1447
  /** Unix time at which we last synced. Undefined when `isLoading` is true. */
@@ -1540,12 +1553,16 @@ export class ShapeStream<T extends Row<unknown> = Row>
1540
1553
  })
1541
1554
  }
1542
1555
 
1543
- #subscribeToVisibilityChanges() {
1544
- if (
1556
+ #hasBrowserVisibilityAPI(): boolean {
1557
+ return (
1545
1558
  typeof document === `object` &&
1546
1559
  typeof document.hidden === `boolean` &&
1547
1560
  typeof document.addEventListener === `function`
1548
- ) {
1561
+ )
1562
+ }
1563
+
1564
+ #subscribeToVisibilityChanges() {
1565
+ if (this.#hasBrowserVisibilityAPI()) {
1549
1566
  const visibilityHandler = () => {
1550
1567
  if (document.hidden) {
1551
1568
  this.#pause()
@@ -1563,6 +1580,52 @@ export class ShapeStream<T extends Row<unknown> = Row>
1563
1580
  }
1564
1581
  }
1565
1582
 
1583
+ /**
1584
+ * Detects system wake from sleep using timer gap detection.
1585
+ * When the system sleeps, setInterval timers are paused. On wake,
1586
+ * the elapsed wall-clock time since the last tick will be much larger
1587
+ * than the interval period, indicating the system was asleep.
1588
+ *
1589
+ * Only active in non-browser environments (Bun, Node.js) where
1590
+ * `document.visibilitychange` is not available. In browsers,
1591
+ * `#subscribeToVisibilityChanges` handles this instead. Without wake
1592
+ * detection, in-flight HTTP requests (long-poll or SSE) may hang until
1593
+ * the OS TCP timeout.
1594
+ */
1595
+ #subscribeToWakeDetection() {
1596
+ if (this.#hasBrowserVisibilityAPI()) return
1597
+
1598
+ const INTERVAL_MS = 2_000
1599
+ const WAKE_THRESHOLD_MS = 4_000
1600
+
1601
+ let lastTickTime = Date.now()
1602
+
1603
+ const timer = setInterval(() => {
1604
+ const now = Date.now()
1605
+ const elapsed = now - lastTickTime
1606
+ lastTickTime = now
1607
+
1608
+ if (elapsed > INTERVAL_MS + WAKE_THRESHOLD_MS) {
1609
+ if (this.#state === `active` && this.#requestAbortController) {
1610
+ this.#isRefreshing = true
1611
+ this.#requestAbortController.abort(SYSTEM_WAKE)
1612
+ queueMicrotask(() => {
1613
+ this.#isRefreshing = false
1614
+ })
1615
+ }
1616
+ }
1617
+ }, INTERVAL_MS)
1618
+
1619
+ // Ensure the timer doesn't prevent the process from exiting
1620
+ if (typeof timer === `object` && `unref` in timer) {
1621
+ timer.unref()
1622
+ }
1623
+
1624
+ this.#unsubscribeFromWakeDetection = () => {
1625
+ clearInterval(timer)
1626
+ }
1627
+ }
1628
+
1566
1629
  /**
1567
1630
  * Resets the state of the stream, optionally with a provided
1568
1631
  * shape handle
@@ -1687,8 +1750,32 @@ export class ShapeStream<T extends Row<unknown> = Row>
1687
1750
  fetchOptions = { headers: result.requestHeaders }
1688
1751
  }
1689
1752
 
1690
- const response = await this.#fetchClient(fetchUrl.toString(), fetchOptions)
1753
+ // Capture handle before fetch to avoid race conditions if it changes during the request
1754
+ const usedHandle = this.#shapeHandle
1755
+
1756
+ let response: Response
1757
+ try {
1758
+ response = await this.#fetchClient(fetchUrl.toString(), fetchOptions)
1759
+ } catch (e) {
1760
+ // Handle 409 "must-refetch" - shape handle changed/expired.
1761
+ // The fetch wrapper throws FetchError for non-OK responses, so we catch here.
1762
+ // Unlike #requestShape, we don't call #reset() here as that would
1763
+ // clear #activeSnapshotRequests and break requestSnapshot's pause/resume logic.
1764
+ if (e instanceof FetchError && e.status === 409) {
1765
+ if (usedHandle) {
1766
+ const shapeKey = canonicalShapeKey(fetchUrl)
1767
+ expiredShapesCache.markExpired(shapeKey, usedHandle)
1768
+ }
1769
+
1770
+ this.#shapeHandle =
1771
+ e.headers[SHAPE_HANDLE_HEADER] || `${usedHandle ?? `handle`}-next`
1772
+
1773
+ return this.fetchSnapshot(opts)
1774
+ }
1775
+ throw e
1776
+ }
1691
1777
 
1778
+ // Handle non-OK responses from custom fetch clients that bypass the wrapper chain
1692
1779
  if (!response.ok) {
1693
1780
  throw await FetchError.fromResponse(response, fetchUrl.toString())
1694
1781
  }
package/src/constants.ts CHANGED
@@ -20,6 +20,7 @@ export const EXPERIMENTAL_LIVE_SSE_QUERY_PARAM = `experimental_live_sse`
20
20
  export const LIVE_SSE_QUERY_PARAM = `live_sse`
21
21
  export const FORCE_DISCONNECT_AND_REFRESH = `force-disconnect-and-refresh`
22
22
  export const PAUSE_STREAM = `pause-stream`
23
+ export const SYSTEM_WAKE = `system-wake`
23
24
  export const LOG_MODE_QUERY_PARAM = `log`
24
25
  export const SUBSET_PARAM_WHERE = `subset__where`
25
26
  export const SUBSET_PARAM_LIMIT = `subset__limit`