@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.
@@ -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 * from './helpers'
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
+ }
package/src/types.ts CHANGED
@@ -108,3 +108,5 @@ export type TypedMessages<T extends Row = Row> = {
108
108
  messages: Array<Message<T>>
109
109
  schema: ColumnInfo
110
110
  }
111
+
112
+ export type MaybePromise<T> = T | Promise<T>