@electric-sql/client 0.5.0 → 0.5.1
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 +346 -227
- 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 +344 -225
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +346 -227
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +168 -421
- package/src/constants.ts +7 -0
- package/src/error.ts +50 -0
- package/src/fetch.ts +79 -0
- package/src/helpers.ts +6 -0
- package/src/index.ts +4 -1
- package/src/queue.ts +62 -0
- 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 { MessageProcessor, MessageProcessorInterface } from './queue'
|
|
5
|
+
import { FetchError, FetchBackoffAbortError } from './error'
|
|
6
|
+
import {
|
|
7
|
+
BackoffDefaults,
|
|
8
|
+
BackoffOptions,
|
|
9
|
+
createFetchWithBackoff,
|
|
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,111 @@ 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
|
+
MessageProcessorInterface<Message<T>[]>,
|
|
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
|
-
this
|
|
203
|
-
this.fetchClient =
|
|
145
|
+
this.#fetchClient = createFetchWithBackoff(
|
|
204
146
|
options.fetchClient ??
|
|
205
|
-
|
|
147
|
+
((...args: Parameters<typeof fetch>) => fetch(...args)),
|
|
148
|
+
{
|
|
149
|
+
...(options.backoffOptions ?? BackoffDefaults),
|
|
150
|
+
onFailedAttempt: () => {
|
|
151
|
+
this.#connected = false
|
|
152
|
+
options.backoffOptions?.onFailedAttempt?.()
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
)
|
|
206
156
|
|
|
207
157
|
this.start()
|
|
208
158
|
}
|
|
209
159
|
|
|
160
|
+
get shapeId() {
|
|
161
|
+
return this.#shapeId
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
get isUpToDate() {
|
|
165
|
+
return this.#isUpToDate
|
|
166
|
+
}
|
|
167
|
+
|
|
210
168
|
async start() {
|
|
211
|
-
this
|
|
169
|
+
this.#isUpToDate = false
|
|
212
170
|
|
|
213
171
|
const { url, where, signal } = this.options
|
|
214
172
|
|
|
215
173
|
try {
|
|
216
|
-
while (
|
|
174
|
+
while (
|
|
175
|
+
(!signal?.aborted && !this.#isUpToDate) ||
|
|
176
|
+
this.options.subscribe
|
|
177
|
+
) {
|
|
217
178
|
const fetchUrl = new URL(url)
|
|
218
|
-
if (where) fetchUrl.searchParams.set(
|
|
219
|
-
fetchUrl.searchParams.set(
|
|
179
|
+
if (where) fetchUrl.searchParams.set(WHERE_QUERY_PARAM, where)
|
|
180
|
+
fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, this.#lastOffset)
|
|
220
181
|
|
|
221
|
-
if (this
|
|
222
|
-
fetchUrl.searchParams.set(
|
|
182
|
+
if (this.#isUpToDate) {
|
|
183
|
+
fetchUrl.searchParams.set(LIVE_QUERY_PARAM, `true`)
|
|
223
184
|
}
|
|
224
185
|
|
|
225
|
-
if (this
|
|
186
|
+
if (this.#shapeId) {
|
|
226
187
|
// This should probably be a header for better cache breaking?
|
|
227
|
-
fetchUrl.searchParams.set(
|
|
188
|
+
fetchUrl.searchParams.set(SHAPE_ID_QUERY_PARAM, this.#shapeId!)
|
|
228
189
|
}
|
|
229
190
|
|
|
230
191
|
let response!: Response
|
|
231
|
-
|
|
232
192
|
try {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
else break
|
|
193
|
+
response = await this.#fetchClient(fetchUrl.toString(), { signal })
|
|
194
|
+
this.#connected = true
|
|
236
195
|
} catch (e) {
|
|
196
|
+
if (e instanceof FetchBackoffAbortError) break // interrupted
|
|
237
197
|
if (!(e instanceof FetchError)) throw e // should never happen
|
|
238
198
|
if (e.status == 400) {
|
|
239
199
|
// The request is invalid, most likely because the shape has been deleted.
|
|
240
200
|
// We should start from scratch, this will force the shape to be recreated.
|
|
241
|
-
this
|
|
242
|
-
this
|
|
201
|
+
this.#reset()
|
|
202
|
+
this.#publish(e.json as Message<T>[])
|
|
243
203
|
continue
|
|
244
204
|
} else if (e.status == 409) {
|
|
245
205
|
// Upon receiving a 409, we should start from scratch
|
|
246
206
|
// with the newly provided shape ID
|
|
247
|
-
const newShapeId = e.headers[
|
|
248
|
-
this
|
|
249
|
-
this
|
|
207
|
+
const newShapeId = e.headers[SHAPE_ID_HEADER]
|
|
208
|
+
this.#reset(newShapeId)
|
|
209
|
+
this.#publish(e.json as Message<T>[])
|
|
250
210
|
continue
|
|
251
211
|
} else if (e.status >= 400 && e.status < 500) {
|
|
252
212
|
// Notify subscribers
|
|
253
|
-
this
|
|
254
|
-
this
|
|
213
|
+
this.#sendErrorToUpToDateSubscribers(e)
|
|
214
|
+
this.#sendErrorToSubscribers(e)
|
|
255
215
|
|
|
256
216
|
// 400 errors are not actionable without additional user input, so we're throwing them.
|
|
257
217
|
throw e
|
|
@@ -259,108 +219,99 @@ export class ShapeStream<T extends Row = Row> {
|
|
|
259
219
|
}
|
|
260
220
|
|
|
261
221
|
const { headers, status } = response
|
|
262
|
-
const shapeId = headers.get(
|
|
222
|
+
const shapeId = headers.get(SHAPE_ID_HEADER)
|
|
263
223
|
if (shapeId) {
|
|
264
|
-
this
|
|
224
|
+
this.#shapeId = shapeId
|
|
265
225
|
}
|
|
266
226
|
|
|
267
|
-
const lastOffset = headers.get(
|
|
227
|
+
const lastOffset = headers.get(CHUNK_LAST_OFFSET_HEADER)
|
|
268
228
|
if (lastOffset) {
|
|
269
|
-
this
|
|
229
|
+
this.#lastOffset = lastOffset as Offset
|
|
270
230
|
}
|
|
271
231
|
|
|
272
232
|
const getSchema = (): Schema => {
|
|
273
|
-
const schemaHeader = headers.get(
|
|
233
|
+
const schemaHeader = headers.get(SHAPE_SCHEMA_HEADER)
|
|
274
234
|
return schemaHeader ? JSON.parse(schemaHeader) : {}
|
|
275
235
|
}
|
|
276
|
-
this
|
|
236
|
+
this.#schema = this.#schema ?? getSchema()
|
|
277
237
|
|
|
278
238
|
const messages = status === 204 ? `[]` : await response.text()
|
|
279
239
|
|
|
280
240
|
if (status === 204) {
|
|
281
241
|
// There's no content so we are live and up to date
|
|
282
|
-
this
|
|
242
|
+
this.#lastSyncedAt = Date.now()
|
|
283
243
|
}
|
|
284
244
|
|
|
285
|
-
const batch = this
|
|
245
|
+
const batch = this.#messageParser.parse(messages, this.#schema)
|
|
286
246
|
|
|
287
247
|
// Update isUpToDate
|
|
288
248
|
if (batch.length > 0) {
|
|
289
249
|
const lastMessage = batch[batch.length - 1]
|
|
290
|
-
if (
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (!this.isUpToDate) {
|
|
296
|
-
this.isUpToDate = true
|
|
297
|
-
this.notifyUpToDateSubscribers()
|
|
250
|
+
if (isUpToDateMessage(lastMessage)) {
|
|
251
|
+
this.#lastSyncedAt = Date.now()
|
|
252
|
+
if (!this.#isUpToDate) {
|
|
253
|
+
this.#isUpToDate = true
|
|
254
|
+
this.#notifyUpToDateSubscribers()
|
|
298
255
|
}
|
|
299
256
|
}
|
|
300
257
|
|
|
301
|
-
this
|
|
258
|
+
this.#publish(batch)
|
|
302
259
|
}
|
|
303
260
|
}
|
|
304
261
|
} finally {
|
|
305
|
-
this
|
|
262
|
+
this.#connected = false
|
|
306
263
|
}
|
|
307
264
|
}
|
|
308
265
|
|
|
309
266
|
subscribe(
|
|
310
|
-
callback: (messages: Message<T>[]) =>
|
|
267
|
+
callback: (messages: Message<T>[]) => MaybePromise<void>,
|
|
311
268
|
onError?: (error: FetchError | Error) => void
|
|
312
269
|
) {
|
|
313
270
|
const subscriptionId = Math.random()
|
|
314
271
|
const subscriber = new MessageProcessor(callback)
|
|
315
272
|
|
|
316
|
-
this
|
|
273
|
+
this.#subscribers.set(subscriptionId, [subscriber, onError])
|
|
317
274
|
|
|
318
275
|
return () => {
|
|
319
|
-
this
|
|
276
|
+
this.#subscribers.delete(subscriptionId)
|
|
320
277
|
}
|
|
321
278
|
}
|
|
322
279
|
|
|
323
280
|
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
|
-
})
|
|
281
|
+
this.#subscribers.clear()
|
|
337
282
|
}
|
|
338
283
|
|
|
339
284
|
subscribeOnceToUpToDate(
|
|
340
|
-
callback: () =>
|
|
285
|
+
callback: () => MaybePromise<void>,
|
|
341
286
|
error: (err: FetchError | Error) => void
|
|
342
287
|
) {
|
|
343
288
|
const subscriptionId = Math.random()
|
|
344
289
|
|
|
345
|
-
this
|
|
290
|
+
this.#upToDateSubscribers.set(subscriptionId, [callback, error])
|
|
346
291
|
|
|
347
292
|
return () => {
|
|
348
|
-
this
|
|
293
|
+
this.#upToDateSubscribers.delete(subscriptionId)
|
|
349
294
|
}
|
|
350
295
|
}
|
|
351
296
|
|
|
352
297
|
unsubscribeAllUpToDateSubscribers(): void {
|
|
353
|
-
this
|
|
298
|
+
this.#upToDateSubscribers.clear()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Unix time at which we last synced. Undefined when `isLoading` is true. */
|
|
302
|
+
lastSyncedAt(): number | undefined {
|
|
303
|
+
return this.#lastSyncedAt
|
|
354
304
|
}
|
|
355
305
|
|
|
356
306
|
/** Time elapsed since last sync (in ms). Infinity if we did not yet sync. */
|
|
357
307
|
lastSynced(): number {
|
|
358
|
-
if (this
|
|
359
|
-
return Date.now() - this
|
|
308
|
+
if (this.#lastSyncedAt === undefined) return Infinity
|
|
309
|
+
return Date.now() - this.#lastSyncedAt
|
|
360
310
|
}
|
|
361
311
|
|
|
312
|
+
/** Indicates if we are connected to the Electric sync service. */
|
|
362
313
|
isConnected(): boolean {
|
|
363
|
-
return this
|
|
314
|
+
return this.#connected
|
|
364
315
|
}
|
|
365
316
|
|
|
366
317
|
/** True during initial fetch. False afterwise. */
|
|
@@ -368,15 +319,26 @@ export class ShapeStream<T extends Row = Row> {
|
|
|
368
319
|
return !this.isUpToDate
|
|
369
320
|
}
|
|
370
321
|
|
|
371
|
-
|
|
372
|
-
this.
|
|
322
|
+
#publish(messages: Message<T>[]) {
|
|
323
|
+
this.#subscribers.forEach(([subscriber, _]) => {
|
|
324
|
+
subscriber.process(messages)
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
#sendErrorToSubscribers(error: Error) {
|
|
329
|
+
this.#subscribers.forEach(([_, errorFn]) => {
|
|
330
|
+
errorFn?.(error)
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#notifyUpToDateSubscribers() {
|
|
335
|
+
this.#upToDateSubscribers.forEach(([callback]) => {
|
|
373
336
|
callback()
|
|
374
337
|
})
|
|
375
338
|
}
|
|
376
339
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
this.upToDateSubscribers.forEach(([_, errorCallback]) =>
|
|
340
|
+
#sendErrorToUpToDateSubscribers(error: FetchError | Error) {
|
|
341
|
+
this.#upToDateSubscribers.forEach(([_, errorCallback]) =>
|
|
380
342
|
errorCallback(error)
|
|
381
343
|
)
|
|
382
344
|
}
|
|
@@ -385,248 +347,33 @@ export class ShapeStream<T extends Row = Row> {
|
|
|
385
347
|
* Resets the state of the stream, optionally with a provided
|
|
386
348
|
* shape ID
|
|
387
349
|
*/
|
|
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
|
-
}
|
|
350
|
+
#reset(shapeId?: string) {
|
|
351
|
+
this.#lastOffset = `-1`
|
|
352
|
+
this.#shapeId = shapeId
|
|
353
|
+
this.#isUpToDate = false
|
|
354
|
+
this.#connected = false
|
|
355
|
+
this.#schema = undefined
|
|
458
356
|
}
|
|
459
357
|
}
|
|
460
358
|
|
|
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
|
-
)
|
|
359
|
+
function validateOptions(options: Partial<ShapeStreamOptions>): void {
|
|
360
|
+
if (!options.url) {
|
|
361
|
+
throw new Error(`Invalid shape option. It must provide the url`)
|
|
512
362
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
unsubscribeAll(): void {
|
|
560
|
-
this.subscribers.clear()
|
|
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
|
-
}
|
|
363
|
+
if (options.signal && !(options.signal instanceof AbortSignal)) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`Invalid signal option. It must be an instance of AbortSignal.`
|
|
366
|
+
)
|
|
625
367
|
}
|
|
626
368
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
369
|
+
if (
|
|
370
|
+
options.offset !== undefined &&
|
|
371
|
+
options.offset !== `-1` &&
|
|
372
|
+
!options.shapeId
|
|
373
|
+
) {
|
|
374
|
+
throw new Error(
|
|
375
|
+
`shapeId is required if this isn't an initial fetch (i.e. offset > -1)`
|
|
376
|
+
)
|
|
631
377
|
}
|
|
378
|
+
return
|
|
632
379
|
}
|