@electric-sql/client 1.5.9 → 1.5.11
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 +62 -9
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/index.browser.mjs +4 -4
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.legacy-esm.js +62 -9
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +62 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +39 -13
- package/src/shape-stream-state.ts +72 -35
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.11",
|
|
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
|
@@ -96,6 +96,10 @@ const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
|
|
|
96
96
|
|
|
97
97
|
const TROUBLESHOOTING_URL = `https://electric-sql.com/docs/guides/troubleshooting`
|
|
98
98
|
|
|
99
|
+
function createCacheBuster(): string {
|
|
100
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
|
101
|
+
}
|
|
102
|
+
|
|
99
103
|
type Replica = `full` | `default`
|
|
100
104
|
export type LogMode = `changes_only` | `full`
|
|
101
105
|
|
|
@@ -617,6 +621,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
617
621
|
#fastLoopBackoffMaxMs = 5_000
|
|
618
622
|
#fastLoopConsecutiveCount = 0
|
|
619
623
|
#fastLoopMaxCount = 5
|
|
624
|
+
#refetchCacheBuster?: string
|
|
620
625
|
|
|
621
626
|
constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
|
|
622
627
|
this.options = { subscribe: true, ...options }
|
|
@@ -875,10 +880,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
875
880
|
if (!(e instanceof FetchError)) throw e // should never happen
|
|
876
881
|
|
|
877
882
|
if (e.status == 409) {
|
|
878
|
-
// Upon receiving a 409,
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
//
|
|
883
|
+
// Upon receiving a 409, start from scratch with the newly
|
|
884
|
+
// provided shape handle. If the header is missing (e.g. proxy
|
|
885
|
+
// stripped it), reset without a handle and use a random
|
|
886
|
+
// cache-buster query param to ensure the retry URL is unique.
|
|
882
887
|
|
|
883
888
|
// Store the current shape URL as expired to avoid future 409s
|
|
884
889
|
if (this.#syncState.handle) {
|
|
@@ -886,8 +891,14 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
886
891
|
expiredShapesCache.markExpired(shapeKey, this.#syncState.handle)
|
|
887
892
|
}
|
|
888
893
|
|
|
889
|
-
const newShapeHandle =
|
|
890
|
-
|
|
894
|
+
const newShapeHandle = e.headers[SHAPE_HANDLE_HEADER]
|
|
895
|
+
if (!newShapeHandle) {
|
|
896
|
+
console.warn(
|
|
897
|
+
`[Electric] Received 409 response without a shape handle header. ` +
|
|
898
|
+
`This likely indicates a proxy or CDN stripping required headers.`
|
|
899
|
+
)
|
|
900
|
+
this.#refetchCacheBuster = createCacheBuster()
|
|
901
|
+
}
|
|
891
902
|
this.#reset(newShapeHandle)
|
|
892
903
|
|
|
893
904
|
// must refetch control message might be in a list or not depending
|
|
@@ -1144,6 +1155,16 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1144
1155
|
fetchUrl.searchParams.set(EXPIRED_HANDLE_QUERY_PARAM, expiredHandle)
|
|
1145
1156
|
}
|
|
1146
1157
|
|
|
1158
|
+
// Add one-shot cache buster when a 409 response lacked a handle header
|
|
1159
|
+
// (e.g. proxy stripped it). Ensures each retry has a unique URL.
|
|
1160
|
+
if (this.#refetchCacheBuster) {
|
|
1161
|
+
fetchUrl.searchParams.set(
|
|
1162
|
+
CACHE_BUSTER_QUERY_PARAM,
|
|
1163
|
+
this.#refetchCacheBuster
|
|
1164
|
+
)
|
|
1165
|
+
this.#refetchCacheBuster = undefined
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1147
1168
|
// sort query params in-place for stable URLs and improved cache hits
|
|
1148
1169
|
fetchUrl.searchParams.sort()
|
|
1149
1170
|
|
|
@@ -1199,8 +1220,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1199
1220
|
expiredHandle,
|
|
1200
1221
|
now: Date.now(),
|
|
1201
1222
|
maxStaleCacheRetries: this.#maxStaleCacheRetries,
|
|
1202
|
-
createCacheBuster
|
|
1203
|
-
`${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
|
1223
|
+
createCacheBuster,
|
|
1204
1224
|
})
|
|
1205
1225
|
|
|
1206
1226
|
this.#syncState = transition.state
|
|
@@ -1786,8 +1806,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1786
1806
|
expiredHandle: null,
|
|
1787
1807
|
now: Date.now(),
|
|
1788
1808
|
maxStaleCacheRetries: this.#maxStaleCacheRetries,
|
|
1789
|
-
createCacheBuster
|
|
1790
|
-
`${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
|
1809
|
+
createCacheBuster,
|
|
1791
1810
|
})
|
|
1792
1811
|
if (transition.action === `accepted`) {
|
|
1793
1812
|
this.#syncState = transition.state
|
|
@@ -1869,9 +1888,16 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1869
1888
|
|
|
1870
1889
|
// For snapshot 409s, only update the handle — don't reset offset/schema/etc.
|
|
1871
1890
|
// The main stream is paused and should not be disturbed.
|
|
1872
|
-
const nextHandle =
|
|
1873
|
-
|
|
1874
|
-
|
|
1891
|
+
const nextHandle = e.headers[SHAPE_HANDLE_HEADER]
|
|
1892
|
+
if (nextHandle) {
|
|
1893
|
+
this.#syncState = this.#syncState.withHandle(nextHandle)
|
|
1894
|
+
} else {
|
|
1895
|
+
console.warn(
|
|
1896
|
+
`[Electric] Received 409 response without a shape handle header. ` +
|
|
1897
|
+
`This likely indicates a proxy or CDN stripping required headers.`
|
|
1898
|
+
)
|
|
1899
|
+
this.#refetchCacheBuster = createCacheBuster()
|
|
1900
|
+
}
|
|
1875
1901
|
|
|
1876
1902
|
return this.fetchSnapshot(opts)
|
|
1877
1903
|
}
|
|
@@ -75,7 +75,7 @@ export type ResponseMetadataTransition =
|
|
|
75
75
|
| { action: `ignored`; state: ShapeStreamState }
|
|
76
76
|
| {
|
|
77
77
|
action: `stale-retry`
|
|
78
|
-
state:
|
|
78
|
+
state: ShapeStreamState
|
|
79
79
|
exceededMaxRetries: boolean
|
|
80
80
|
}
|
|
81
81
|
|
|
@@ -122,7 +122,7 @@ export interface UrlParamsContext {
|
|
|
122
122
|
* Each concrete state carries only its relevant fields — there is no shared
|
|
123
123
|
* flat context bag. Transitions create new immutable state objects.
|
|
124
124
|
*
|
|
125
|
-
* `isUpToDate`
|
|
125
|
+
* `isUpToDate` returns true for LiveState and delegating states wrapping LiveState.
|
|
126
126
|
*/
|
|
127
127
|
export abstract class ShapeStreamState {
|
|
128
128
|
abstract readonly kind: ShapeStreamStateKind
|
|
@@ -368,9 +368,10 @@ abstract class ActiveState extends ShapeStreamState {
|
|
|
368
368
|
|
|
369
369
|
/**
|
|
370
370
|
* Captures shared behavior of InitialState, SyncingState, StaleRetryState:
|
|
371
|
-
* - handleResponseMetadata: stale check → parse fields → new SyncingState
|
|
372
|
-
* -
|
|
373
|
-
* -
|
|
371
|
+
* - handleResponseMetadata: stale check → parse fields → new SyncingState (or LiveState for 204)
|
|
372
|
+
* - enterReplayMode(cursor) → new ReplayingState
|
|
373
|
+
* - canEnterReplayMode(): boolean — returns true (StaleRetryState overrides to return false,
|
|
374
|
+
* because entering replay would lose the stale-retry count; see C1 in SPEC.md)
|
|
374
375
|
*/
|
|
375
376
|
abstract class FetchingState extends ActiveState {
|
|
376
377
|
handleResponseMetadata(
|
|
@@ -659,48 +660,60 @@ export class ReplayingState extends ActiveState {
|
|
|
659
660
|
// Delegating states (Paused / Error)
|
|
660
661
|
// ---------------------------------------------------------------------------
|
|
661
662
|
|
|
663
|
+
// Union of all non-delegating states — used to type previousState fields so
|
|
664
|
+
// that PausedState.previousState is never another PausedState, and
|
|
665
|
+
// ErrorState.previousState is never another ErrorState.
|
|
666
|
+
export type ShapeStreamActiveState =
|
|
667
|
+
| InitialState
|
|
668
|
+
| SyncingState
|
|
669
|
+
| LiveState
|
|
670
|
+
| ReplayingState
|
|
671
|
+
| StaleRetryState
|
|
672
|
+
|
|
662
673
|
export class PausedState extends ShapeStreamState {
|
|
663
674
|
readonly kind = `paused` as const
|
|
664
|
-
readonly previousState:
|
|
675
|
+
readonly previousState: ShapeStreamActiveState | ErrorState
|
|
665
676
|
|
|
666
677
|
constructor(previousState: ShapeStreamState) {
|
|
667
678
|
super()
|
|
668
|
-
this.previousState =
|
|
679
|
+
this.previousState = (
|
|
680
|
+
previousState instanceof PausedState
|
|
681
|
+
? previousState.previousState
|
|
682
|
+
: previousState
|
|
683
|
+
) as ShapeStreamActiveState | ErrorState
|
|
669
684
|
}
|
|
670
685
|
|
|
671
|
-
get handle() {
|
|
686
|
+
get handle(): string | undefined {
|
|
672
687
|
return this.previousState.handle
|
|
673
688
|
}
|
|
674
|
-
get offset() {
|
|
689
|
+
get offset(): Offset {
|
|
675
690
|
return this.previousState.offset
|
|
676
691
|
}
|
|
677
|
-
get schema() {
|
|
692
|
+
get schema(): Schema | undefined {
|
|
678
693
|
return this.previousState.schema
|
|
679
694
|
}
|
|
680
|
-
get liveCacheBuster() {
|
|
695
|
+
get liveCacheBuster(): string {
|
|
681
696
|
return this.previousState.liveCacheBuster
|
|
682
697
|
}
|
|
683
|
-
get lastSyncedAt() {
|
|
698
|
+
get lastSyncedAt(): number | undefined {
|
|
684
699
|
return this.previousState.lastSyncedAt
|
|
685
700
|
}
|
|
686
|
-
|
|
687
701
|
get isUpToDate(): boolean {
|
|
688
702
|
return this.previousState.isUpToDate
|
|
689
703
|
}
|
|
690
|
-
|
|
691
|
-
get staleCacheBuster() {
|
|
704
|
+
get staleCacheBuster(): string | undefined {
|
|
692
705
|
return this.previousState.staleCacheBuster
|
|
693
706
|
}
|
|
694
|
-
get staleCacheRetryCount() {
|
|
707
|
+
get staleCacheRetryCount(): number {
|
|
695
708
|
return this.previousState.staleCacheRetryCount
|
|
696
709
|
}
|
|
697
|
-
get sseFallbackToLongPolling() {
|
|
710
|
+
get sseFallbackToLongPolling(): boolean {
|
|
698
711
|
return this.previousState.sseFallbackToLongPolling
|
|
699
712
|
}
|
|
700
|
-
get consecutiveShortSseConnections() {
|
|
713
|
+
get consecutiveShortSseConnections(): number {
|
|
701
714
|
return this.previousState.consecutiveShortSseConnections
|
|
702
715
|
}
|
|
703
|
-
get replayCursor() {
|
|
716
|
+
get replayCursor(): string | undefined {
|
|
704
717
|
return this.previousState.replayCursor
|
|
705
718
|
}
|
|
706
719
|
|
|
@@ -711,7 +724,20 @@ export class PausedState extends ShapeStreamState {
|
|
|
711
724
|
if (transition.action === `accepted`) {
|
|
712
725
|
return { action: `accepted`, state: new PausedState(transition.state) }
|
|
713
726
|
}
|
|
714
|
-
|
|
727
|
+
if (transition.action === `ignored`) {
|
|
728
|
+
return { action: `ignored`, state: this }
|
|
729
|
+
}
|
|
730
|
+
if (transition.action === `stale-retry`) {
|
|
731
|
+
return {
|
|
732
|
+
action: `stale-retry`,
|
|
733
|
+
state: new PausedState(transition.state),
|
|
734
|
+
exceededMaxRetries: transition.exceededMaxRetries,
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const _exhaustive: never = transition
|
|
738
|
+
throw new Error(
|
|
739
|
+
`PausedState.handleResponseMetadata: unhandled transition action "${(_exhaustive as ResponseMetadataTransition).action}"`
|
|
740
|
+
)
|
|
715
741
|
}
|
|
716
742
|
|
|
717
743
|
withHandle(handle: string): PausedState {
|
|
@@ -733,34 +759,52 @@ export class PausedState extends ShapeStreamState {
|
|
|
733
759
|
|
|
734
760
|
export class ErrorState extends ShapeStreamState {
|
|
735
761
|
readonly kind = `error` as const
|
|
736
|
-
readonly previousState:
|
|
762
|
+
readonly previousState: ShapeStreamActiveState | PausedState
|
|
737
763
|
readonly error: Error
|
|
738
764
|
|
|
739
765
|
constructor(previousState: ShapeStreamState, error: Error) {
|
|
740
766
|
super()
|
|
741
|
-
this.previousState =
|
|
767
|
+
this.previousState = (
|
|
768
|
+
previousState instanceof ErrorState
|
|
769
|
+
? previousState.previousState
|
|
770
|
+
: previousState
|
|
771
|
+
) as ShapeStreamActiveState | PausedState
|
|
742
772
|
this.error = error
|
|
743
773
|
}
|
|
744
774
|
|
|
745
|
-
get handle() {
|
|
775
|
+
get handle(): string | undefined {
|
|
746
776
|
return this.previousState.handle
|
|
747
777
|
}
|
|
748
|
-
get offset() {
|
|
778
|
+
get offset(): Offset {
|
|
749
779
|
return this.previousState.offset
|
|
750
780
|
}
|
|
751
|
-
get schema() {
|
|
781
|
+
get schema(): Schema | undefined {
|
|
752
782
|
return this.previousState.schema
|
|
753
783
|
}
|
|
754
|
-
get liveCacheBuster() {
|
|
784
|
+
get liveCacheBuster(): string {
|
|
755
785
|
return this.previousState.liveCacheBuster
|
|
756
786
|
}
|
|
757
|
-
get lastSyncedAt() {
|
|
787
|
+
get lastSyncedAt(): number | undefined {
|
|
758
788
|
return this.previousState.lastSyncedAt
|
|
759
789
|
}
|
|
760
|
-
|
|
761
790
|
get isUpToDate(): boolean {
|
|
762
791
|
return this.previousState.isUpToDate
|
|
763
792
|
}
|
|
793
|
+
get staleCacheBuster(): string | undefined {
|
|
794
|
+
return this.previousState.staleCacheBuster
|
|
795
|
+
}
|
|
796
|
+
get staleCacheRetryCount(): number {
|
|
797
|
+
return this.previousState.staleCacheRetryCount
|
|
798
|
+
}
|
|
799
|
+
get sseFallbackToLongPolling(): boolean {
|
|
800
|
+
return this.previousState.sseFallbackToLongPolling
|
|
801
|
+
}
|
|
802
|
+
get consecutiveShortSseConnections(): number {
|
|
803
|
+
return this.previousState.consecutiveShortSseConnections
|
|
804
|
+
}
|
|
805
|
+
get replayCursor(): string | undefined {
|
|
806
|
+
return this.previousState.replayCursor
|
|
807
|
+
}
|
|
764
808
|
|
|
765
809
|
withHandle(handle: string): ErrorState {
|
|
766
810
|
return new ErrorState(this.previousState.withHandle(handle), this.error)
|
|
@@ -783,13 +827,6 @@ export class ErrorState extends ShapeStreamState {
|
|
|
783
827
|
// Type alias & factory
|
|
784
828
|
// ---------------------------------------------------------------------------
|
|
785
829
|
|
|
786
|
-
export type ShapeStreamActiveState =
|
|
787
|
-
| InitialState
|
|
788
|
-
| SyncingState
|
|
789
|
-
| LiveState
|
|
790
|
-
| ReplayingState
|
|
791
|
-
| StaleRetryState
|
|
792
|
-
|
|
793
830
|
export function createInitialState(opts: {
|
|
794
831
|
offset: Offset
|
|
795
832
|
handle?: string
|