@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/dist/cjs/index.cjs +75 -9
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.legacy-esm.js +75 -9
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +75 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +96 -9
- package/src/constants.ts +1 -0
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
1544
|
-
|
|
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
|
-
|
|
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`
|