@electric-sql/client 0.5.1 → 0.6.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/README.md +2 -0
- package/dist/cjs/index.cjs +157 -77
- 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 +35 -23
- package/dist/index.legacy-esm.js +157 -75
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +157 -77
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +61 -34
- package/src/constants.ts +4 -3
- package/src/fetch.ts +193 -0
- package/src/helpers.ts +3 -3
- package/src/parser.ts +29 -16
- package/src/shape.ts +3 -3
- package/src/types.ts +17 -7
- package/src/queue.ts +0 -62
package/src/client.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Message,
|
|
3
|
+
Offset,
|
|
4
|
+
Schema,
|
|
5
|
+
Row,
|
|
6
|
+
MaybePromise,
|
|
7
|
+
GetExtensions,
|
|
8
|
+
} from './types'
|
|
2
9
|
import { MessageParser, Parser } from './parser'
|
|
3
10
|
import { isUpToDateMessage } from './helpers'
|
|
4
|
-
import { MessageProcessor, MessageProcessorInterface } from './queue'
|
|
5
11
|
import { FetchError, FetchBackoffAbortError } from './error'
|
|
6
12
|
import {
|
|
7
13
|
BackoffDefaults,
|
|
8
14
|
BackoffOptions,
|
|
9
15
|
createFetchWithBackoff,
|
|
16
|
+
createFetchWithChunkBuffer,
|
|
10
17
|
} from './fetch'
|
|
11
18
|
import {
|
|
12
19
|
CHUNK_LAST_OFFSET_HEADER,
|
|
@@ -21,7 +28,7 @@ import {
|
|
|
21
28
|
/**
|
|
22
29
|
* Options for constructing a ShapeStream.
|
|
23
30
|
*/
|
|
24
|
-
export interface ShapeStreamOptions {
|
|
31
|
+
export interface ShapeStreamOptions<T = never> {
|
|
25
32
|
/**
|
|
26
33
|
* The full URL to where the Shape is hosted. This can either be the Electric server
|
|
27
34
|
* directly or a proxy. E.g. for a local Electric instance, you might set `http://localhost:3000/v1/shape/foo`
|
|
@@ -46,6 +53,13 @@ export interface ShapeStreamOptions {
|
|
|
46
53
|
*/
|
|
47
54
|
shapeId?: string
|
|
48
55
|
backoffOptions?: BackoffOptions
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* HTTP headers to attach to requests made by the client.
|
|
59
|
+
* Can be used for adding authentication headers.
|
|
60
|
+
*/
|
|
61
|
+
headers?: Record<string, string>
|
|
62
|
+
|
|
49
63
|
/**
|
|
50
64
|
* Automatically fetch updates to the Shape. If you just want to sync the current
|
|
51
65
|
* shape and stop, pass false.
|
|
@@ -53,10 +67,10 @@ export interface ShapeStreamOptions {
|
|
|
53
67
|
subscribe?: boolean
|
|
54
68
|
signal?: AbortSignal
|
|
55
69
|
fetchClient?: typeof fetch
|
|
56
|
-
parser?: Parser
|
|
70
|
+
parser?: Parser<T>
|
|
57
71
|
}
|
|
58
72
|
|
|
59
|
-
export interface ShapeStreamInterface<T extends Row = Row> {
|
|
73
|
+
export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
|
|
60
74
|
subscribe(
|
|
61
75
|
callback: (messages: Message<T>[]) => MaybePromise<void>,
|
|
62
76
|
onError?: (error: FetchError | Error) => void
|
|
@@ -108,10 +122,10 @@ export interface ShapeStreamInterface<T extends Row = Row> {
|
|
|
108
122
|
* ```
|
|
109
123
|
*/
|
|
110
124
|
|
|
111
|
-
export class ShapeStream<T extends Row = Row>
|
|
125
|
+
export class ShapeStream<T extends Row<unknown> = Row>
|
|
112
126
|
implements ShapeStreamInterface<T>
|
|
113
127
|
{
|
|
114
|
-
readonly options: ShapeStreamOptions
|
|
128
|
+
readonly options: ShapeStreamOptions<GetExtensions<T>>
|
|
115
129
|
|
|
116
130
|
readonly #fetchClient: typeof fetch
|
|
117
131
|
readonly #messageParser: MessageParser<T>
|
|
@@ -119,7 +133,7 @@ export class ShapeStream<T extends Row = Row>
|
|
|
119
133
|
readonly #subscribers = new Map<
|
|
120
134
|
number,
|
|
121
135
|
[
|
|
122
|
-
|
|
136
|
+
(messages: Message<T>[]) => MaybePromise<void>,
|
|
123
137
|
((error: Error) => void) | undefined,
|
|
124
138
|
]
|
|
125
139
|
>()
|
|
@@ -135,24 +149,26 @@ export class ShapeStream<T extends Row = Row>
|
|
|
135
149
|
#shapeId?: string
|
|
136
150
|
#schema?: Schema
|
|
137
151
|
|
|
138
|
-
constructor(options: ShapeStreamOptions) {
|
|
152
|
+
constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
|
|
139
153
|
validateOptions(options)
|
|
140
154
|
this.options = { subscribe: true, ...options }
|
|
141
155
|
this.#lastOffset = this.options.offset ?? `-1`
|
|
142
156
|
this.#shapeId = this.options.shapeId
|
|
143
157
|
this.#messageParser = new MessageParser<T>(options.parser)
|
|
144
158
|
|
|
145
|
-
|
|
159
|
+
const baseFetchClient =
|
|
146
160
|
options.fetchClient ??
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
)
|
|
161
|
+
((...args: Parameters<typeof fetch>) => fetch(...args))
|
|
162
|
+
|
|
163
|
+
const fetchWithBackoffClient = createFetchWithBackoff(baseFetchClient, {
|
|
164
|
+
...(options.backoffOptions ?? BackoffDefaults),
|
|
165
|
+
onFailedAttempt: () => {
|
|
166
|
+
this.#connected = false
|
|
167
|
+
options.backoffOptions?.onFailedAttempt?.()
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
this.#fetchClient = createFetchWithChunkBuffer(fetchWithBackoffClient)
|
|
156
172
|
|
|
157
173
|
this.start()
|
|
158
174
|
}
|
|
@@ -190,7 +206,10 @@ export class ShapeStream<T extends Row = Row>
|
|
|
190
206
|
|
|
191
207
|
let response!: Response
|
|
192
208
|
try {
|
|
193
|
-
response = await this.#fetchClient(fetchUrl.toString(), {
|
|
209
|
+
response = await this.#fetchClient(fetchUrl.toString(), {
|
|
210
|
+
signal,
|
|
211
|
+
headers: this.options.headers,
|
|
212
|
+
})
|
|
194
213
|
this.#connected = true
|
|
195
214
|
} catch (e) {
|
|
196
215
|
if (e instanceof FetchBackoffAbortError) break // interrupted
|
|
@@ -199,14 +218,14 @@ export class ShapeStream<T extends Row = Row>
|
|
|
199
218
|
// The request is invalid, most likely because the shape has been deleted.
|
|
200
219
|
// We should start from scratch, this will force the shape to be recreated.
|
|
201
220
|
this.#reset()
|
|
202
|
-
this.#publish(e.json as Message<T>[])
|
|
221
|
+
await this.#publish(e.json as Message<T>[])
|
|
203
222
|
continue
|
|
204
223
|
} else if (e.status == 409) {
|
|
205
224
|
// Upon receiving a 409, we should start from scratch
|
|
206
225
|
// with the newly provided shape ID
|
|
207
226
|
const newShapeId = e.headers[SHAPE_ID_HEADER]
|
|
208
227
|
this.#reset(newShapeId)
|
|
209
|
-
this.#publish(e.json as Message<T>[])
|
|
228
|
+
await this.#publish(e.json as Message<T>[])
|
|
210
229
|
continue
|
|
211
230
|
} else if (e.status >= 400 && e.status < 500) {
|
|
212
231
|
// Notify subscribers
|
|
@@ -246,16 +265,17 @@ export class ShapeStream<T extends Row = Row>
|
|
|
246
265
|
|
|
247
266
|
// Update isUpToDate
|
|
248
267
|
if (batch.length > 0) {
|
|
268
|
+
const prevUpToDate = this.#isUpToDate
|
|
249
269
|
const lastMessage = batch[batch.length - 1]
|
|
250
270
|
if (isUpToDateMessage(lastMessage)) {
|
|
251
271
|
this.#lastSyncedAt = Date.now()
|
|
252
|
-
|
|
253
|
-
this.#isUpToDate = true
|
|
254
|
-
this.#notifyUpToDateSubscribers()
|
|
255
|
-
}
|
|
272
|
+
this.#isUpToDate = true
|
|
256
273
|
}
|
|
257
274
|
|
|
258
|
-
this.#publish(batch)
|
|
275
|
+
await this.#publish(batch)
|
|
276
|
+
if (!prevUpToDate && this.#isUpToDate) {
|
|
277
|
+
this.#notifyUpToDateSubscribers()
|
|
278
|
+
}
|
|
259
279
|
}
|
|
260
280
|
}
|
|
261
281
|
} finally {
|
|
@@ -268,9 +288,8 @@ export class ShapeStream<T extends Row = Row>
|
|
|
268
288
|
onError?: (error: FetchError | Error) => void
|
|
269
289
|
) {
|
|
270
290
|
const subscriptionId = Math.random()
|
|
271
|
-
const subscriber = new MessageProcessor(callback)
|
|
272
291
|
|
|
273
|
-
this.#subscribers.set(subscriptionId, [
|
|
292
|
+
this.#subscribers.set(subscriptionId, [callback, onError])
|
|
274
293
|
|
|
275
294
|
return () => {
|
|
276
295
|
this.#subscribers.delete(subscriptionId)
|
|
@@ -319,10 +338,18 @@ export class ShapeStream<T extends Row = Row>
|
|
|
319
338
|
return !this.isUpToDate
|
|
320
339
|
}
|
|
321
340
|
|
|
322
|
-
#publish(messages: Message<T>[]) {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
341
|
+
async #publish(messages: Message<T>[]): Promise<void> {
|
|
342
|
+
await Promise.all(
|
|
343
|
+
Array.from(this.#subscribers.values()).map(async ([callback, __]) => {
|
|
344
|
+
try {
|
|
345
|
+
await callback(messages)
|
|
346
|
+
} catch (err) {
|
|
347
|
+
queueMicrotask(() => {
|
|
348
|
+
throw err
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
)
|
|
326
353
|
}
|
|
327
354
|
|
|
328
355
|
#sendErrorToSubscribers(error: Error) {
|
|
@@ -356,7 +383,7 @@ export class ShapeStream<T extends Row = Row>
|
|
|
356
383
|
}
|
|
357
384
|
}
|
|
358
385
|
|
|
359
|
-
function validateOptions(options: Partial<ShapeStreamOptions
|
|
386
|
+
function validateOptions<T>(options: Partial<ShapeStreamOptions<T>>): void {
|
|
360
387
|
if (!options.url) {
|
|
361
388
|
throw new Error(`Invalid shape option. It must provide the url`)
|
|
362
389
|
}
|
package/src/constants.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
export const SHAPE_ID_HEADER = `
|
|
2
|
-
export const CHUNK_LAST_OFFSET_HEADER = `
|
|
3
|
-
export const
|
|
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`
|
|
4
5
|
export const SHAPE_ID_QUERY_PARAM = `shape_id`
|
|
5
6
|
export const OFFSET_QUERY_PARAM = `offset`
|
|
6
7
|
export const WHERE_QUERY_PARAM = `where`
|
package/src/fetch.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
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'
|
|
1
9
|
import { FetchError, FetchBackoffAbortError } from './error'
|
|
2
10
|
|
|
3
11
|
export interface BackoffOptions {
|
|
@@ -77,3 +85,188 @@ export function createFetchWithBackoff(
|
|
|
77
85
|
}
|
|
78
86
|
}
|
|
79
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
|
@@ -17,7 +17,7 @@ import { ChangeMessage, ControlMessage, Message, Row } from './types'
|
|
|
17
17
|
* }
|
|
18
18
|
* ```
|
|
19
19
|
*/
|
|
20
|
-
export function isChangeMessage<T extends Row = Row>(
|
|
20
|
+
export function isChangeMessage<T extends Row<unknown> = Row>(
|
|
21
21
|
message: Message<T>
|
|
22
22
|
): message is ChangeMessage<T> {
|
|
23
23
|
return `key` in message
|
|
@@ -40,13 +40,13 @@ export function isChangeMessage<T extends Row = Row>(
|
|
|
40
40
|
* }
|
|
41
41
|
* ```
|
|
42
42
|
*/
|
|
43
|
-
export function isControlMessage<T extends Row = Row>(
|
|
43
|
+
export function isControlMessage<T extends Row<unknown> = Row>(
|
|
44
44
|
message: Message<T>
|
|
45
45
|
): message is ControlMessage {
|
|
46
46
|
return !isChangeMessage(message)
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
export function isUpToDateMessage<T extends Row = Row>(
|
|
49
|
+
export function isUpToDateMessage<T extends Row<unknown> = Row>(
|
|
50
50
|
message: Message<T>
|
|
51
51
|
): message is ControlMessage & { up_to_date: true } {
|
|
52
52
|
return isControlMessage(message) && message.headers.control === `up-to-date`
|
package/src/parser.ts
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
|
-
import { ColumnInfo, Message, Row, Schema, Value } from './types'
|
|
1
|
+
import { ColumnInfo, GetExtensions, Message, Row, Schema, Value } from './types'
|
|
2
2
|
|
|
3
3
|
type NullToken = null | `NULL`
|
|
4
4
|
type Token = Exclude<string, NullToken>
|
|
5
5
|
type NullableToken = Token | NullToken
|
|
6
|
-
export type ParseFunction = (
|
|
6
|
+
export type ParseFunction<Extensions = never> = (
|
|
7
7
|
value: Token,
|
|
8
8
|
additionalInfo?: Omit<ColumnInfo, `type` | `dims`>
|
|
9
|
-
) => Value
|
|
10
|
-
type NullableParseFunction = (
|
|
9
|
+
) => Value<Extensions>
|
|
10
|
+
type NullableParseFunction<Extensions = never> = (
|
|
11
11
|
value: NullableToken,
|
|
12
12
|
additionalInfo?: Omit<ColumnInfo, `type` | `dims`>
|
|
13
|
-
) => Value
|
|
14
|
-
|
|
13
|
+
) => Value<Extensions>
|
|
14
|
+
/**
|
|
15
|
+
* @typeParam Extensions - Additional types that can be parsed by this parser beyond the standard SQL types.
|
|
16
|
+
* Defaults to no additional types.
|
|
17
|
+
*/
|
|
18
|
+
export type Parser<Extensions = never> = {
|
|
19
|
+
[key: string]: ParseFunction<Extensions>
|
|
20
|
+
}
|
|
15
21
|
|
|
16
22
|
const parseNumber = (value: string) => Number(value)
|
|
17
23
|
const parseBool = (value: string) => value === `true` || value === `t`
|
|
@@ -31,7 +37,10 @@ export const defaultParser: Parser = {
|
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
// Taken from: https://github.com/electric-sql/pglite/blob/main/packages/pglite/src/types.ts#L233-L279
|
|
34
|
-
export function pgArrayParser(
|
|
40
|
+
export function pgArrayParser<Extensions>(
|
|
41
|
+
value: Token,
|
|
42
|
+
parser?: ParseFunction<Extensions>
|
|
43
|
+
): Value<Extensions> {
|
|
35
44
|
let i = 0
|
|
36
45
|
let char = null
|
|
37
46
|
let str = ``
|
|
@@ -39,7 +48,7 @@ export function pgArrayParser(value: Token, parser?: ParseFunction): Value {
|
|
|
39
48
|
let last = 0
|
|
40
49
|
let p: string | undefined = undefined
|
|
41
50
|
|
|
42
|
-
function loop(x: string): Value
|
|
51
|
+
function loop(x: string): Array<Value<Extensions>> {
|
|
43
52
|
const xs = []
|
|
44
53
|
for (; i < x.length; i++) {
|
|
45
54
|
char = x[i]
|
|
@@ -79,9 +88,9 @@ export function pgArrayParser(value: Token, parser?: ParseFunction): Value {
|
|
|
79
88
|
return loop(value)[0]
|
|
80
89
|
}
|
|
81
90
|
|
|
82
|
-
export class MessageParser<T extends Row
|
|
83
|
-
private parser: Parser
|
|
84
|
-
constructor(parser?: Parser) {
|
|
91
|
+
export class MessageParser<T extends Row<unknown>> {
|
|
92
|
+
private parser: Parser<GetExtensions<T>>
|
|
93
|
+
constructor(parser?: Parser<GetExtensions<T>>) {
|
|
85
94
|
// Merge the provided parser with the default parser
|
|
86
95
|
// to use the provided parser whenever defined
|
|
87
96
|
// and otherwise fall back to the default parser
|
|
@@ -96,7 +105,7 @@ export class MessageParser<T extends Row> {
|
|
|
96
105
|
// But `typeof null === 'object'` so we need to make an explicit check.
|
|
97
106
|
if (key === `value` && typeof value === `object` && value !== null) {
|
|
98
107
|
// Parse the row values
|
|
99
|
-
const row = value as Record<string, Value
|
|
108
|
+
const row = value as Record<string, Value<GetExtensions<T>>>
|
|
100
109
|
Object.keys(row).forEach((key) => {
|
|
101
110
|
row[key] = this.parseRow(key, row[key] as NullableToken, schema)
|
|
102
111
|
})
|
|
@@ -106,7 +115,11 @@ export class MessageParser<T extends Row> {
|
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
// Parses the message values using the provided parser based on the schema information
|
|
109
|
-
private parseRow(
|
|
118
|
+
private parseRow(
|
|
119
|
+
key: string,
|
|
120
|
+
value: NullableToken,
|
|
121
|
+
schema: Schema
|
|
122
|
+
): Value<GetExtensions<T>> {
|
|
110
123
|
const columnInfo = schema[key]
|
|
111
124
|
if (!columnInfo) {
|
|
112
125
|
// We don't have information about the value
|
|
@@ -137,11 +150,11 @@ export class MessageParser<T extends Row> {
|
|
|
137
150
|
}
|
|
138
151
|
}
|
|
139
152
|
|
|
140
|
-
function makeNullableParser(
|
|
141
|
-
parser: ParseFunction
|
|
153
|
+
function makeNullableParser<Extensions>(
|
|
154
|
+
parser: ParseFunction<Extensions>,
|
|
142
155
|
columnInfo: ColumnInfo,
|
|
143
156
|
columnName?: string
|
|
144
|
-
): NullableParseFunction {
|
|
157
|
+
): NullableParseFunction<Extensions> {
|
|
145
158
|
const isNullable = !(columnInfo.not_null ?? false)
|
|
146
159
|
// The sync service contains `null` value for a column whose value is NULL
|
|
147
160
|
// but if the column value is an array that contains a NULL value
|
package/src/shape.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { isChangeMessage, isControlMessage } from './helpers'
|
|
|
3
3
|
import { FetchError } from './error'
|
|
4
4
|
import { ShapeStreamInterface } from './client'
|
|
5
5
|
|
|
6
|
-
export type ShapeData<T extends Row = Row> = Map<string, T>
|
|
7
|
-
export type ShapeChangedCallback<T extends Row = Row> = (
|
|
6
|
+
export type ShapeData<T extends Row<unknown> = Row> = Map<string, T>
|
|
7
|
+
export type ShapeChangedCallback<T extends Row<unknown> = Row> = (
|
|
8
8
|
value: ShapeData<T>
|
|
9
9
|
) => void
|
|
10
10
|
|
|
@@ -39,7 +39,7 @@ export type ShapeChangedCallback<T extends Row = Row> = (
|
|
|
39
39
|
* console.log(shapeData)
|
|
40
40
|
* })
|
|
41
41
|
*/
|
|
42
|
-
export class Shape<T extends Row = Row> {
|
|
42
|
+
export class Shape<T extends Row<unknown> = Row> {
|
|
43
43
|
readonly #stream: ShapeStreamInterface<T>
|
|
44
44
|
|
|
45
45
|
readonly #data: ShapeData<T> = new Map()
|
package/src/types.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Default types for SQL but can be extended with additional types when using a custom parser.
|
|
3
|
+
* @typeParam Extensions - Additional value types.
|
|
4
|
+
*/
|
|
5
|
+
export type Value<Extensions = never> =
|
|
2
6
|
| string
|
|
3
7
|
| number
|
|
4
8
|
| boolean
|
|
5
9
|
| bigint
|
|
6
10
|
| null
|
|
7
|
-
|
|
|
8
|
-
|
|
|
11
|
+
| Extensions
|
|
12
|
+
| Value<Extensions>[]
|
|
13
|
+
| { [key: string]: Value<Extensions> }
|
|
14
|
+
|
|
15
|
+
export type Row<Extensions = never> = Record<string, Value<Extensions>>
|
|
9
16
|
|
|
10
|
-
export type Row =
|
|
17
|
+
export type GetExtensions<T extends Row<unknown>> =
|
|
18
|
+
T extends Row<infer Extensions> ? Extensions : never
|
|
11
19
|
|
|
12
20
|
export type Offset = `-1` | `${number}_${number}`
|
|
13
21
|
|
|
@@ -19,7 +27,7 @@ export type ControlMessage = {
|
|
|
19
27
|
headers: Header & { control: `up-to-date` | `must-refetch` }
|
|
20
28
|
}
|
|
21
29
|
|
|
22
|
-
export type ChangeMessage<T extends Row = Row> = {
|
|
30
|
+
export type ChangeMessage<T extends Row<unknown> = Row> = {
|
|
23
31
|
key: string
|
|
24
32
|
value: T
|
|
25
33
|
headers: Header & { operation: `insert` | `update` | `delete` }
|
|
@@ -27,7 +35,9 @@ export type ChangeMessage<T extends Row = Row> = {
|
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
// Define the type for a record
|
|
30
|
-
export type Message<T extends Row = Row> =
|
|
38
|
+
export type Message<T extends Row<unknown> = Row> =
|
|
39
|
+
| ControlMessage
|
|
40
|
+
| ChangeMessage<T>
|
|
31
41
|
|
|
32
42
|
/**
|
|
33
43
|
* Common properties for all columns.
|
|
@@ -104,7 +114,7 @@ export type ColumnInfo =
|
|
|
104
114
|
|
|
105
115
|
export type Schema = { [key: string]: ColumnInfo }
|
|
106
116
|
|
|
107
|
-
export type TypedMessages<T extends Row = Row> = {
|
|
117
|
+
export type TypedMessages<T extends Row<unknown> = Row> = {
|
|
108
118
|
messages: Array<Message<T>>
|
|
109
119
|
schema: ColumnInfo
|
|
110
120
|
}
|
package/src/queue.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
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
|
-
}
|