@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/constants.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const SHAPE_ID_HEADER = `electric-shape-id`
|
|
2
|
+
export const CHUNK_LAST_OFFSET_HEADER = `electric-chunk-last-offset`
|
|
3
|
+
export const CHUNK_UP_TO_DATE_HEADER = `electric-chunk-up-to-date`
|
|
4
|
+
export const SHAPE_SCHEMA_HEADER = `electric-schema`
|
|
5
|
+
export const SHAPE_ID_QUERY_PARAM = `shape_id`
|
|
6
|
+
export const OFFSET_QUERY_PARAM = `offset`
|
|
7
|
+
export const WHERE_QUERY_PARAM = `where`
|
|
8
|
+
export const LIVE_QUERY_PARAM = `live`
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export class FetchError extends Error {
|
|
2
|
+
status: number
|
|
3
|
+
text?: string
|
|
4
|
+
json?: object
|
|
5
|
+
headers: Record<string, string>
|
|
6
|
+
|
|
7
|
+
constructor(
|
|
8
|
+
status: number,
|
|
9
|
+
text: string | undefined,
|
|
10
|
+
json: object | undefined,
|
|
11
|
+
headers: Record<string, string>,
|
|
12
|
+
public url: string,
|
|
13
|
+
message?: string
|
|
14
|
+
) {
|
|
15
|
+
super(
|
|
16
|
+
message ||
|
|
17
|
+
`HTTP Error ${status} at ${url}: ${text ?? JSON.stringify(json)}`
|
|
18
|
+
)
|
|
19
|
+
this.name = `FetchError`
|
|
20
|
+
this.status = status
|
|
21
|
+
this.text = text
|
|
22
|
+
this.json = json
|
|
23
|
+
this.headers = headers
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static async fromResponse(
|
|
27
|
+
response: Response,
|
|
28
|
+
url: string
|
|
29
|
+
): Promise<FetchError> {
|
|
30
|
+
const status = response.status
|
|
31
|
+
const headers = Object.fromEntries([...response.headers.entries()])
|
|
32
|
+
let text: string | undefined = undefined
|
|
33
|
+
let json: object | undefined = undefined
|
|
34
|
+
|
|
35
|
+
const contentType = response.headers.get(`content-type`)
|
|
36
|
+
if (contentType && contentType.includes(`application/json`)) {
|
|
37
|
+
json = (await response.json()) as object
|
|
38
|
+
} else {
|
|
39
|
+
text = await response.text()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return new FetchError(status, text, json, headers, url)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class FetchBackoffAbortError extends Error {
|
|
47
|
+
constructor() {
|
|
48
|
+
super(`Fetch with backoff aborted`)
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CHUNK_LAST_OFFSET_HEADER,
|
|
3
|
+
CHUNK_UP_TO_DATE_HEADER,
|
|
4
|
+
LIVE_QUERY_PARAM,
|
|
5
|
+
OFFSET_QUERY_PARAM,
|
|
6
|
+
SHAPE_ID_HEADER,
|
|
7
|
+
SHAPE_ID_QUERY_PARAM,
|
|
8
|
+
} from './constants'
|
|
9
|
+
import { FetchError, FetchBackoffAbortError } from './error'
|
|
10
|
+
|
|
11
|
+
export interface BackoffOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Initial delay before retrying in milliseconds
|
|
14
|
+
*/
|
|
15
|
+
initialDelay: number
|
|
16
|
+
/**
|
|
17
|
+
* Maximum retry delay in milliseconds
|
|
18
|
+
*/
|
|
19
|
+
maxDelay: number
|
|
20
|
+
multiplier: number
|
|
21
|
+
onFailedAttempt?: () => void
|
|
22
|
+
debug?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const BackoffDefaults = {
|
|
26
|
+
initialDelay: 100,
|
|
27
|
+
maxDelay: 10_000,
|
|
28
|
+
multiplier: 1.3,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createFetchWithBackoff(
|
|
32
|
+
fetchClient: typeof fetch,
|
|
33
|
+
backoffOptions: BackoffOptions = BackoffDefaults
|
|
34
|
+
): typeof fetch {
|
|
35
|
+
const {
|
|
36
|
+
initialDelay,
|
|
37
|
+
maxDelay,
|
|
38
|
+
multiplier,
|
|
39
|
+
debug = false,
|
|
40
|
+
onFailedAttempt,
|
|
41
|
+
} = backoffOptions
|
|
42
|
+
return async (...args: Parameters<typeof fetch>): Promise<Response> => {
|
|
43
|
+
const url = args[0]
|
|
44
|
+
const options = args[1]
|
|
45
|
+
|
|
46
|
+
let delay = initialDelay
|
|
47
|
+
let attempt = 0
|
|
48
|
+
|
|
49
|
+
/* eslint-disable no-constant-condition -- we re-fetch the shape log
|
|
50
|
+
* continuously until we get a non-ok response. For recoverable errors,
|
|
51
|
+
* we retry the fetch with exponential backoff. Users can pass in an
|
|
52
|
+
* AbortController to abort the fetching an any point.
|
|
53
|
+
* */
|
|
54
|
+
while (true) {
|
|
55
|
+
/* eslint-enable no-constant-condition */
|
|
56
|
+
try {
|
|
57
|
+
const result = await fetchClient(...args)
|
|
58
|
+
if (result.ok) return result
|
|
59
|
+
else throw await FetchError.fromResponse(result, url.toString())
|
|
60
|
+
} catch (e) {
|
|
61
|
+
onFailedAttempt?.()
|
|
62
|
+
if (options?.signal?.aborted) {
|
|
63
|
+
throw new FetchBackoffAbortError()
|
|
64
|
+
} else if (
|
|
65
|
+
e instanceof FetchError &&
|
|
66
|
+
e.status >= 400 &&
|
|
67
|
+
e.status < 500
|
|
68
|
+
) {
|
|
69
|
+
// Any client errors cannot be backed off on, leave it to the caller to handle.
|
|
70
|
+
throw e
|
|
71
|
+
} else {
|
|
72
|
+
// Exponentially backoff on errors.
|
|
73
|
+
// Wait for the current delay duration
|
|
74
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
75
|
+
|
|
76
|
+
// Increase the delay for the next attempt
|
|
77
|
+
delay = Math.min(delay * multiplier, maxDelay)
|
|
78
|
+
|
|
79
|
+
if (debug) {
|
|
80
|
+
attempt++
|
|
81
|
+
console.log(`Retry attempt #${attempt} after ${delay}ms`)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface ChunkPrefetchOptions {
|
|
90
|
+
maxChunksToPrefetch: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const ChunkPrefetchDefaults = {
|
|
94
|
+
maxChunksToPrefetch: 2,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Creates a fetch client that prefetches subsequent log chunks for
|
|
99
|
+
* consumption by the shape stream without waiting for the chunk bodies
|
|
100
|
+
* themselves to be loaded.
|
|
101
|
+
*
|
|
102
|
+
* @param fetchClient the client to wrap
|
|
103
|
+
* @param prefetchOptions options to configure prefetching
|
|
104
|
+
* @returns wrapped client with prefetch capabilities
|
|
105
|
+
*/
|
|
106
|
+
export function createFetchWithChunkBuffer(
|
|
107
|
+
fetchClient: typeof fetch,
|
|
108
|
+
prefetchOptions: ChunkPrefetchOptions = ChunkPrefetchDefaults
|
|
109
|
+
): typeof fetch {
|
|
110
|
+
const { maxChunksToPrefetch } = prefetchOptions
|
|
111
|
+
|
|
112
|
+
let prefetchQueue: PrefetchQueue
|
|
113
|
+
|
|
114
|
+
const prefetchClient = async (...args: Parameters<typeof fetchClient>) => {
|
|
115
|
+
const url = args[0].toString()
|
|
116
|
+
|
|
117
|
+
// try to consume from the prefetch queue first, and if request is
|
|
118
|
+
// not present abort the prefetch queue as it must no longer be valid
|
|
119
|
+
const prefetchedRequest = prefetchQueue?.consume(...args)
|
|
120
|
+
if (prefetchedRequest) {
|
|
121
|
+
return prefetchedRequest
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
prefetchQueue?.abort()
|
|
125
|
+
|
|
126
|
+
// perform request and fire off prefetch queue if request is eligible
|
|
127
|
+
const response = await fetchClient(...args)
|
|
128
|
+
const nextUrl = getNextChunkUrl(url, response)
|
|
129
|
+
if (nextUrl) {
|
|
130
|
+
prefetchQueue = new PrefetchQueue({
|
|
131
|
+
fetchClient,
|
|
132
|
+
maxPrefetchedRequests: maxChunksToPrefetch,
|
|
133
|
+
url: nextUrl,
|
|
134
|
+
requestInit: args[1],
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return response
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return prefetchClient
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
class PrefetchQueue {
|
|
145
|
+
readonly #fetchClient: typeof fetch
|
|
146
|
+
readonly #maxPrefetchedRequests: number
|
|
147
|
+
readonly #prefetchQueue = new Map<
|
|
148
|
+
string,
|
|
149
|
+
[Promise<Response>, AbortController]
|
|
150
|
+
>()
|
|
151
|
+
#queueHeadUrl: string | void
|
|
152
|
+
#queueTailUrl: string
|
|
153
|
+
|
|
154
|
+
constructor(options: {
|
|
155
|
+
url: Parameters<typeof fetch>[0]
|
|
156
|
+
requestInit: Parameters<typeof fetch>[1]
|
|
157
|
+
maxPrefetchedRequests: number
|
|
158
|
+
fetchClient?: typeof fetch
|
|
159
|
+
}) {
|
|
160
|
+
this.#fetchClient =
|
|
161
|
+
options.fetchClient ??
|
|
162
|
+
((...args: Parameters<typeof fetch>) => fetch(...args))
|
|
163
|
+
this.#maxPrefetchedRequests = options.maxPrefetchedRequests
|
|
164
|
+
this.#queueHeadUrl = options.url.toString()
|
|
165
|
+
this.#queueTailUrl = this.#queueHeadUrl
|
|
166
|
+
this.#prefetch(options.url, options.requestInit)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
abort(): void {
|
|
170
|
+
this.#prefetchQueue.forEach(([_, aborter]) => aborter.abort())
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
consume(...args: Parameters<typeof fetch>): Promise<Response> | void {
|
|
174
|
+
const url = args[0].toString()
|
|
175
|
+
|
|
176
|
+
const request = this.#prefetchQueue.get(url)?.[0]
|
|
177
|
+
// only consume if request is in queue and is the queue "head"
|
|
178
|
+
// if request is in the queue but not the head, the queue is being
|
|
179
|
+
// consumed out of order and should be restarted
|
|
180
|
+
if (!request || url !== this.#queueHeadUrl) return
|
|
181
|
+
this.#prefetchQueue.delete(url)
|
|
182
|
+
|
|
183
|
+
// fire off new prefetch since request has been consumed
|
|
184
|
+
request
|
|
185
|
+
.then((response) => {
|
|
186
|
+
const nextUrl = getNextChunkUrl(url, response)
|
|
187
|
+
this.#queueHeadUrl = nextUrl
|
|
188
|
+
if (!this.#prefetchQueue.has(this.#queueTailUrl)) {
|
|
189
|
+
this.#prefetch(this.#queueTailUrl, args[1])
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
.catch(() => {})
|
|
193
|
+
|
|
194
|
+
return request
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#prefetch(...args: Parameters<typeof fetch>): void {
|
|
198
|
+
const url = args[0].toString()
|
|
199
|
+
|
|
200
|
+
// only prefetch when queue is not full
|
|
201
|
+
if (this.#prefetchQueue.size >= this.#maxPrefetchedRequests) return
|
|
202
|
+
|
|
203
|
+
// initialize aborter per request, to avoid aborting consumed requests that
|
|
204
|
+
// are still streaming their bodies to the consumer
|
|
205
|
+
const aborter = new AbortController()
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const request = this.#fetchClient(url, {
|
|
209
|
+
...(args[1] ?? {}),
|
|
210
|
+
signal: chainAborter(aborter, args[1]?.signal),
|
|
211
|
+
})
|
|
212
|
+
this.#prefetchQueue.set(url, [request, aborter])
|
|
213
|
+
request
|
|
214
|
+
.then((response) => {
|
|
215
|
+
// only keep prefetching if response chain is uninterrupted
|
|
216
|
+
if (!response.ok || aborter.signal.aborted) return
|
|
217
|
+
|
|
218
|
+
const nextUrl = getNextChunkUrl(url, response)
|
|
219
|
+
|
|
220
|
+
// only prefetch when there is a next URL
|
|
221
|
+
if (!nextUrl || nextUrl === url) return
|
|
222
|
+
|
|
223
|
+
this.#queueTailUrl = nextUrl
|
|
224
|
+
return this.#prefetch(nextUrl, args[1])
|
|
225
|
+
})
|
|
226
|
+
.catch(() => {})
|
|
227
|
+
} catch (_) {
|
|
228
|
+
// ignore prefetch errors
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Generate the next chunk's URL if the url and response are valid
|
|
235
|
+
*/
|
|
236
|
+
function getNextChunkUrl(url: string, res: Response): string | void {
|
|
237
|
+
const shapeId = res.headers.get(SHAPE_ID_HEADER)
|
|
238
|
+
const lastOffset = res.headers.get(CHUNK_LAST_OFFSET_HEADER)
|
|
239
|
+
const isUpToDate = res.headers.has(CHUNK_UP_TO_DATE_HEADER)
|
|
240
|
+
|
|
241
|
+
// only prefetch if shape ID and offset for next chunk are available, and
|
|
242
|
+
// response is not already up-to-date
|
|
243
|
+
if (!shapeId || !lastOffset || isUpToDate) return
|
|
244
|
+
|
|
245
|
+
const nextUrl = new URL(url)
|
|
246
|
+
|
|
247
|
+
// don't prefetch live requests, rushing them will only
|
|
248
|
+
// potentially miss more recent data
|
|
249
|
+
if (nextUrl.searchParams.has(LIVE_QUERY_PARAM)) return
|
|
250
|
+
|
|
251
|
+
nextUrl.searchParams.set(SHAPE_ID_QUERY_PARAM, shapeId)
|
|
252
|
+
nextUrl.searchParams.set(OFFSET_QUERY_PARAM, lastOffset)
|
|
253
|
+
return nextUrl.toString()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Chains an abort controller on an optional source signal's
|
|
258
|
+
* aborted state - if the source signal is aborted, the provided abort
|
|
259
|
+
* controller will also abort
|
|
260
|
+
*/
|
|
261
|
+
function chainAborter(
|
|
262
|
+
aborter: AbortController,
|
|
263
|
+
sourceSignal?: AbortSignal
|
|
264
|
+
): AbortSignal {
|
|
265
|
+
if (!sourceSignal) return aborter.signal
|
|
266
|
+
if (sourceSignal.aborted) aborter.abort()
|
|
267
|
+
else
|
|
268
|
+
sourceSignal.addEventListener(`abort`, () => aborter.abort(), {
|
|
269
|
+
once: true,
|
|
270
|
+
})
|
|
271
|
+
return aborter.signal
|
|
272
|
+
}
|
package/src/helpers.ts
CHANGED
|
@@ -45,3 +45,9 @@ export function isControlMessage<T extends Row = Row>(
|
|
|
45
45
|
): message is ControlMessage {
|
|
46
46
|
return !isChangeMessage(message)
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
export function isUpToDateMessage<T extends Row = Row>(
|
|
50
|
+
message: Message<T>
|
|
51
|
+
): message is ControlMessage & { up_to_date: true } {
|
|
52
|
+
return isControlMessage(message) && message.headers.control === `up-to-date`
|
|
53
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
export * from './client'
|
|
2
|
+
export * from './shape'
|
|
2
3
|
export * from './types'
|
|
3
|
-
export
|
|
4
|
+
export { isChangeMessage, isControlMessage } from './helpers'
|
|
5
|
+
export { FetchError } from './error'
|
|
6
|
+
export { type BackoffOptions, BackoffDefaults } from './fetch'
|
package/src/shape.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { Message, Row } from './types'
|
|
2
|
+
import { isChangeMessage, isControlMessage } from './helpers'
|
|
3
|
+
import { FetchError } from './error'
|
|
4
|
+
import { ShapeStreamInterface } from './client'
|
|
5
|
+
|
|
6
|
+
export type ShapeData<T extends Row = Row> = Map<string, T>
|
|
7
|
+
export type ShapeChangedCallback<T extends Row = Row> = (
|
|
8
|
+
value: ShapeData<T>
|
|
9
|
+
) => void
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A Shape is an object that subscribes to a shape log,
|
|
13
|
+
* keeps a materialised shape `.value` in memory and
|
|
14
|
+
* notifies subscribers when the value has changed.
|
|
15
|
+
*
|
|
16
|
+
* It can be used without a framework and as a primitive
|
|
17
|
+
* to simplify developing framework hooks.
|
|
18
|
+
*
|
|
19
|
+
* @constructor
|
|
20
|
+
* @param {ShapeStream<T extends Row>} - the underlying shape stream
|
|
21
|
+
* @example
|
|
22
|
+
* ```
|
|
23
|
+
* const shapeStream = new ShapeStream<{ foo: number }>(url: 'http://localhost:3000/v1/shape/foo'})
|
|
24
|
+
* const shape = new Shape(shapeStream)
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* `value` returns a promise that resolves the Shape data once the Shape has been
|
|
28
|
+
* fully loaded (and when resuming from being offline):
|
|
29
|
+
*
|
|
30
|
+
* const value = await shape.value
|
|
31
|
+
*
|
|
32
|
+
* `valueSync` returns the current data synchronously:
|
|
33
|
+
*
|
|
34
|
+
* const value = shape.valueSync
|
|
35
|
+
*
|
|
36
|
+
* Subscribe to updates. Called whenever the shape updates in Postgres.
|
|
37
|
+
*
|
|
38
|
+
* shape.subscribe(shapeData => {
|
|
39
|
+
* console.log(shapeData)
|
|
40
|
+
* })
|
|
41
|
+
*/
|
|
42
|
+
export class Shape<T extends Row = Row> {
|
|
43
|
+
readonly #stream: ShapeStreamInterface<T>
|
|
44
|
+
|
|
45
|
+
readonly #data: ShapeData<T> = new Map()
|
|
46
|
+
readonly #subscribers = new Map<number, ShapeChangedCallback<T>>()
|
|
47
|
+
|
|
48
|
+
#hasNotifiedSubscribersUpToDate: boolean = false
|
|
49
|
+
#error: FetchError | false = false
|
|
50
|
+
|
|
51
|
+
constructor(stream: ShapeStreamInterface<T>) {
|
|
52
|
+
this.#stream = stream
|
|
53
|
+
this.#stream.subscribe(
|
|
54
|
+
this.#process.bind(this),
|
|
55
|
+
this.#handleError.bind(this)
|
|
56
|
+
)
|
|
57
|
+
const unsubscribe = this.#stream.subscribeOnceToUpToDate(
|
|
58
|
+
() => {
|
|
59
|
+
unsubscribe()
|
|
60
|
+
},
|
|
61
|
+
(e) => {
|
|
62
|
+
this.#handleError(e)
|
|
63
|
+
throw e
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get isUpToDate(): boolean {
|
|
69
|
+
return this.#stream.isUpToDate
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get value(): Promise<ShapeData<T>> {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
if (this.#stream.isUpToDate) {
|
|
75
|
+
resolve(this.valueSync)
|
|
76
|
+
} else {
|
|
77
|
+
const unsubscribe = this.subscribe((shapeData) => {
|
|
78
|
+
unsubscribe()
|
|
79
|
+
if (this.#error) reject(this.#error)
|
|
80
|
+
resolve(shapeData)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get valueSync() {
|
|
87
|
+
return this.#data
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get error() {
|
|
91
|
+
return this.#error
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Unix time at which we last synced. Undefined when `isLoading` is true. */
|
|
95
|
+
lastSyncedAt(): number | undefined {
|
|
96
|
+
return this.#stream.lastSyncedAt()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Time elapsed since last sync (in ms). Infinity if we did not yet sync. */
|
|
100
|
+
lastSynced() {
|
|
101
|
+
return this.#stream.lastSynced()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** True during initial fetch. False afterwise. */
|
|
105
|
+
isLoading() {
|
|
106
|
+
return this.#stream.isLoading()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Indicates if we are connected to the Electric sync service. */
|
|
110
|
+
isConnected(): boolean {
|
|
111
|
+
return this.#stream.isConnected()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
subscribe(callback: ShapeChangedCallback<T>): () => void {
|
|
115
|
+
const subscriptionId = Math.random()
|
|
116
|
+
|
|
117
|
+
this.#subscribers.set(subscriptionId, callback)
|
|
118
|
+
|
|
119
|
+
return () => {
|
|
120
|
+
this.#subscribers.delete(subscriptionId)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
unsubscribeAll(): void {
|
|
125
|
+
this.#subscribers.clear()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
get numSubscribers() {
|
|
129
|
+
return this.#subscribers.size
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#process(messages: Message<T>[]): void {
|
|
133
|
+
let dataMayHaveChanged = false
|
|
134
|
+
let isUpToDate = false
|
|
135
|
+
let newlyUpToDate = false
|
|
136
|
+
|
|
137
|
+
messages.forEach((message) => {
|
|
138
|
+
if (isChangeMessage(message)) {
|
|
139
|
+
dataMayHaveChanged = [`insert`, `update`, `delete`].includes(
|
|
140
|
+
message.headers.operation
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
switch (message.headers.operation) {
|
|
144
|
+
case `insert`:
|
|
145
|
+
this.#data.set(message.key, message.value)
|
|
146
|
+
break
|
|
147
|
+
case `update`:
|
|
148
|
+
this.#data.set(message.key, {
|
|
149
|
+
...this.#data.get(message.key)!,
|
|
150
|
+
...message.value,
|
|
151
|
+
})
|
|
152
|
+
break
|
|
153
|
+
case `delete`:
|
|
154
|
+
this.#data.delete(message.key)
|
|
155
|
+
break
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (isControlMessage(message)) {
|
|
160
|
+
switch (message.headers.control) {
|
|
161
|
+
case `up-to-date`:
|
|
162
|
+
isUpToDate = true
|
|
163
|
+
if (!this.#hasNotifiedSubscribersUpToDate) {
|
|
164
|
+
newlyUpToDate = true
|
|
165
|
+
}
|
|
166
|
+
break
|
|
167
|
+
case `must-refetch`:
|
|
168
|
+
this.#data.clear()
|
|
169
|
+
this.#error = false
|
|
170
|
+
isUpToDate = false
|
|
171
|
+
newlyUpToDate = false
|
|
172
|
+
break
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// Always notify subscribers when the Shape first is up to date.
|
|
178
|
+
// FIXME this would be cleaner with a simple state machine.
|
|
179
|
+
if (newlyUpToDate || (isUpToDate && dataMayHaveChanged)) {
|
|
180
|
+
this.#hasNotifiedSubscribersUpToDate = true
|
|
181
|
+
this.#notify()
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#handleError(e: Error): void {
|
|
186
|
+
if (e instanceof FetchError) {
|
|
187
|
+
this.#error = e
|
|
188
|
+
this.#notify()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#notify(): void {
|
|
193
|
+
this.#subscribers.forEach((callback) => {
|
|
194
|
+
callback(this.valueSync)
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
}
|