@electric-sql/client 1.5.7 → 1.5.9
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 +94 -9
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/index.browser.mjs +7 -3
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.legacy-esm.js +94 -9
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +94 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +109 -3
- package/src/expired-shapes-cache.ts +5 -0
- package/src/fetch.ts +3 -3
- package/src/helpers.ts +1 -1
- package/src/up-to-date-tracker.ts +5 -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.9",
|
|
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
|
@@ -94,6 +94,8 @@ const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
|
|
|
94
94
|
CACHE_BUSTER_QUERY_PARAM,
|
|
95
95
|
])
|
|
96
96
|
|
|
97
|
+
const TROUBLESHOOTING_URL = `https://electric-sql.com/docs/guides/troubleshooting`
|
|
98
|
+
|
|
97
99
|
type Replica = `full` | `default`
|
|
98
100
|
export type LogMode = `changes_only` | `full`
|
|
99
101
|
|
|
@@ -606,6 +608,15 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
606
608
|
#unsubscribeFromVisibilityChanges?: () => void
|
|
607
609
|
#unsubscribeFromWakeDetection?: () => void
|
|
608
610
|
#maxStaleCacheRetries = 3
|
|
611
|
+
// Fast-loop detection: track recent non-live requests to detect tight retry
|
|
612
|
+
// loops caused by proxy/CDN misconfiguration or stale client-side caches
|
|
613
|
+
#recentRequestEntries: Array<{ timestamp: number; offset: string }> = []
|
|
614
|
+
#fastLoopWindowMs = 500
|
|
615
|
+
#fastLoopThreshold = 5
|
|
616
|
+
#fastLoopBackoffBaseMs = 100
|
|
617
|
+
#fastLoopBackoffMaxMs = 5_000
|
|
618
|
+
#fastLoopConsecutiveCount = 0
|
|
619
|
+
#fastLoopMaxCount = 5
|
|
609
620
|
|
|
610
621
|
constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
|
|
611
622
|
this.options = { subscribe: true, ...options }
|
|
@@ -687,7 +698,6 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
687
698
|
this.#fetchClient = createFetchWithConsumedMessages(this.#sseFetchClient)
|
|
688
699
|
|
|
689
700
|
this.#subscribeToVisibilityChanges()
|
|
690
|
-
this.#subscribeToWakeDetection()
|
|
691
701
|
}
|
|
692
702
|
|
|
693
703
|
get shapeHandle() {
|
|
@@ -712,6 +722,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
712
722
|
|
|
713
723
|
async #start(): Promise<void> {
|
|
714
724
|
this.#started = true
|
|
725
|
+
this.#subscribeToWakeDetection()
|
|
715
726
|
|
|
716
727
|
try {
|
|
717
728
|
await this.#requestShape()
|
|
@@ -750,6 +761,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
750
761
|
if (this.#syncState instanceof ErrorState) {
|
|
751
762
|
this.#syncState = this.#syncState.retry()
|
|
752
763
|
}
|
|
764
|
+
this.#fastLoopConsecutiveCount = 0
|
|
765
|
+
this.#recentRequestEntries = []
|
|
753
766
|
|
|
754
767
|
// Restart from current offset
|
|
755
768
|
this.#started = false
|
|
@@ -793,6 +806,14 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
793
806
|
return
|
|
794
807
|
}
|
|
795
808
|
|
|
809
|
+
// Only check for fast loops on non-live requests; live polling is expected to be rapid
|
|
810
|
+
if (!this.#syncState.isUpToDate) {
|
|
811
|
+
await this.#checkFastLoop()
|
|
812
|
+
} else {
|
|
813
|
+
this.#fastLoopConsecutiveCount = 0
|
|
814
|
+
this.#recentRequestEntries = []
|
|
815
|
+
}
|
|
816
|
+
|
|
796
817
|
let resumingFromPause = false
|
|
797
818
|
if (this.#syncState instanceof PausedState) {
|
|
798
819
|
resumingFromPause = true
|
|
@@ -898,6 +919,88 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
898
919
|
return this.#requestShape()
|
|
899
920
|
}
|
|
900
921
|
|
|
922
|
+
/**
|
|
923
|
+
* Detects tight retry loops (e.g., from stale client-side caches or
|
|
924
|
+
* proxy/CDN misconfiguration) and attempts recovery. On first detection,
|
|
925
|
+
* clears client-side caches (in-memory and localStorage) and resets the
|
|
926
|
+
* stream to fetch from scratch.
|
|
927
|
+
* If the loop persists, applies exponential backoff and eventually throws.
|
|
928
|
+
*/
|
|
929
|
+
async #checkFastLoop(): Promise<void> {
|
|
930
|
+
const now = Date.now()
|
|
931
|
+
const currentOffset = this.#syncState.offset
|
|
932
|
+
|
|
933
|
+
this.#recentRequestEntries = this.#recentRequestEntries.filter(
|
|
934
|
+
(e) => now - e.timestamp < this.#fastLoopWindowMs
|
|
935
|
+
)
|
|
936
|
+
this.#recentRequestEntries.push({ timestamp: now, offset: currentOffset })
|
|
937
|
+
|
|
938
|
+
// Only flag as a fast loop if requests are stuck at the same offset.
|
|
939
|
+
// Normal rapid syncing advances the offset with each response.
|
|
940
|
+
const sameOffsetCount = this.#recentRequestEntries.filter(
|
|
941
|
+
(e) => e.offset === currentOffset
|
|
942
|
+
).length
|
|
943
|
+
|
|
944
|
+
if (sameOffsetCount < this.#fastLoopThreshold) return
|
|
945
|
+
|
|
946
|
+
this.#fastLoopConsecutiveCount++
|
|
947
|
+
|
|
948
|
+
if (this.#fastLoopConsecutiveCount >= this.#fastLoopMaxCount) {
|
|
949
|
+
throw new FetchError(
|
|
950
|
+
502,
|
|
951
|
+
undefined,
|
|
952
|
+
undefined,
|
|
953
|
+
{},
|
|
954
|
+
this.options.url,
|
|
955
|
+
`Client is stuck in a fast retry loop ` +
|
|
956
|
+
`(${this.#fastLoopThreshold} requests in ${this.#fastLoopWindowMs}ms at the same offset, ` +
|
|
957
|
+
`repeated ${this.#fastLoopMaxCount} times). ` +
|
|
958
|
+
`Client-side caches were cleared automatically on first detection, but the loop persists. ` +
|
|
959
|
+
`This usually indicates a proxy or CDN misconfiguration. ` +
|
|
960
|
+
`Common causes:\n` +
|
|
961
|
+
` - Proxy is not including query parameters (handle, offset) in its cache key\n` +
|
|
962
|
+
` - CDN is serving stale 409 responses\n` +
|
|
963
|
+
` - Proxy is stripping required Electric headers from responses\n` +
|
|
964
|
+
`For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`
|
|
965
|
+
)
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (this.#fastLoopConsecutiveCount === 1) {
|
|
969
|
+
console.warn(
|
|
970
|
+
`[Electric] Detected fast retry loop ` +
|
|
971
|
+
`(${this.#fastLoopThreshold} requests in ${this.#fastLoopWindowMs}ms at the same offset). ` +
|
|
972
|
+
`Clearing client-side caches and resetting stream to recover. ` +
|
|
973
|
+
`If this persists, check that your proxy includes all query parameters ` +
|
|
974
|
+
`(especially 'handle' and 'offset') in its cache key, ` +
|
|
975
|
+
`and that required Electric headers are forwarded to the client. ` +
|
|
976
|
+
`For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
if (this.#currentFetchUrl) {
|
|
980
|
+
const shapeKey = canonicalShapeKey(this.#currentFetchUrl)
|
|
981
|
+
expiredShapesCache.delete(shapeKey)
|
|
982
|
+
upToDateTracker.delete(shapeKey)
|
|
983
|
+
} else {
|
|
984
|
+
expiredShapesCache.clear()
|
|
985
|
+
upToDateTracker.clear()
|
|
986
|
+
}
|
|
987
|
+
this.#reset()
|
|
988
|
+
this.#recentRequestEntries = []
|
|
989
|
+
return
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Exponential backoff with full jitter
|
|
993
|
+
const maxDelay = Math.min(
|
|
994
|
+
this.#fastLoopBackoffMaxMs,
|
|
995
|
+
this.#fastLoopBackoffBaseMs * Math.pow(2, this.#fastLoopConsecutiveCount)
|
|
996
|
+
)
|
|
997
|
+
const delayMs = Math.floor(Math.random() * maxDelay)
|
|
998
|
+
|
|
999
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
1000
|
+
|
|
1001
|
+
this.#recentRequestEntries = []
|
|
1002
|
+
}
|
|
1003
|
+
|
|
901
1004
|
async #constructUrl(
|
|
902
1005
|
url: string,
|
|
903
1006
|
resumingFromPause: boolean,
|
|
@@ -1115,7 +1218,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1115
1218
|
`CDN continues serving stale cached responses after ${this.#maxStaleCacheRetries} retry attempts. ` +
|
|
1116
1219
|
`This indicates a severe proxy/CDN misconfiguration. ` +
|
|
1117
1220
|
`Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
|
|
1118
|
-
`For more information visit the troubleshooting guide:
|
|
1221
|
+
`For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`
|
|
1119
1222
|
)
|
|
1120
1223
|
}
|
|
1121
1224
|
console.warn(
|
|
@@ -1123,7 +1226,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1123
1226
|
`This should not happen and indicates a proxy/CDN caching misconfiguration. ` +
|
|
1124
1227
|
`The response contained handle "${shapeHandle}" which was previously marked as expired. ` +
|
|
1125
1228
|
`Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
|
|
1126
|
-
`For more information visit the troubleshooting guide:
|
|
1229
|
+
`For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL} ` +
|
|
1127
1230
|
`Retrying with a random cache buster to bypass the stale cache (attempt ${this.#syncState.staleCacheRetryCount}/${this.#maxStaleCacheRetries}).`
|
|
1128
1231
|
)
|
|
1129
1232
|
throw new StaleCacheError(
|
|
@@ -1540,6 +1643,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1540
1643
|
// Store cleanup function to remove the event listener
|
|
1541
1644
|
this.#unsubscribeFromVisibilityChanges = () => {
|
|
1542
1645
|
document.removeEventListener(`visibilitychange`, visibilityHandler)
|
|
1646
|
+
this.#unsubscribeFromVisibilityChanges = undefined
|
|
1543
1647
|
}
|
|
1544
1648
|
}
|
|
1545
1649
|
}
|
|
@@ -1558,6 +1662,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1558
1662
|
*/
|
|
1559
1663
|
#subscribeToWakeDetection() {
|
|
1560
1664
|
if (this.#hasBrowserVisibilityAPI()) return
|
|
1665
|
+
if (this.#unsubscribeFromWakeDetection) return
|
|
1561
1666
|
|
|
1562
1667
|
const INTERVAL_MS = 2_000
|
|
1563
1668
|
const WAKE_THRESHOLD_MS = 4_000
|
|
@@ -1592,6 +1697,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1592
1697
|
|
|
1593
1698
|
this.#unsubscribeFromWakeDetection = () => {
|
|
1594
1699
|
clearInterval(timer)
|
|
1700
|
+
this.#unsubscribeFromWakeDetection = undefined
|
|
1595
1701
|
}
|
|
1596
1702
|
}
|
|
1597
1703
|
|
package/src/fetch.ts
CHANGED
|
@@ -44,9 +44,9 @@ export interface BackoffOptions {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export const BackoffDefaults = {
|
|
47
|
-
initialDelay:
|
|
48
|
-
maxDelay:
|
|
49
|
-
multiplier:
|
|
47
|
+
initialDelay: 1_000,
|
|
48
|
+
maxDelay: 32_000,
|
|
49
|
+
multiplier: 2,
|
|
50
50
|
maxRetries: Infinity, // Retry forever - clients may go offline and come back
|
|
51
51
|
}
|
|
52
52
|
|
package/src/helpers.ts
CHANGED
|
@@ -51,7 +51,7 @@ export function isChangeMessage<T extends Row<unknown> = Row>(
|
|
|
51
51
|
export function isControlMessage<T extends Row<unknown> = Row>(
|
|
52
52
|
message: Message<T>
|
|
53
53
|
): message is ControlMessage {
|
|
54
|
-
return message != null &&
|
|
54
|
+
return message != null && `headers` in message && `control` in message.headers
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export function isUpToDateMessage<T extends Row<unknown> = Row>(
|