@electric-sql/client 0.5.0 → 0.6.0
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/README.md +2 -0
- package/dist/cjs/index.cjs +436 -240
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/index.browser.mjs +1 -1
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +57 -50
- package/dist/index.legacy-esm.js +434 -236
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +436 -240
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +179 -422
- package/src/constants.ts +8 -0
- package/src/error.ts +50 -0
- package/src/fetch.ts +272 -0
- package/src/helpers.ts +6 -0
- package/src/index.ts +4 -1
- package/src/shape.ts +197 -0
- package/src/types.ts +2 -0
package/src/client.ts
CHANGED
|
@@ -1,23 +1,22 @@
|
|
|
1
|
-
import { Message, Offset, Schema, Row } from './types'
|
|
1
|
+
import { Message, Offset, Schema, Row, MaybePromise } from './types'
|
|
2
2
|
import { MessageParser, Parser } from './parser'
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
3
|
+
import { isUpToDateMessage } from './helpers'
|
|
4
|
+
import { FetchError, FetchBackoffAbortError } from './error'
|
|
5
|
+
import {
|
|
6
|
+
BackoffDefaults,
|
|
7
|
+
BackoffOptions,
|
|
8
|
+
createFetchWithBackoff,
|
|
9
|
+
createFetchWithChunkBuffer,
|
|
10
|
+
} from './fetch'
|
|
11
|
+
import {
|
|
12
|
+
CHUNK_LAST_OFFSET_HEADER,
|
|
13
|
+
LIVE_QUERY_PARAM,
|
|
14
|
+
OFFSET_QUERY_PARAM,
|
|
15
|
+
SHAPE_ID_HEADER,
|
|
16
|
+
SHAPE_ID_QUERY_PARAM,
|
|
17
|
+
SHAPE_SCHEMA_HEADER,
|
|
18
|
+
WHERE_QUERY_PARAM,
|
|
19
|
+
} from './constants'
|
|
21
20
|
|
|
22
21
|
/**
|
|
23
22
|
* Options for constructing a ShapeStream.
|
|
@@ -57,86 +56,25 @@ export interface ShapeStreamOptions {
|
|
|
57
56
|
parser?: Parser
|
|
58
57
|
}
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
constructor(callback: (messages: Message<T>[]) => void | Promise<void>) {
|
|
73
|
-
this.callback = callback
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
process(messages: Message<T>[]) {
|
|
77
|
-
this.messageQueue.push(messages)
|
|
78
|
-
|
|
79
|
-
if (!this.isProcessing) {
|
|
80
|
-
this.processQueue()
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
private async processQueue() {
|
|
85
|
-
this.isProcessing = true
|
|
86
|
-
|
|
87
|
-
while (this.messageQueue.length > 0) {
|
|
88
|
-
const messages = this.messageQueue.shift()!
|
|
89
|
-
|
|
90
|
-
await this.callback(messages)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
this.isProcessing = false
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export class FetchError extends Error {
|
|
98
|
-
status: number
|
|
99
|
-
text?: string
|
|
100
|
-
json?: object
|
|
101
|
-
headers: Record<string, string>
|
|
102
|
-
|
|
103
|
-
constructor(
|
|
104
|
-
status: number,
|
|
105
|
-
text: string | undefined,
|
|
106
|
-
json: object | undefined,
|
|
107
|
-
headers: Record<string, string>,
|
|
108
|
-
public url: string,
|
|
109
|
-
message?: string
|
|
110
|
-
) {
|
|
111
|
-
super(
|
|
112
|
-
message ||
|
|
113
|
-
`HTTP Error ${status} at ${url}: ${text ?? JSON.stringify(json)}`
|
|
114
|
-
)
|
|
115
|
-
this.name = `FetchError`
|
|
116
|
-
this.status = status
|
|
117
|
-
this.text = text
|
|
118
|
-
this.json = json
|
|
119
|
-
this.headers = headers
|
|
120
|
-
}
|
|
59
|
+
export interface ShapeStreamInterface<T extends Row = Row> {
|
|
60
|
+
subscribe(
|
|
61
|
+
callback: (messages: Message<T>[]) => MaybePromise<void>,
|
|
62
|
+
onError?: (error: FetchError | Error) => void
|
|
63
|
+
): void
|
|
64
|
+
unsubscribeAllUpToDateSubscribers(): void
|
|
65
|
+
unsubscribeAll(): void
|
|
66
|
+
subscribeOnceToUpToDate(
|
|
67
|
+
callback: () => MaybePromise<void>,
|
|
68
|
+
error: (err: FetchError | Error) => void
|
|
69
|
+
): () => void
|
|
121
70
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
):
|
|
126
|
-
const status = response.status
|
|
127
|
-
const headers = Object.fromEntries([...response.headers.entries()])
|
|
128
|
-
let text: string | undefined = undefined
|
|
129
|
-
let json: object | undefined = undefined
|
|
130
|
-
|
|
131
|
-
const contentType = response.headers.get(`content-type`)
|
|
132
|
-
if (contentType && contentType.includes(`application/json`)) {
|
|
133
|
-
json = (await response.json()) as object
|
|
134
|
-
} else {
|
|
135
|
-
text = await response.text()
|
|
136
|
-
}
|
|
71
|
+
isLoading(): boolean
|
|
72
|
+
lastSyncedAt(): number | undefined
|
|
73
|
+
lastSynced(): number
|
|
74
|
+
isConnected(): boolean
|
|
137
75
|
|
|
138
|
-
|
|
139
|
-
|
|
76
|
+
isUpToDate: boolean
|
|
77
|
+
shapeId?: string
|
|
140
78
|
}
|
|
141
79
|
|
|
142
80
|
/**
|
|
@@ -169,89 +107,113 @@ export class FetchError extends Error {
|
|
|
169
107
|
* aborter.abort()
|
|
170
108
|
* ```
|
|
171
109
|
*/
|
|
172
|
-
export class ShapeStream<T extends Row = Row> {
|
|
173
|
-
private options: ShapeStreamOptions
|
|
174
|
-
private backoffOptions: BackoffOptions
|
|
175
|
-
private fetchClient: typeof fetch
|
|
176
|
-
private schema?: Schema
|
|
177
110
|
|
|
178
|
-
|
|
111
|
+
export class ShapeStream<T extends Row = Row>
|
|
112
|
+
implements ShapeStreamInterface<T>
|
|
113
|
+
{
|
|
114
|
+
readonly options: ShapeStreamOptions
|
|
115
|
+
|
|
116
|
+
readonly #fetchClient: typeof fetch
|
|
117
|
+
readonly #messageParser: MessageParser<T>
|
|
118
|
+
|
|
119
|
+
readonly #subscribers = new Map<
|
|
179
120
|
number,
|
|
180
|
-
[
|
|
121
|
+
[
|
|
122
|
+
(messages: Message<T>[]) => MaybePromise<void>,
|
|
123
|
+
((error: Error) => void) | undefined,
|
|
124
|
+
]
|
|
181
125
|
>()
|
|
182
|
-
|
|
126
|
+
readonly #upToDateSubscribers = new Map<
|
|
183
127
|
number,
|
|
184
128
|
[() => void, (error: FetchError | Error) => void]
|
|
185
129
|
>()
|
|
186
130
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
shapeId?: string
|
|
131
|
+
#lastOffset: Offset
|
|
132
|
+
#lastSyncedAt?: number // unix time
|
|
133
|
+
#isUpToDate: boolean = false
|
|
134
|
+
#connected: boolean = false
|
|
135
|
+
#shapeId?: string
|
|
136
|
+
#schema?: Schema
|
|
194
137
|
|
|
195
138
|
constructor(options: ShapeStreamOptions) {
|
|
196
|
-
|
|
139
|
+
validateOptions(options)
|
|
197
140
|
this.options = { subscribe: true, ...options }
|
|
198
|
-
this
|
|
199
|
-
this
|
|
200
|
-
this
|
|
141
|
+
this.#lastOffset = this.options.offset ?? `-1`
|
|
142
|
+
this.#shapeId = this.options.shapeId
|
|
143
|
+
this.#messageParser = new MessageParser<T>(options.parser)
|
|
201
144
|
|
|
202
|
-
|
|
203
|
-
this.fetchClient =
|
|
145
|
+
const baseFetchClient =
|
|
204
146
|
options.fetchClient ??
|
|
205
147
|
((...args: Parameters<typeof fetch>) => fetch(...args))
|
|
206
148
|
|
|
149
|
+
const fetchWithBackoffClient = createFetchWithBackoff(baseFetchClient, {
|
|
150
|
+
...(options.backoffOptions ?? BackoffDefaults),
|
|
151
|
+
onFailedAttempt: () => {
|
|
152
|
+
this.#connected = false
|
|
153
|
+
options.backoffOptions?.onFailedAttempt?.()
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
this.#fetchClient = createFetchWithChunkBuffer(fetchWithBackoffClient)
|
|
158
|
+
|
|
207
159
|
this.start()
|
|
208
160
|
}
|
|
209
161
|
|
|
162
|
+
get shapeId() {
|
|
163
|
+
return this.#shapeId
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
get isUpToDate() {
|
|
167
|
+
return this.#isUpToDate
|
|
168
|
+
}
|
|
169
|
+
|
|
210
170
|
async start() {
|
|
211
|
-
this
|
|
171
|
+
this.#isUpToDate = false
|
|
212
172
|
|
|
213
173
|
const { url, where, signal } = this.options
|
|
214
174
|
|
|
215
175
|
try {
|
|
216
|
-
while (
|
|
176
|
+
while (
|
|
177
|
+
(!signal?.aborted && !this.#isUpToDate) ||
|
|
178
|
+
this.options.subscribe
|
|
179
|
+
) {
|
|
217
180
|
const fetchUrl = new URL(url)
|
|
218
|
-
if (where) fetchUrl.searchParams.set(
|
|
219
|
-
fetchUrl.searchParams.set(
|
|
181
|
+
if (where) fetchUrl.searchParams.set(WHERE_QUERY_PARAM, where)
|
|
182
|
+
fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, this.#lastOffset)
|
|
220
183
|
|
|
221
|
-
if (this
|
|
222
|
-
fetchUrl.searchParams.set(
|
|
184
|
+
if (this.#isUpToDate) {
|
|
185
|
+
fetchUrl.searchParams.set(LIVE_QUERY_PARAM, `true`)
|
|
223
186
|
}
|
|
224
187
|
|
|
225
|
-
if (this
|
|
188
|
+
if (this.#shapeId) {
|
|
226
189
|
// This should probably be a header for better cache breaking?
|
|
227
|
-
fetchUrl.searchParams.set(
|
|
190
|
+
fetchUrl.searchParams.set(SHAPE_ID_QUERY_PARAM, this.#shapeId!)
|
|
228
191
|
}
|
|
229
192
|
|
|
230
193
|
let response!: Response
|
|
231
|
-
|
|
232
194
|
try {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
else break
|
|
195
|
+
response = await this.#fetchClient(fetchUrl.toString(), { signal })
|
|
196
|
+
this.#connected = true
|
|
236
197
|
} catch (e) {
|
|
198
|
+
if (e instanceof FetchBackoffAbortError) break // interrupted
|
|
237
199
|
if (!(e instanceof FetchError)) throw e // should never happen
|
|
238
200
|
if (e.status == 400) {
|
|
239
201
|
// The request is invalid, most likely because the shape has been deleted.
|
|
240
202
|
// We should start from scratch, this will force the shape to be recreated.
|
|
241
|
-
this
|
|
242
|
-
this
|
|
203
|
+
this.#reset()
|
|
204
|
+
await this.#publish(e.json as Message<T>[])
|
|
243
205
|
continue
|
|
244
206
|
} else if (e.status == 409) {
|
|
245
207
|
// Upon receiving a 409, we should start from scratch
|
|
246
208
|
// with the newly provided shape ID
|
|
247
|
-
const newShapeId = e.headers[
|
|
248
|
-
this
|
|
249
|
-
this
|
|
209
|
+
const newShapeId = e.headers[SHAPE_ID_HEADER]
|
|
210
|
+
this.#reset(newShapeId)
|
|
211
|
+
await this.#publish(e.json as Message<T>[])
|
|
250
212
|
continue
|
|
251
213
|
} else if (e.status >= 400 && e.status < 500) {
|
|
252
214
|
// Notify subscribers
|
|
253
|
-
this
|
|
254
|
-
this
|
|
215
|
+
this.#sendErrorToUpToDateSubscribers(e)
|
|
216
|
+
this.#sendErrorToSubscribers(e)
|
|
255
217
|
|
|
256
218
|
// 400 errors are not actionable without additional user input, so we're throwing them.
|
|
257
219
|
throw e
|
|
@@ -259,108 +221,99 @@ export class ShapeStream<T extends Row = Row> {
|
|
|
259
221
|
}
|
|
260
222
|
|
|
261
223
|
const { headers, status } = response
|
|
262
|
-
const shapeId = headers.get(
|
|
224
|
+
const shapeId = headers.get(SHAPE_ID_HEADER)
|
|
263
225
|
if (shapeId) {
|
|
264
|
-
this
|
|
226
|
+
this.#shapeId = shapeId
|
|
265
227
|
}
|
|
266
228
|
|
|
267
|
-
const lastOffset = headers.get(
|
|
229
|
+
const lastOffset = headers.get(CHUNK_LAST_OFFSET_HEADER)
|
|
268
230
|
if (lastOffset) {
|
|
269
|
-
this
|
|
231
|
+
this.#lastOffset = lastOffset as Offset
|
|
270
232
|
}
|
|
271
233
|
|
|
272
234
|
const getSchema = (): Schema => {
|
|
273
|
-
const schemaHeader = headers.get(
|
|
235
|
+
const schemaHeader = headers.get(SHAPE_SCHEMA_HEADER)
|
|
274
236
|
return schemaHeader ? JSON.parse(schemaHeader) : {}
|
|
275
237
|
}
|
|
276
|
-
this
|
|
238
|
+
this.#schema = this.#schema ?? getSchema()
|
|
277
239
|
|
|
278
240
|
const messages = status === 204 ? `[]` : await response.text()
|
|
279
241
|
|
|
280
242
|
if (status === 204) {
|
|
281
243
|
// There's no content so we are live and up to date
|
|
282
|
-
this
|
|
244
|
+
this.#lastSyncedAt = Date.now()
|
|
283
245
|
}
|
|
284
246
|
|
|
285
|
-
const batch = this
|
|
247
|
+
const batch = this.#messageParser.parse(messages, this.#schema)
|
|
286
248
|
|
|
287
249
|
// Update isUpToDate
|
|
288
250
|
if (batch.length > 0) {
|
|
251
|
+
const prevUpToDate = this.#isUpToDate
|
|
289
252
|
const lastMessage = batch[batch.length - 1]
|
|
290
|
-
if (
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
) {
|
|
294
|
-
this.lastSyncedAt = Date.now()
|
|
295
|
-
if (!this.isUpToDate) {
|
|
296
|
-
this.isUpToDate = true
|
|
297
|
-
this.notifyUpToDateSubscribers()
|
|
298
|
-
}
|
|
253
|
+
if (isUpToDateMessage(lastMessage)) {
|
|
254
|
+
this.#lastSyncedAt = Date.now()
|
|
255
|
+
this.#isUpToDate = true
|
|
299
256
|
}
|
|
300
257
|
|
|
301
|
-
this
|
|
258
|
+
await this.#publish(batch)
|
|
259
|
+
if (!prevUpToDate && this.#isUpToDate) {
|
|
260
|
+
this.#notifyUpToDateSubscribers()
|
|
261
|
+
}
|
|
302
262
|
}
|
|
303
263
|
}
|
|
304
264
|
} finally {
|
|
305
|
-
this
|
|
265
|
+
this.#connected = false
|
|
306
266
|
}
|
|
307
267
|
}
|
|
308
268
|
|
|
309
269
|
subscribe(
|
|
310
|
-
callback: (messages: Message<T>[]) =>
|
|
270
|
+
callback: (messages: Message<T>[]) => MaybePromise<void>,
|
|
311
271
|
onError?: (error: FetchError | Error) => void
|
|
312
272
|
) {
|
|
313
273
|
const subscriptionId = Math.random()
|
|
314
|
-
const subscriber = new MessageProcessor(callback)
|
|
315
274
|
|
|
316
|
-
this
|
|
275
|
+
this.#subscribers.set(subscriptionId, [callback, onError])
|
|
317
276
|
|
|
318
277
|
return () => {
|
|
319
|
-
this
|
|
278
|
+
this.#subscribers.delete(subscriptionId)
|
|
320
279
|
}
|
|
321
280
|
}
|
|
322
281
|
|
|
323
282
|
unsubscribeAll(): void {
|
|
324
|
-
this
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
private publish(messages: Message<T>[]) {
|
|
328
|
-
this.subscribers.forEach(([subscriber, _]) => {
|
|
329
|
-
subscriber.process(messages)
|
|
330
|
-
})
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
private sendErrorToSubscribers(error: Error) {
|
|
334
|
-
this.subscribers.forEach(([_, errorFn]) => {
|
|
335
|
-
errorFn?.(error)
|
|
336
|
-
})
|
|
283
|
+
this.#subscribers.clear()
|
|
337
284
|
}
|
|
338
285
|
|
|
339
286
|
subscribeOnceToUpToDate(
|
|
340
|
-
callback: () =>
|
|
287
|
+
callback: () => MaybePromise<void>,
|
|
341
288
|
error: (err: FetchError | Error) => void
|
|
342
289
|
) {
|
|
343
290
|
const subscriptionId = Math.random()
|
|
344
291
|
|
|
345
|
-
this
|
|
292
|
+
this.#upToDateSubscribers.set(subscriptionId, [callback, error])
|
|
346
293
|
|
|
347
294
|
return () => {
|
|
348
|
-
this
|
|
295
|
+
this.#upToDateSubscribers.delete(subscriptionId)
|
|
349
296
|
}
|
|
350
297
|
}
|
|
351
298
|
|
|
352
299
|
unsubscribeAllUpToDateSubscribers(): void {
|
|
353
|
-
this
|
|
300
|
+
this.#upToDateSubscribers.clear()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Unix time at which we last synced. Undefined when `isLoading` is true. */
|
|
304
|
+
lastSyncedAt(): number | undefined {
|
|
305
|
+
return this.#lastSyncedAt
|
|
354
306
|
}
|
|
355
307
|
|
|
356
308
|
/** Time elapsed since last sync (in ms). Infinity if we did not yet sync. */
|
|
357
309
|
lastSynced(): number {
|
|
358
|
-
if (this
|
|
359
|
-
return Date.now() - this
|
|
310
|
+
if (this.#lastSyncedAt === undefined) return Infinity
|
|
311
|
+
return Date.now() - this.#lastSyncedAt
|
|
360
312
|
}
|
|
361
313
|
|
|
314
|
+
/** Indicates if we are connected to the Electric sync service. */
|
|
362
315
|
isConnected(): boolean {
|
|
363
|
-
return this
|
|
316
|
+
return this.#connected
|
|
364
317
|
}
|
|
365
318
|
|
|
366
319
|
/** True during initial fetch. False afterwise. */
|
|
@@ -368,15 +321,34 @@ export class ShapeStream<T extends Row = Row> {
|
|
|
368
321
|
return !this.isUpToDate
|
|
369
322
|
}
|
|
370
323
|
|
|
371
|
-
|
|
372
|
-
|
|
324
|
+
async #publish(messages: Message<T>[]): Promise<void> {
|
|
325
|
+
await Promise.all(
|
|
326
|
+
Array.from(this.#subscribers.values()).map(async ([callback, __]) => {
|
|
327
|
+
try {
|
|
328
|
+
await callback(messages)
|
|
329
|
+
} catch (err) {
|
|
330
|
+
queueMicrotask(() => {
|
|
331
|
+
throw err
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
#sendErrorToSubscribers(error: Error) {
|
|
339
|
+
this.#subscribers.forEach(([_, errorFn]) => {
|
|
340
|
+
errorFn?.(error)
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#notifyUpToDateSubscribers() {
|
|
345
|
+
this.#upToDateSubscribers.forEach(([callback]) => {
|
|
373
346
|
callback()
|
|
374
347
|
})
|
|
375
348
|
}
|
|
376
349
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
this.upToDateSubscribers.forEach(([_, errorCallback]) =>
|
|
350
|
+
#sendErrorToUpToDateSubscribers(error: FetchError | Error) {
|
|
351
|
+
this.#upToDateSubscribers.forEach(([_, errorCallback]) =>
|
|
380
352
|
errorCallback(error)
|
|
381
353
|
)
|
|
382
354
|
}
|
|
@@ -385,248 +357,33 @@ export class ShapeStream<T extends Row = Row> {
|
|
|
385
357
|
* Resets the state of the stream, optionally with a provided
|
|
386
358
|
* shape ID
|
|
387
359
|
*/
|
|
388
|
-
|
|
389
|
-
this
|
|
390
|
-
this
|
|
391
|
-
this
|
|
392
|
-
this
|
|
393
|
-
this
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
private validateOptions(options: ShapeStreamOptions): void {
|
|
397
|
-
if (!options.url) {
|
|
398
|
-
throw new Error(`Invalid shape option. It must provide the url`)
|
|
399
|
-
}
|
|
400
|
-
if (options.signal && !(options.signal instanceof AbortSignal)) {
|
|
401
|
-
throw new Error(
|
|
402
|
-
`Invalid signal option. It must be an instance of AbortSignal.`
|
|
403
|
-
)
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
if (
|
|
407
|
-
options.offset !== undefined &&
|
|
408
|
-
options.offset !== `-1` &&
|
|
409
|
-
!options.shapeId
|
|
410
|
-
) {
|
|
411
|
-
throw new Error(
|
|
412
|
-
`shapeId is required if this isn't an initial fetch (i.e. offset > -1)`
|
|
413
|
-
)
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
private async fetchWithBackoff(url: URL) {
|
|
418
|
-
const { initialDelay, maxDelay, multiplier } = this.backoffOptions
|
|
419
|
-
const signal = this.options.signal
|
|
420
|
-
|
|
421
|
-
let delay = initialDelay
|
|
422
|
-
let attempt = 0
|
|
423
|
-
|
|
424
|
-
// eslint-disable-next-line no-constant-condition -- we're retrying with a lag until we get a non-500 response or the abort signal is triggered
|
|
425
|
-
while (true) {
|
|
426
|
-
try {
|
|
427
|
-
const result = await this.fetchClient(url.toString(), { signal })
|
|
428
|
-
if (result.ok) {
|
|
429
|
-
if (this.options.subscribe) {
|
|
430
|
-
this.connected = true
|
|
431
|
-
}
|
|
432
|
-
return result
|
|
433
|
-
} else throw await FetchError.fromResponse(result, url.toString())
|
|
434
|
-
} catch (e) {
|
|
435
|
-
this.connected = false
|
|
436
|
-
if (signal?.aborted) {
|
|
437
|
-
return undefined
|
|
438
|
-
} else if (
|
|
439
|
-
e instanceof FetchError &&
|
|
440
|
-
e.status >= 400 &&
|
|
441
|
-
e.status < 500
|
|
442
|
-
) {
|
|
443
|
-
// Any client errors cannot be backed off on, leave it to the caller to handle.
|
|
444
|
-
throw e
|
|
445
|
-
} else {
|
|
446
|
-
// Exponentially backoff on errors.
|
|
447
|
-
// Wait for the current delay duration
|
|
448
|
-
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
449
|
-
|
|
450
|
-
// Increase the delay for the next attempt
|
|
451
|
-
delay = Math.min(delay * multiplier, maxDelay)
|
|
452
|
-
|
|
453
|
-
attempt++
|
|
454
|
-
console.log(`Retry attempt #${attempt} after ${delay}ms`)
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
360
|
+
#reset(shapeId?: string) {
|
|
361
|
+
this.#lastOffset = `-1`
|
|
362
|
+
this.#shapeId = shapeId
|
|
363
|
+
this.#isUpToDate = false
|
|
364
|
+
this.#connected = false
|
|
365
|
+
this.#schema = undefined
|
|
458
366
|
}
|
|
459
367
|
}
|
|
460
368
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
* notifies subscribers when the value has changed.
|
|
465
|
-
*
|
|
466
|
-
* It can be used without a framework and as a primitive
|
|
467
|
-
* to simplify developing framework hooks.
|
|
468
|
-
*
|
|
469
|
-
* @constructor
|
|
470
|
-
* @param {ShapeStream<T extends Row>} - the underlying shape stream
|
|
471
|
-
* @example
|
|
472
|
-
* ```
|
|
473
|
-
* const shapeStream = new ShapeStream<{ foo: number }>(url: 'http://localhost:3000/v1/shape/foo'})
|
|
474
|
-
* const shape = new Shape(shapeStream)
|
|
475
|
-
* ```
|
|
476
|
-
*
|
|
477
|
-
* `value` returns a promise that resolves the Shape data once the Shape has been
|
|
478
|
-
* fully loaded (and when resuming from being offline):
|
|
479
|
-
*
|
|
480
|
-
* const value = await shape.value
|
|
481
|
-
*
|
|
482
|
-
* `valueSync` returns the current data synchronously:
|
|
483
|
-
*
|
|
484
|
-
* const value = shape.valueSync
|
|
485
|
-
*
|
|
486
|
-
* Subscribe to updates. Called whenever the shape updates in Postgres.
|
|
487
|
-
*
|
|
488
|
-
* shape.subscribe(shapeData => {
|
|
489
|
-
* console.log(shapeData)
|
|
490
|
-
* })
|
|
491
|
-
*/
|
|
492
|
-
export class Shape<T extends Row = Row> {
|
|
493
|
-
private stream: ShapeStream<T>
|
|
494
|
-
|
|
495
|
-
private data: ShapeData<T> = new Map()
|
|
496
|
-
private subscribers = new Map<number, ShapeChangedCallback<T>>()
|
|
497
|
-
public error: FetchError | false = false
|
|
498
|
-
private hasNotifiedSubscribersUpToDate: boolean = false
|
|
499
|
-
|
|
500
|
-
constructor(stream: ShapeStream<T>) {
|
|
501
|
-
this.stream = stream
|
|
502
|
-
this.stream.subscribe(this.process.bind(this), this.handleError.bind(this))
|
|
503
|
-
const unsubscribe = this.stream.subscribeOnceToUpToDate(
|
|
504
|
-
() => {
|
|
505
|
-
unsubscribe()
|
|
506
|
-
},
|
|
507
|
-
(e) => {
|
|
508
|
-
this.handleError(e)
|
|
509
|
-
throw e
|
|
510
|
-
}
|
|
511
|
-
)
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
lastSynced(): number {
|
|
515
|
-
return this.stream.lastSynced()
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
isConnected(): boolean {
|
|
519
|
-
return this.stream.isConnected()
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/** True during initial fetch. False afterwise. */
|
|
523
|
-
isLoading(): boolean {
|
|
524
|
-
return this.stream.isLoading()
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
get value(): Promise<ShapeData<T>> {
|
|
528
|
-
return new Promise((resolve) => {
|
|
529
|
-
if (this.stream.isUpToDate) {
|
|
530
|
-
resolve(this.valueSync)
|
|
531
|
-
} else {
|
|
532
|
-
const unsubscribe = this.stream.subscribeOnceToUpToDate(
|
|
533
|
-
() => {
|
|
534
|
-
unsubscribe()
|
|
535
|
-
resolve(this.valueSync)
|
|
536
|
-
},
|
|
537
|
-
(e) => {
|
|
538
|
-
throw e
|
|
539
|
-
}
|
|
540
|
-
)
|
|
541
|
-
}
|
|
542
|
-
})
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
get valueSync() {
|
|
546
|
-
return this.data
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
subscribe(callback: ShapeChangedCallback<T>): () => void {
|
|
550
|
-
const subscriptionId = Math.random()
|
|
551
|
-
|
|
552
|
-
this.subscribers.set(subscriptionId, callback)
|
|
553
|
-
|
|
554
|
-
return () => {
|
|
555
|
-
this.subscribers.delete(subscriptionId)
|
|
556
|
-
}
|
|
369
|
+
function validateOptions(options: Partial<ShapeStreamOptions>): void {
|
|
370
|
+
if (!options.url) {
|
|
371
|
+
throw new Error(`Invalid shape option. It must provide the url`)
|
|
557
372
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
get numSubscribers() {
|
|
564
|
-
return this.subscribers.size
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
private process(messages: Message<T>[]): void {
|
|
568
|
-
let dataMayHaveChanged = false
|
|
569
|
-
let isUpToDate = false
|
|
570
|
-
let newlyUpToDate = false
|
|
571
|
-
|
|
572
|
-
messages.forEach((message) => {
|
|
573
|
-
if (isChangeMessage(message)) {
|
|
574
|
-
dataMayHaveChanged = [`insert`, `update`, `delete`].includes(
|
|
575
|
-
message.headers.operation
|
|
576
|
-
)
|
|
577
|
-
|
|
578
|
-
switch (message.headers.operation) {
|
|
579
|
-
case `insert`:
|
|
580
|
-
this.data.set(message.key, message.value)
|
|
581
|
-
break
|
|
582
|
-
case `update`:
|
|
583
|
-
this.data.set(message.key, {
|
|
584
|
-
...this.data.get(message.key)!,
|
|
585
|
-
...message.value,
|
|
586
|
-
})
|
|
587
|
-
break
|
|
588
|
-
case `delete`:
|
|
589
|
-
this.data.delete(message.key)
|
|
590
|
-
break
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
if (isControlMessage(message)) {
|
|
595
|
-
switch (message.headers.control) {
|
|
596
|
-
case `up-to-date`:
|
|
597
|
-
isUpToDate = true
|
|
598
|
-
if (!this.hasNotifiedSubscribersUpToDate) {
|
|
599
|
-
newlyUpToDate = true
|
|
600
|
-
}
|
|
601
|
-
break
|
|
602
|
-
case `must-refetch`:
|
|
603
|
-
this.data.clear()
|
|
604
|
-
this.error = false
|
|
605
|
-
isUpToDate = false
|
|
606
|
-
newlyUpToDate = false
|
|
607
|
-
break
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
})
|
|
611
|
-
|
|
612
|
-
// Always notify subscribers when the Shape first is up to date.
|
|
613
|
-
// FIXME this would be cleaner with a simple state machine.
|
|
614
|
-
if (newlyUpToDate || (isUpToDate && dataMayHaveChanged)) {
|
|
615
|
-
this.hasNotifiedSubscribersUpToDate = true
|
|
616
|
-
this.notify()
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
private handleError(e: Error): void {
|
|
621
|
-
if (e instanceof FetchError) {
|
|
622
|
-
this.error = e
|
|
623
|
-
this.notify()
|
|
624
|
-
}
|
|
373
|
+
if (options.signal && !(options.signal instanceof AbortSignal)) {
|
|
374
|
+
throw new Error(
|
|
375
|
+
`Invalid signal option. It must be an instance of AbortSignal.`
|
|
376
|
+
)
|
|
625
377
|
}
|
|
626
378
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
379
|
+
if (
|
|
380
|
+
options.offset !== undefined &&
|
|
381
|
+
options.offset !== `-1` &&
|
|
382
|
+
!options.shapeId
|
|
383
|
+
) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
`shapeId is required if this isn't an initial fetch (i.e. offset > -1)`
|
|
386
|
+
)
|
|
631
387
|
}
|
|
388
|
+
return
|
|
632
389
|
}
|