@electric-sql/client 1.5.1 → 1.5.3
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 +808 -268
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +2 -2
- package/dist/index.browser.mjs +3 -3
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.legacy-esm.js +808 -268
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +808 -268
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +366 -377
- package/src/constants.ts +1 -0
- package/src/pause-lock.ts +112 -0
- package/src/shape-stream-state.ts +781 -0
|
@@ -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
|
+
}
|