@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/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.9",
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, we should start from scratch
879
- // with the newly provided shape handle, or a fallback
880
- // pseudo-handle based on the current one to act as a
881
- // consistent cache buster
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
- e.headers[SHAPE_HANDLE_HEADER] || `${this.#syncState.handle!}-next`
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
- e.headers[SHAPE_HANDLE_HEADER] || `${usedHandle ?? `handle`}-next`
1874
- this.#syncState = this.#syncState.withHandle(nextHandle)
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: StaleRetryState
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` is derived from state kind (only LiveState returns true).
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
- * - canEnterReplayModetrue
373
- * - enterReplayMode new ReplayingState
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: ShapeStreamState
675
+ readonly previousState: ShapeStreamActiveState | ErrorState
665
676
 
666
677
  constructor(previousState: ShapeStreamState) {
667
678
  super()
668
- this.previousState = 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
- return transition
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: ShapeStreamState
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 = 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