@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.
- package/dist/cjs/index.cjs +350 -223
- 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 +59 -48
- package/dist/index.legacy-esm.js +348 -221
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +350 -223
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +173 -416
- 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/constants.ts
ADDED
|
@@ -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
|
|
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
|
+
}
|