@electric-sql/client 1.5.2 → 1.5.4

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.
@@ -0,0 +1,781 @@
1
+ /*
2
+ * Shape stream state machine.
3
+ *
4
+ * Class hierarchy:
5
+ *
6
+ * ShapeStreamState (abstract base)
7
+ * ├── ActiveState (abstract — shared field storage & helpers)
8
+ * │ ├── FetchingState (abstract — shared Initial/Syncing/StaleRetry behavior)
9
+ * │ │ ├── InitialState
10
+ * │ │ ├── SyncingState
11
+ * │ │ └── StaleRetryState
12
+ * │ ├── LiveState
13
+ * │ └── ReplayingState
14
+ * ├── PausedState (delegates to previousState)
15
+ * └── ErrorState (delegates to previousState)
16
+ *
17
+ * State transitions:
18
+ *
19
+ * Initial ─response─► Syncing ─up-to-date─► Live
20
+ * │ │
21
+ * └──stale──► StaleRetry
22
+ * │
23
+ * Syncing ◄──response──┘
24
+ *
25
+ * Any state ─pause─► Paused ─resume─► (previous state)
26
+ * Any state ─error─► Error ─retry──► (previous state)
27
+ * Any state ─markMustRefetch─► Initial (offset reset)
28
+ */
29
+ import { Offset, Schema } from './types'
30
+ import {
31
+ OFFSET_QUERY_PARAM,
32
+ SHAPE_HANDLE_QUERY_PARAM,
33
+ LIVE_CACHE_BUSTER_QUERY_PARAM,
34
+ LIVE_QUERY_PARAM,
35
+ CACHE_BUSTER_QUERY_PARAM,
36
+ } from './constants'
37
+
38
+ export type ShapeStreamStateKind =
39
+ | `initial`
40
+ | `syncing`
41
+ | `live`
42
+ | `replaying`
43
+ | `stale-retry`
44
+ | `paused`
45
+ | `error`
46
+
47
+ /**
48
+ * Shared fields carried by all active (non-paused, non-error) states.
49
+ */
50
+ export interface SharedStateFields {
51
+ readonly handle?: string
52
+ readonly offset: Offset
53
+ readonly schema?: Schema
54
+ readonly liveCacheBuster: string
55
+ readonly lastSyncedAt?: number
56
+ }
57
+
58
+ type ResponseBaseInput = {
59
+ status: number
60
+ responseHandle: string | null
61
+ responseOffset: Offset | null
62
+ responseCursor: string | null
63
+ responseSchema?: Schema
64
+ expiredHandle?: string | null
65
+ now: number
66
+ }
67
+
68
+ export type ResponseMetadataInput = ResponseBaseInput & {
69
+ maxStaleCacheRetries: number
70
+ createCacheBuster: () => string
71
+ }
72
+
73
+ export type ResponseMetadataTransition =
74
+ | { action: `accepted`; state: ShapeStreamState }
75
+ | { action: `ignored`; state: ShapeStreamState }
76
+ | {
77
+ action: `stale-retry`
78
+ state: StaleRetryState
79
+ exceededMaxRetries: boolean
80
+ }
81
+
82
+ export interface MessageBatchInput {
83
+ hasMessages: boolean
84
+ hasUpToDateMessage: boolean
85
+ isSse: boolean
86
+ upToDateOffset?: Offset
87
+ now: number
88
+ currentCursor: string
89
+ }
90
+
91
+ export interface MessageBatchTransition {
92
+ state: ShapeStreamState
93
+ suppressBatch: boolean
94
+ becameUpToDate: boolean
95
+ }
96
+
97
+ export interface SseCloseInput {
98
+ connectionDuration: number
99
+ wasAborted: boolean
100
+ minConnectionDuration: number
101
+ maxShortConnections: number
102
+ }
103
+
104
+ export interface SseCloseTransition {
105
+ state: ShapeStreamState
106
+ fellBackToLongPolling: boolean
107
+ wasShortConnection: boolean
108
+ }
109
+
110
+ export interface UrlParamsContext {
111
+ isSnapshotRequest: boolean
112
+ canLongPoll: boolean
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Abstract base — shared by ALL states (including Paused/Error)
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Abstract base class for all shape stream states.
121
+ *
122
+ * Each concrete state carries only its relevant fields — there is no shared
123
+ * flat context bag. Transitions create new immutable state objects.
124
+ *
125
+ * `isUpToDate` is derived from state kind (only LiveState returns true).
126
+ */
127
+ export abstract class ShapeStreamState {
128
+ abstract readonly kind: ShapeStreamStateKind
129
+
130
+ // --- Shared field getters (all states expose these) ---
131
+ abstract get handle(): string | undefined
132
+ abstract get offset(): Offset
133
+ abstract get schema(): Schema | undefined
134
+ abstract get liveCacheBuster(): string
135
+ abstract get lastSyncedAt(): number | undefined
136
+
137
+ // --- Derived booleans ---
138
+ get isUpToDate(): boolean {
139
+ return false
140
+ }
141
+
142
+ // --- Per-state field defaults ---
143
+ get staleCacheBuster(): string | undefined {
144
+ return undefined
145
+ }
146
+ get staleCacheRetryCount(): number {
147
+ return 0
148
+ }
149
+ get sseFallbackToLongPolling(): boolean {
150
+ return false
151
+ }
152
+ get consecutiveShortSseConnections(): number {
153
+ return 0
154
+ }
155
+ get replayCursor(): string | undefined {
156
+ return undefined
157
+ }
158
+
159
+ // --- Default no-op methods ---
160
+
161
+ canEnterReplayMode(): boolean {
162
+ return false
163
+ }
164
+
165
+ enterReplayMode(_cursor: string): ShapeStreamState {
166
+ return this
167
+ }
168
+
169
+ shouldUseSse(_opts: {
170
+ liveSseEnabled: boolean
171
+ isRefreshing: boolean
172
+ resumingFromPause: boolean
173
+ }): boolean {
174
+ return false
175
+ }
176
+
177
+ handleSseConnectionClosed(_input: SseCloseInput): SseCloseTransition {
178
+ return {
179
+ state: this,
180
+ fellBackToLongPolling: false,
181
+ wasShortConnection: false,
182
+ }
183
+ }
184
+
185
+ // --- URL param application ---
186
+
187
+ /** Adds state-specific query parameters to the fetch URL. */
188
+ applyUrlParams(_url: URL, _context: UrlParamsContext): void {}
189
+
190
+ // --- Default response/message handlers (Paused/Error never receive these) ---
191
+
192
+ handleResponseMetadata(
193
+ _input: ResponseMetadataInput
194
+ ): ResponseMetadataTransition {
195
+ return { action: `ignored`, state: this }
196
+ }
197
+
198
+ handleMessageBatch(_input: MessageBatchInput): MessageBatchTransition {
199
+ return { state: this, suppressBatch: false, becameUpToDate: false }
200
+ }
201
+
202
+ // --- Universal transitions ---
203
+
204
+ /** Returns a new state identical to this one but with the handle changed. */
205
+ abstract withHandle(handle: string): ShapeStreamState
206
+
207
+ pause(): PausedState {
208
+ return new PausedState(this)
209
+ }
210
+
211
+ toErrorState(error: Error): ErrorState {
212
+ return new ErrorState(this, error)
213
+ }
214
+
215
+ markMustRefetch(handle?: string): InitialState {
216
+ return new InitialState({
217
+ handle,
218
+ offset: `-1`,
219
+ liveCacheBuster: ``,
220
+ lastSyncedAt: this.lastSyncedAt,
221
+ schema: undefined,
222
+ })
223
+ }
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // ActiveState — intermediate base for all non-paused, non-error states
228
+ // ---------------------------------------------------------------------------
229
+
230
+ /**
231
+ * Holds shared field storage and provides helpers for response/message
232
+ * handling. All five active states extend this (via FetchingState or directly).
233
+ */
234
+ abstract class ActiveState extends ShapeStreamState {
235
+ readonly #shared: SharedStateFields
236
+
237
+ constructor(shared: SharedStateFields) {
238
+ super()
239
+ this.#shared = shared
240
+ }
241
+
242
+ get handle() {
243
+ return this.#shared.handle
244
+ }
245
+ get offset() {
246
+ return this.#shared.offset
247
+ }
248
+ get schema() {
249
+ return this.#shared.schema
250
+ }
251
+ get liveCacheBuster() {
252
+ return this.#shared.liveCacheBuster
253
+ }
254
+ get lastSyncedAt() {
255
+ return this.#shared.lastSyncedAt
256
+ }
257
+
258
+ /** Expose shared fields to subclasses for spreading into new instances. */
259
+ protected get currentFields(): SharedStateFields {
260
+ return this.#shared
261
+ }
262
+
263
+ // --- URL param application ---
264
+
265
+ applyUrlParams(url: URL, _context: UrlParamsContext): void {
266
+ url.searchParams.set(OFFSET_QUERY_PARAM, this.#shared.offset)
267
+ if (this.#shared.handle) {
268
+ url.searchParams.set(SHAPE_HANDLE_QUERY_PARAM, this.#shared.handle)
269
+ }
270
+ }
271
+
272
+ // --- Helpers for subclass handleResponseMetadata implementations ---
273
+
274
+ /** Extracts updated SharedStateFields from response headers. */
275
+ protected parseResponseFields(
276
+ input: ResponseMetadataInput
277
+ ): SharedStateFields {
278
+ const responseHandle = input.responseHandle
279
+ const handle =
280
+ responseHandle && responseHandle !== input.expiredHandle
281
+ ? responseHandle
282
+ : this.#shared.handle
283
+ const offset = input.responseOffset ?? this.#shared.offset
284
+ const liveCacheBuster = input.responseCursor ?? this.#shared.liveCacheBuster
285
+ const schema = this.#shared.schema ?? input.responseSchema
286
+ const lastSyncedAt =
287
+ input.status === 204 ? input.now : this.#shared.lastSyncedAt
288
+
289
+ return { handle, offset, schema, liveCacheBuster, lastSyncedAt }
290
+ }
291
+
292
+ /**
293
+ * Stale detection. Returns a transition if the response is stale,
294
+ * or null if it is not stale and the caller should proceed normally.
295
+ */
296
+ protected checkStaleResponse(
297
+ input: ResponseMetadataInput
298
+ ): ResponseMetadataTransition | null {
299
+ const responseHandle = input.responseHandle
300
+ const expiredHandle = input.expiredHandle
301
+
302
+ if (!responseHandle || responseHandle !== expiredHandle) {
303
+ return null // not stale
304
+ }
305
+
306
+ // Stale response detected
307
+ if (
308
+ this.#shared.handle === undefined ||
309
+ this.#shared.handle === expiredHandle
310
+ ) {
311
+ // No local handle, or local handle is itself the expired one — enter stale retry
312
+ const retryCount = this.staleCacheRetryCount + 1
313
+ return {
314
+ action: `stale-retry`,
315
+ state: new StaleRetryState({
316
+ ...this.currentFields,
317
+ staleCacheBuster: input.createCacheBuster(),
318
+ staleCacheRetryCount: retryCount,
319
+ }),
320
+ exceededMaxRetries: retryCount > input.maxStaleCacheRetries,
321
+ }
322
+ }
323
+
324
+ // We have a different valid local handle — ignore this stale response
325
+ return { action: `ignored`, state: this }
326
+ }
327
+
328
+ // --- handleMessageBatch: template method with onUpToDate override point ---
329
+
330
+ handleMessageBatch(input: MessageBatchInput): MessageBatchTransition {
331
+ if (!input.hasMessages || !input.hasUpToDateMessage) {
332
+ return { state: this, suppressBatch: false, becameUpToDate: false }
333
+ }
334
+
335
+ // Has up-to-date message — compute shared fields for the transition
336
+ let offset = this.#shared.offset
337
+ if (input.isSse && input.upToDateOffset) {
338
+ offset = input.upToDateOffset
339
+ }
340
+
341
+ const shared: SharedStateFields = {
342
+ handle: this.#shared.handle,
343
+ offset,
344
+ schema: this.#shared.schema,
345
+ liveCacheBuster: this.#shared.liveCacheBuster,
346
+ lastSyncedAt: input.now,
347
+ }
348
+
349
+ return this.onUpToDate(shared, input)
350
+ }
351
+
352
+ /** Override point for up-to-date handling. Default → LiveState. */
353
+ protected onUpToDate(
354
+ shared: SharedStateFields,
355
+ _input: MessageBatchInput
356
+ ): MessageBatchTransition {
357
+ return {
358
+ state: new LiveState(shared),
359
+ suppressBatch: false,
360
+ becameUpToDate: true,
361
+ }
362
+ }
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // FetchingState — Common behavior for Initial/Syncing/StaleRetry
367
+ // ---------------------------------------------------------------------------
368
+
369
+ /**
370
+ * Captures shared behavior of InitialState, SyncingState, StaleRetryState:
371
+ * - handleResponseMetadata: stale check → parse fields → new SyncingState
372
+ * - canEnterReplayMode → true
373
+ * - enterReplayMode → new ReplayingState
374
+ */
375
+ abstract class FetchingState extends ActiveState {
376
+ handleResponseMetadata(
377
+ input: ResponseMetadataInput
378
+ ): ResponseMetadataTransition {
379
+ const staleResult = this.checkStaleResponse(input)
380
+ if (staleResult) return staleResult
381
+
382
+ const shared = this.parseResponseFields(input)
383
+ return { action: `accepted`, state: new SyncingState(shared) }
384
+ }
385
+
386
+ canEnterReplayMode(): boolean {
387
+ return true
388
+ }
389
+
390
+ enterReplayMode(cursor: string): ReplayingState {
391
+ return new ReplayingState({
392
+ ...this.currentFields,
393
+ replayCursor: cursor,
394
+ })
395
+ }
396
+ }
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // Concrete states
400
+ // ---------------------------------------------------------------------------
401
+
402
+ export class InitialState extends FetchingState {
403
+ readonly kind = `initial` as const
404
+
405
+ constructor(shared: SharedStateFields) {
406
+ super(shared)
407
+ }
408
+
409
+ withHandle(handle: string): InitialState {
410
+ return new InitialState({ ...this.currentFields, handle })
411
+ }
412
+ }
413
+
414
+ export class SyncingState extends FetchingState {
415
+ readonly kind = `syncing` as const
416
+
417
+ constructor(shared: SharedStateFields) {
418
+ super(shared)
419
+ }
420
+
421
+ withHandle(handle: string): SyncingState {
422
+ return new SyncingState({ ...this.currentFields, handle })
423
+ }
424
+ }
425
+
426
+ export class StaleRetryState extends FetchingState {
427
+ readonly kind = `stale-retry` as const
428
+ readonly #staleCacheBuster: string
429
+ readonly #staleCacheRetryCount: number
430
+
431
+ constructor(
432
+ fields: SharedStateFields & {
433
+ staleCacheBuster: string
434
+ staleCacheRetryCount: number
435
+ }
436
+ ) {
437
+ const { staleCacheBuster, staleCacheRetryCount, ...shared } = fields
438
+ super(shared)
439
+ this.#staleCacheBuster = staleCacheBuster
440
+ this.#staleCacheRetryCount = staleCacheRetryCount
441
+ }
442
+
443
+ get staleCacheBuster() {
444
+ return this.#staleCacheBuster
445
+ }
446
+ get staleCacheRetryCount() {
447
+ return this.#staleCacheRetryCount
448
+ }
449
+
450
+ // StaleRetryState must not enter replay mode — it would lose the retry count
451
+ canEnterReplayMode(): boolean {
452
+ return false
453
+ }
454
+
455
+ withHandle(handle: string): StaleRetryState {
456
+ return new StaleRetryState({
457
+ ...this.currentFields,
458
+ handle,
459
+ staleCacheBuster: this.#staleCacheBuster,
460
+ staleCacheRetryCount: this.#staleCacheRetryCount,
461
+ })
462
+ }
463
+
464
+ applyUrlParams(url: URL, context: UrlParamsContext): void {
465
+ super.applyUrlParams(url, context)
466
+ url.searchParams.set(CACHE_BUSTER_QUERY_PARAM, this.#staleCacheBuster)
467
+ }
468
+ }
469
+
470
+ export class LiveState extends ActiveState {
471
+ readonly kind = `live` as const
472
+ readonly #consecutiveShortSseConnections: number
473
+ readonly #sseFallbackToLongPolling: boolean
474
+
475
+ constructor(
476
+ shared: SharedStateFields,
477
+ sseState?: {
478
+ consecutiveShortSseConnections?: number
479
+ sseFallbackToLongPolling?: boolean
480
+ }
481
+ ) {
482
+ super(shared)
483
+ this.#consecutiveShortSseConnections =
484
+ sseState?.consecutiveShortSseConnections ?? 0
485
+ this.#sseFallbackToLongPolling = sseState?.sseFallbackToLongPolling ?? false
486
+ }
487
+
488
+ get isUpToDate(): boolean {
489
+ return true
490
+ }
491
+
492
+ get consecutiveShortSseConnections(): number {
493
+ return this.#consecutiveShortSseConnections
494
+ }
495
+
496
+ get sseFallbackToLongPolling(): boolean {
497
+ return this.#sseFallbackToLongPolling
498
+ }
499
+
500
+ withHandle(handle: string): LiveState {
501
+ return new LiveState({ ...this.currentFields, handle }, this.sseState)
502
+ }
503
+
504
+ applyUrlParams(url: URL, context: UrlParamsContext): void {
505
+ super.applyUrlParams(url, context)
506
+ // Snapshot requests (with subsetParams) should never use live polling
507
+ if (!context.isSnapshotRequest) {
508
+ url.searchParams.set(LIVE_CACHE_BUSTER_QUERY_PARAM, this.liveCacheBuster)
509
+ if (context.canLongPoll) {
510
+ url.searchParams.set(LIVE_QUERY_PARAM, `true`)
511
+ }
512
+ }
513
+ }
514
+
515
+ private get sseState() {
516
+ return {
517
+ consecutiveShortSseConnections: this.#consecutiveShortSseConnections,
518
+ sseFallbackToLongPolling: this.#sseFallbackToLongPolling,
519
+ }
520
+ }
521
+
522
+ handleResponseMetadata(
523
+ input: ResponseMetadataInput
524
+ ): ResponseMetadataTransition {
525
+ const staleResult = this.checkStaleResponse(input)
526
+ if (staleResult) return staleResult
527
+
528
+ const shared = this.parseResponseFields(input)
529
+ return {
530
+ action: `accepted`,
531
+ state: new LiveState(shared, this.sseState),
532
+ }
533
+ }
534
+
535
+ protected onUpToDate(
536
+ shared: SharedStateFields,
537
+ _input: MessageBatchInput
538
+ ): MessageBatchTransition {
539
+ return {
540
+ state: new LiveState(shared, this.sseState),
541
+ suppressBatch: false,
542
+ becameUpToDate: true,
543
+ }
544
+ }
545
+
546
+ shouldUseSse(opts: {
547
+ liveSseEnabled: boolean
548
+ isRefreshing: boolean
549
+ resumingFromPause: boolean
550
+ }): boolean {
551
+ return (
552
+ opts.liveSseEnabled &&
553
+ !opts.isRefreshing &&
554
+ !opts.resumingFromPause &&
555
+ !this.#sseFallbackToLongPolling
556
+ )
557
+ }
558
+
559
+ handleSseConnectionClosed(input: SseCloseInput): SseCloseTransition {
560
+ let nextConsecutiveShort = this.#consecutiveShortSseConnections
561
+ let nextFallback = this.#sseFallbackToLongPolling
562
+ let fellBackToLongPolling = false
563
+ let wasShortConnection = false
564
+
565
+ if (
566
+ input.connectionDuration < input.minConnectionDuration &&
567
+ !input.wasAborted
568
+ ) {
569
+ wasShortConnection = true
570
+ nextConsecutiveShort = nextConsecutiveShort + 1
571
+
572
+ if (nextConsecutiveShort >= input.maxShortConnections) {
573
+ nextFallback = true
574
+ fellBackToLongPolling = true
575
+ }
576
+ } else if (input.connectionDuration >= input.minConnectionDuration) {
577
+ nextConsecutiveShort = 0
578
+ }
579
+
580
+ return {
581
+ state: new LiveState(this.currentFields, {
582
+ consecutiveShortSseConnections: nextConsecutiveShort,
583
+ sseFallbackToLongPolling: nextFallback,
584
+ }),
585
+ fellBackToLongPolling,
586
+ wasShortConnection,
587
+ }
588
+ }
589
+ }
590
+
591
+ export class ReplayingState extends ActiveState {
592
+ readonly kind = `replaying` as const
593
+ readonly #replayCursor: string
594
+
595
+ constructor(fields: SharedStateFields & { replayCursor: string }) {
596
+ const { replayCursor, ...shared } = fields
597
+ super(shared)
598
+ this.#replayCursor = replayCursor
599
+ }
600
+
601
+ get replayCursor() {
602
+ return this.#replayCursor
603
+ }
604
+
605
+ withHandle(handle: string): ReplayingState {
606
+ return new ReplayingState({
607
+ ...this.currentFields,
608
+ handle,
609
+ replayCursor: this.#replayCursor,
610
+ })
611
+ }
612
+
613
+ handleResponseMetadata(
614
+ input: ResponseMetadataInput
615
+ ): ResponseMetadataTransition {
616
+ const staleResult = this.checkStaleResponse(input)
617
+ if (staleResult) return staleResult
618
+
619
+ const shared = this.parseResponseFields(input)
620
+ return {
621
+ action: `accepted`,
622
+ state: new ReplayingState({
623
+ ...shared,
624
+ replayCursor: this.#replayCursor,
625
+ }),
626
+ }
627
+ }
628
+
629
+ protected onUpToDate(
630
+ shared: SharedStateFields,
631
+ input: MessageBatchInput
632
+ ): MessageBatchTransition {
633
+ // Suppress replayed cache data when cursor has not moved since
634
+ // the previous session (non-SSE only).
635
+ const suppressBatch =
636
+ !input.isSse && this.#replayCursor === input.currentCursor
637
+ return {
638
+ state: new LiveState(shared),
639
+ suppressBatch,
640
+ becameUpToDate: true,
641
+ }
642
+ }
643
+ }
644
+
645
+ // ---------------------------------------------------------------------------
646
+ // Delegating states (Paused / Error)
647
+ // ---------------------------------------------------------------------------
648
+
649
+ export class PausedState extends ShapeStreamState {
650
+ readonly kind = `paused` as const
651
+ readonly previousState: ShapeStreamState
652
+
653
+ constructor(previousState: ShapeStreamState) {
654
+ super()
655
+ this.previousState = previousState
656
+ }
657
+
658
+ get handle() {
659
+ return this.previousState.handle
660
+ }
661
+ get offset() {
662
+ return this.previousState.offset
663
+ }
664
+ get schema() {
665
+ return this.previousState.schema
666
+ }
667
+ get liveCacheBuster() {
668
+ return this.previousState.liveCacheBuster
669
+ }
670
+ get lastSyncedAt() {
671
+ return this.previousState.lastSyncedAt
672
+ }
673
+
674
+ get isUpToDate(): boolean {
675
+ return this.previousState.isUpToDate
676
+ }
677
+
678
+ get staleCacheBuster() {
679
+ return this.previousState.staleCacheBuster
680
+ }
681
+ get staleCacheRetryCount() {
682
+ return this.previousState.staleCacheRetryCount
683
+ }
684
+ get sseFallbackToLongPolling() {
685
+ return this.previousState.sseFallbackToLongPolling
686
+ }
687
+ get consecutiveShortSseConnections() {
688
+ return this.previousState.consecutiveShortSseConnections
689
+ }
690
+ get replayCursor() {
691
+ return this.previousState.replayCursor
692
+ }
693
+
694
+ withHandle(handle: string): PausedState {
695
+ return new PausedState(this.previousState.withHandle(handle))
696
+ }
697
+
698
+ applyUrlParams(url: URL, context: UrlParamsContext): void {
699
+ this.previousState.applyUrlParams(url, context)
700
+ }
701
+
702
+ pause(): PausedState {
703
+ return this
704
+ }
705
+
706
+ resume(): ShapeStreamState {
707
+ return this.previousState
708
+ }
709
+ }
710
+
711
+ export class ErrorState extends ShapeStreamState {
712
+ readonly kind = `error` as const
713
+ readonly previousState: ShapeStreamState
714
+ readonly error: Error
715
+
716
+ constructor(previousState: ShapeStreamState, error: Error) {
717
+ super()
718
+ this.previousState = previousState
719
+ this.error = error
720
+ }
721
+
722
+ get handle() {
723
+ return this.previousState.handle
724
+ }
725
+ get offset() {
726
+ return this.previousState.offset
727
+ }
728
+ get schema() {
729
+ return this.previousState.schema
730
+ }
731
+ get liveCacheBuster() {
732
+ return this.previousState.liveCacheBuster
733
+ }
734
+ get lastSyncedAt() {
735
+ return this.previousState.lastSyncedAt
736
+ }
737
+
738
+ get isUpToDate(): boolean {
739
+ return this.previousState.isUpToDate
740
+ }
741
+
742
+ withHandle(handle: string): ErrorState {
743
+ return new ErrorState(this.previousState.withHandle(handle), this.error)
744
+ }
745
+
746
+ applyUrlParams(url: URL, context: UrlParamsContext): void {
747
+ this.previousState.applyUrlParams(url, context)
748
+ }
749
+
750
+ retry(): ShapeStreamState {
751
+ return this.previousState
752
+ }
753
+
754
+ reset(handle?: string): InitialState {
755
+ return this.previousState.markMustRefetch(handle)
756
+ }
757
+ }
758
+
759
+ // ---------------------------------------------------------------------------
760
+ // Type alias & factory
761
+ // ---------------------------------------------------------------------------
762
+
763
+ export type ShapeStreamActiveState =
764
+ | InitialState
765
+ | SyncingState
766
+ | LiveState
767
+ | ReplayingState
768
+ | StaleRetryState
769
+
770
+ export function createInitialState(opts: {
771
+ offset: Offset
772
+ handle?: string
773
+ }): InitialState {
774
+ return new InitialState({
775
+ handle: opts.handle,
776
+ offset: opts.offset,
777
+ liveCacheBuster: ``,
778
+ lastSyncedAt: undefined,
779
+ schema: undefined,
780
+ })
781
+ }