@electric-sql/client 0.4.1 → 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.
@@ -0,0 +1,7 @@
1
+ export const SHAPE_ID_HEADER = `x-electric-shape-id`
2
+ export const CHUNK_LAST_OFFSET_HEADER = `x-electric-chunk-last-offset`
3
+ export const SHAPE_SCHEMA_HEADER = `x-electric-schema`
4
+ export const SHAPE_ID_QUERY_PARAM = `shape_id`
5
+ export const OFFSET_QUERY_PARAM = `offset`
6
+ export const WHERE_QUERY_PARAM = `where`
7
+ 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,79 @@
1
+ import { FetchError, FetchBackoffAbortError } from './error'
2
+
3
+ export interface BackoffOptions {
4
+ /**
5
+ * Initial delay before retrying in milliseconds
6
+ */
7
+ initialDelay: number
8
+ /**
9
+ * Maximum retry delay in milliseconds
10
+ */
11
+ maxDelay: number
12
+ multiplier: number
13
+ onFailedAttempt?: () => void
14
+ debug?: boolean
15
+ }
16
+
17
+ export const BackoffDefaults = {
18
+ initialDelay: 100,
19
+ maxDelay: 10_000,
20
+ multiplier: 1.3,
21
+ }
22
+
23
+ export function createFetchWithBackoff(
24
+ fetchClient: typeof fetch,
25
+ backoffOptions: BackoffOptions = BackoffDefaults
26
+ ): typeof fetch {
27
+ const {
28
+ initialDelay,
29
+ maxDelay,
30
+ multiplier,
31
+ debug = false,
32
+ onFailedAttempt,
33
+ } = backoffOptions
34
+ return async (...args: Parameters<typeof fetch>): Promise<Response> => {
35
+ const url = args[0]
36
+ const options = args[1]
37
+
38
+ let delay = initialDelay
39
+ let attempt = 0
40
+
41
+ /* eslint-disable no-constant-condition -- we re-fetch the shape log
42
+ * continuously until we get a non-ok response. For recoverable errors,
43
+ * we retry the fetch with exponential backoff. Users can pass in an
44
+ * AbortController to abort the fetching an any point.
45
+ * */
46
+ while (true) {
47
+ /* eslint-enable no-constant-condition */
48
+ try {
49
+ const result = await fetchClient(...args)
50
+ if (result.ok) return result
51
+ else throw await FetchError.fromResponse(result, url.toString())
52
+ } catch (e) {
53
+ onFailedAttempt?.()
54
+ if (options?.signal?.aborted) {
55
+ throw new FetchBackoffAbortError()
56
+ } else if (
57
+ e instanceof FetchError &&
58
+ e.status >= 400 &&
59
+ e.status < 500
60
+ ) {
61
+ // Any client errors cannot be backed off on, leave it to the caller to handle.
62
+ throw e
63
+ } else {
64
+ // Exponentially backoff on errors.
65
+ // Wait for the current delay duration
66
+ await new Promise((resolve) => setTimeout(resolve, delay))
67
+
68
+ // Increase the delay for the next attempt
69
+ delay = Math.min(delay * multiplier, maxDelay)
70
+
71
+ if (debug) {
72
+ attempt++
73
+ console.log(`Retry attempt #${attempt} after ${delay}ms`)
74
+ }
75
+ }
76
+ }
77
+ }
78
+ }
79
+ }
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/queue.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { MaybePromise } from './types'
2
+
3
+ function isThenable(value: MaybePromise<void>): value is Promise<void> {
4
+ return (
5
+ !!value &&
6
+ typeof value === `object` &&
7
+ `then` in value &&
8
+ typeof value.then === `function`
9
+ )
10
+ }
11
+
12
+ /**
13
+ * Processes messages asynchronously in order.
14
+ */
15
+ export class AsyncProcessingQueue {
16
+ #processingChain: MaybePromise<void> = undefined
17
+
18
+ public process(callback: () => MaybePromise<void>): MaybePromise<void> {
19
+ this.#processingChain = isThenable(this.#processingChain)
20
+ ? this.#processingChain.then(callback)
21
+ : callback()
22
+ return this.#processingChain
23
+ }
24
+
25
+ public async waitForProcessing(): Promise<void> {
26
+ let currentChain: MaybePromise<void>
27
+ do {
28
+ currentChain = this.#processingChain
29
+ await currentChain
30
+ } while (this.#processingChain !== currentChain)
31
+ }
32
+ }
33
+
34
+ export interface MessageProcessorInterface<T> {
35
+ process(messages: T): MaybePromise<void>
36
+ waitForProcessing(): Promise<void>
37
+ }
38
+
39
+ /**
40
+ * Receives messages, puts them on a queue and processes
41
+ * them synchronously or asynchronously by passing to a
42
+ * registered callback function.
43
+ *
44
+ * @constructor
45
+ * @param {(message: T) => MaybePromise<void>} callback function
46
+ */
47
+ export class MessageProcessor<T> implements MessageProcessorInterface<T> {
48
+ readonly #queue = new AsyncProcessingQueue()
49
+ readonly #callback: (messages: T) => MaybePromise<void>
50
+
51
+ constructor(callback: (messages: T) => MaybePromise<void>) {
52
+ this.#callback = callback
53
+ }
54
+
55
+ public process(messages: T): void {
56
+ this.#queue.process(() => this.#callback(messages))
57
+ }
58
+
59
+ public async waitForProcessing(): Promise<void> {
60
+ await this.#queue.waitForProcessing()
61
+ }
62
+ }
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>