@durable-streams/client 0.2.1 → 0.2.2

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,306 @@
1
+ /**
2
+ * Explicit state machine for StreamResponseImpl.
3
+ *
4
+ * Every transition returns a new state — no mutation.
5
+ *
6
+ * Hierarchy:
7
+ * StreamResponseState (abstract)
8
+ * ├── LongPollState shouldUseSse() → false
9
+ * ├── SSEState shouldUseSse() → true
10
+ * └── PausedState delegates to wrapped inner state
11
+ */
12
+
13
+ import type { SSEControlEvent } from "./sse"
14
+ import type { LiveMode, Offset, SSEResilienceOptions } from "./types"
15
+
16
+ /**
17
+ * Shared sync fields across all state types.
18
+ */
19
+ export interface SyncFields {
20
+ readonly offset: Offset
21
+ readonly cursor: string | undefined
22
+ readonly upToDate: boolean
23
+ readonly streamClosed: boolean
24
+ }
25
+
26
+ /**
27
+ * Extracted metadata from an HTTP response for state transitions.
28
+ * undefined values mean "not present in response, preserve current value".
29
+ */
30
+ export interface ResponseMetadataUpdate {
31
+ readonly offset?: string
32
+ readonly cursor?: string
33
+ readonly upToDate: boolean
34
+ readonly streamClosed: boolean
35
+ }
36
+
37
+ /**
38
+ * Result of SSEState.handleConnectionEnd().
39
+ */
40
+ export type SSEConnectionEndResult =
41
+ | {
42
+ readonly action: `reconnect`
43
+ readonly state: SSEState
44
+ readonly backoffAttempt: number
45
+ }
46
+ | { readonly action: `fallback`; readonly state: LongPollState }
47
+ | { readonly action: `healthy`; readonly state: SSEState }
48
+
49
+ /**
50
+ * Abstract base class for stream response state.
51
+ * All state transitions return new immutable state objects.
52
+ */
53
+ export abstract class StreamResponseState implements SyncFields {
54
+ abstract readonly offset: Offset
55
+ abstract readonly cursor: string | undefined
56
+ abstract readonly upToDate: boolean
57
+ abstract readonly streamClosed: boolean
58
+
59
+ abstract shouldUseSse(): boolean
60
+ abstract withResponseMetadata(
61
+ update: ResponseMetadataUpdate
62
+ ): StreamResponseState
63
+ abstract withSSEControl(event: SSEControlEvent): StreamResponseState
64
+ abstract pause(): StreamResponseState
65
+
66
+ shouldContinueLive(stopAfterUpToDate: boolean, liveMode: LiveMode): boolean {
67
+ if (stopAfterUpToDate && this.upToDate) return false
68
+ if (liveMode === false) return false
69
+ if (this.streamClosed) return false
70
+ return true
71
+ }
72
+ }
73
+
74
+ /**
75
+ * State for long-poll mode. shouldUseSse() returns false.
76
+ */
77
+ export class LongPollState extends StreamResponseState {
78
+ readonly offset: Offset
79
+ readonly cursor: string | undefined
80
+ readonly upToDate: boolean
81
+ readonly streamClosed: boolean
82
+
83
+ constructor(fields: SyncFields) {
84
+ super()
85
+ this.offset = fields.offset
86
+ this.cursor = fields.cursor
87
+ this.upToDate = fields.upToDate
88
+ this.streamClosed = fields.streamClosed
89
+ }
90
+
91
+ shouldUseSse(): boolean {
92
+ return false
93
+ }
94
+
95
+ withResponseMetadata(update: ResponseMetadataUpdate): LongPollState {
96
+ return new LongPollState({
97
+ offset: update.offset ?? this.offset,
98
+ cursor: update.cursor ?? this.cursor,
99
+ upToDate: update.upToDate,
100
+ streamClosed: this.streamClosed || update.streamClosed,
101
+ })
102
+ }
103
+
104
+ withSSEControl(event: SSEControlEvent): LongPollState {
105
+ const streamClosed = this.streamClosed || (event.streamClosed ?? false)
106
+ return new LongPollState({
107
+ offset: event.streamNextOffset,
108
+ cursor: event.streamCursor || this.cursor,
109
+ upToDate:
110
+ (event.streamClosed ?? false)
111
+ ? true
112
+ : (event.upToDate ?? this.upToDate),
113
+ streamClosed,
114
+ })
115
+ }
116
+
117
+ pause(): PausedState {
118
+ return new PausedState(this)
119
+ }
120
+ }
121
+
122
+ /**
123
+ * State for SSE mode. shouldUseSse() returns true.
124
+ * Tracks SSE connection resilience (short connection detection).
125
+ */
126
+ export class SSEState extends StreamResponseState {
127
+ readonly offset: Offset
128
+ readonly cursor: string | undefined
129
+ readonly upToDate: boolean
130
+ readonly streamClosed: boolean
131
+ readonly consecutiveShortConnections: number
132
+ readonly connectionStartTime: number | undefined
133
+
134
+ constructor(
135
+ fields: SyncFields & {
136
+ consecutiveShortConnections?: number
137
+ connectionStartTime?: number
138
+ }
139
+ ) {
140
+ super()
141
+ this.offset = fields.offset
142
+ this.cursor = fields.cursor
143
+ this.upToDate = fields.upToDate
144
+ this.streamClosed = fields.streamClosed
145
+ this.consecutiveShortConnections = fields.consecutiveShortConnections ?? 0
146
+ this.connectionStartTime = fields.connectionStartTime
147
+ }
148
+
149
+ shouldUseSse(): boolean {
150
+ return true
151
+ }
152
+
153
+ withResponseMetadata(update: ResponseMetadataUpdate): SSEState {
154
+ return new SSEState({
155
+ offset: update.offset ?? this.offset,
156
+ cursor: update.cursor ?? this.cursor,
157
+ upToDate: update.upToDate,
158
+ streamClosed: this.streamClosed || update.streamClosed,
159
+ consecutiveShortConnections: this.consecutiveShortConnections,
160
+ connectionStartTime: this.connectionStartTime,
161
+ })
162
+ }
163
+
164
+ withSSEControl(event: SSEControlEvent): SSEState {
165
+ const streamClosed = this.streamClosed || (event.streamClosed ?? false)
166
+ return new SSEState({
167
+ offset: event.streamNextOffset,
168
+ cursor: event.streamCursor || this.cursor,
169
+ upToDate:
170
+ (event.streamClosed ?? false)
171
+ ? true
172
+ : (event.upToDate ?? this.upToDate),
173
+ streamClosed,
174
+ consecutiveShortConnections: this.consecutiveShortConnections,
175
+ connectionStartTime: this.connectionStartTime,
176
+ })
177
+ }
178
+
179
+ startConnection(now: number): SSEState {
180
+ return new SSEState({
181
+ offset: this.offset,
182
+ cursor: this.cursor,
183
+ upToDate: this.upToDate,
184
+ streamClosed: this.streamClosed,
185
+ consecutiveShortConnections: this.consecutiveShortConnections,
186
+ connectionStartTime: now,
187
+ })
188
+ }
189
+
190
+ handleConnectionEnd(
191
+ now: number,
192
+ wasAborted: boolean,
193
+ config: Required<SSEResilienceOptions>
194
+ ): SSEConnectionEndResult {
195
+ if (this.connectionStartTime === undefined) {
196
+ return { action: `healthy`, state: this }
197
+ }
198
+
199
+ const duration = now - this.connectionStartTime
200
+
201
+ if (duration < config.minConnectionDuration && !wasAborted) {
202
+ // Connection was too short — likely proxy buffering or misconfiguration
203
+ const newCount = this.consecutiveShortConnections + 1
204
+
205
+ if (newCount >= config.maxShortConnections) {
206
+ // Threshold reached → permanent fallback to long-poll
207
+ return {
208
+ action: `fallback`,
209
+ state: new LongPollState({
210
+ offset: this.offset,
211
+ cursor: this.cursor,
212
+ upToDate: this.upToDate,
213
+ streamClosed: this.streamClosed,
214
+ }),
215
+ }
216
+ }
217
+
218
+ // Reconnect with backoff
219
+ return {
220
+ action: `reconnect`,
221
+ state: new SSEState({
222
+ offset: this.offset,
223
+ cursor: this.cursor,
224
+ upToDate: this.upToDate,
225
+ streamClosed: this.streamClosed,
226
+ consecutiveShortConnections: newCount,
227
+ connectionStartTime: this.connectionStartTime,
228
+ }),
229
+ backoffAttempt: newCount,
230
+ }
231
+ }
232
+
233
+ if (duration >= config.minConnectionDuration) {
234
+ // Healthy connection — reset counter
235
+ return {
236
+ action: `healthy`,
237
+ state: new SSEState({
238
+ offset: this.offset,
239
+ cursor: this.cursor,
240
+ upToDate: this.upToDate,
241
+ streamClosed: this.streamClosed,
242
+ consecutiveShortConnections: 0,
243
+ connectionStartTime: this.connectionStartTime,
244
+ }),
245
+ }
246
+ }
247
+
248
+ // Aborted connection — don't change counter
249
+ return { action: `healthy`, state: this }
250
+ }
251
+
252
+ pause(): PausedState {
253
+ return new PausedState(this)
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Paused state wrapper. Delegates all sync field access to the inner state.
259
+ * resume() returns the wrapped state unchanged (identity preserved).
260
+ */
261
+ export class PausedState extends StreamResponseState {
262
+ readonly #inner: LongPollState | SSEState
263
+
264
+ constructor(inner: LongPollState | SSEState) {
265
+ super()
266
+ this.#inner = inner
267
+ }
268
+
269
+ get offset(): Offset {
270
+ return this.#inner.offset
271
+ }
272
+
273
+ get cursor(): string | undefined {
274
+ return this.#inner.cursor
275
+ }
276
+
277
+ get upToDate(): boolean {
278
+ return this.#inner.upToDate
279
+ }
280
+
281
+ get streamClosed(): boolean {
282
+ return this.#inner.streamClosed
283
+ }
284
+
285
+ shouldUseSse(): boolean {
286
+ return this.#inner.shouldUseSse()
287
+ }
288
+
289
+ withResponseMetadata(update: ResponseMetadataUpdate): PausedState {
290
+ const newInner = this.#inner.withResponseMetadata(update)
291
+ return new PausedState(newInner)
292
+ }
293
+
294
+ withSSEControl(event: SSEControlEvent): PausedState {
295
+ const newInner = this.#inner.withSSEControl(event)
296
+ return new PausedState(newInner)
297
+ }
298
+
299
+ pause(): PausedState {
300
+ return this
301
+ }
302
+
303
+ resume(): { state: LongPollState | SSEState; justResumed: true } {
304
+ return { state: this.#inner, justResumed: true }
305
+ }
306
+ }