@electric-sql/client 0.2.2

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/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@electric-sql/client",
3
+ "version": "0.2.2",
4
+ "description": "Postgres everywhere - your data, in sync, wherever you need it.",
5
+ "type": "module",
6
+ "main": "dist/cjs/index.cjs",
7
+ "module": "dist/index.legacy-esm.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ "./package.json": "./package.json",
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.mjs",
14
+ "default": "./dist/cjs/index.cjs"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src"
20
+ ],
21
+ "sideEffects": false,
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/electric-sql/electric.git"
25
+ },
26
+ "author": "ElectricSQL team and contributors.",
27
+ "license": "Apache-2",
28
+ "bugs": {
29
+ "url": "https://github.com/electric-sql/electric/issues"
30
+ },
31
+ "homepage": "https://next.electric-sql.com",
32
+ "dependencies": {},
33
+ "devDependencies": {
34
+ "@types/pg": "^8.11.6",
35
+ "@types/uuid": "^10.0.0",
36
+ "@typescript-eslint/eslint-plugin": "^7.14.1",
37
+ "@typescript-eslint/parser": "^7.14.1",
38
+ "cache-control-parser": "^2.0.6",
39
+ "concurrently": "^8.2.2",
40
+ "eslint": "^8.57.0",
41
+ "eslint-config-prettier": "^9.1.0",
42
+ "eslint-plugin-prettier": "^5.1.3",
43
+ "glob": "^10.3.10",
44
+ "pg": "^8.12.0",
45
+ "prettier": "^3.3.2",
46
+ "shx": "^0.3.4",
47
+ "tsup": "^8.0.1",
48
+ "typescript": "^5.5.2",
49
+ "uuid": "^10.0.0",
50
+ "vitest": "^2.0.2"
51
+ },
52
+ "optionalDependencies": {
53
+ "@rollup/rollup-darwin-arm64": "^4.18.1"
54
+ },
55
+ "typesVersions": {
56
+ "*": {
57
+ "*": [
58
+ "./dist/index.d.ts"
59
+ ]
60
+ }
61
+ },
62
+ "scripts": {
63
+ "test": "pnpm exec vitest",
64
+ "typecheck": "tsc -p tsconfig.json",
65
+ "build": "shx rm -rf dist && concurrently \"tsup\" \"tsc -p tsconfig.build.json\"",
66
+ "stylecheck": "eslint . --quiet",
67
+ "format": "eslint . --fix"
68
+ }
69
+ }
package/src/client.ts ADDED
@@ -0,0 +1,572 @@
1
+ import { ArgumentsType } from 'vitest'
2
+ import { Message, Value, Offset, Schema } from './types'
3
+ import { MessageParser, Parser } from './parser'
4
+
5
+ export type ShapeData = Map<string, { [key: string]: Value }>
6
+ export type ShapeChangedCallback = (value: ShapeData) => void
7
+
8
+ export interface BackoffOptions {
9
+ initialDelay: number
10
+ maxDelay: number
11
+ multiplier: number
12
+ }
13
+
14
+ export const BackoffDefaults = {
15
+ initialDelay: 100,
16
+ maxDelay: 10_000,
17
+ multiplier: 1.3,
18
+ }
19
+
20
+ /**
21
+ * Options for constructing a ShapeStream.
22
+ */
23
+ export interface ShapeStreamOptions {
24
+ /**
25
+ * The full URL to where the Shape is hosted. This can either be the Electric server
26
+ * directly or a proxy. E.g. for a local Electric instance, you might set `http://localhost:3000/v1/shape/foo`
27
+ */
28
+ url: string
29
+ /**
30
+ * where clauses for the shape.
31
+ */
32
+ where?: string
33
+ /**
34
+ * The "offset" on the shape log. This is typically not set as the ShapeStream
35
+ * will handle this automatically. A common scenario where you might pass an offset
36
+ * is if you're maintaining a local cache of the log. If you've gone offline
37
+ * and are re-starting a ShapeStream to catch-up to the latest state of the Shape,
38
+ * you'd pass in the last offset and shapeId you'd seen from the Electric server
39
+ * so it knows at what point in the shape to catch you up from.
40
+ */
41
+ offset?: Offset
42
+ /**
43
+ * Similar to `offset`, this isn't typically used unless you're maintaining
44
+ * a cache of the shape log.
45
+ */
46
+ shapeId?: string
47
+ backoffOptions?: BackoffOptions
48
+ /**
49
+ * Automatically fetch updates to the Shape. If you just want to sync the current
50
+ * shape and stop, pass false.
51
+ */
52
+ subscribe?: boolean
53
+ signal?: AbortSignal
54
+ fetchClient?: typeof fetch
55
+ parser?: Parser
56
+ }
57
+
58
+ /**
59
+ * Receives batches of `messages`, puts them on a queue and processes
60
+ * them asynchronously by passing to a registered callback function.
61
+ *
62
+ * @constructor
63
+ * @param {(messages: Message[]) => void} callback function
64
+ */
65
+ class MessageProcessor {
66
+ private messageQueue: Message[][] = []
67
+ private isProcessing = false
68
+ private callback: (messages: Message[]) => void | Promise<void>
69
+
70
+ constructor(callback: (messages: Message[]) => void | Promise<void>) {
71
+ this.callback = callback
72
+ }
73
+
74
+ process(messages: Message[]) {
75
+ this.messageQueue.push(messages)
76
+
77
+ if (!this.isProcessing) {
78
+ this.processQueue()
79
+ }
80
+ }
81
+
82
+ private async processQueue() {
83
+ this.isProcessing = true
84
+
85
+ while (this.messageQueue.length > 0) {
86
+ const messages = this.messageQueue.shift()!
87
+
88
+ await this.callback(messages)
89
+ }
90
+
91
+ this.isProcessing = false
92
+ }
93
+ }
94
+
95
+ export class FetchError extends Error {
96
+ status: number
97
+ text?: string
98
+ json?: object
99
+ headers: Record<string, string>
100
+
101
+ constructor(
102
+ status: number,
103
+ text: string | undefined,
104
+ json: object | undefined,
105
+ headers: Record<string, string>,
106
+ public url: string,
107
+ message?: string
108
+ ) {
109
+ super(
110
+ message ||
111
+ `HTTP Error ${status} at ${url}: ${text ?? JSON.stringify(json)}`
112
+ )
113
+ this.name = `FetchError`
114
+ this.status = status
115
+ this.text = text
116
+ this.json = json
117
+ this.headers = headers
118
+ }
119
+
120
+ static async fromResponse(
121
+ response: Response,
122
+ url: string
123
+ ): Promise<FetchError> {
124
+ const status = response.status
125
+ const headers = Object.fromEntries([...response.headers.entries()])
126
+ let text: string | undefined = undefined
127
+ let json: object | undefined = undefined
128
+
129
+ const contentType = response.headers.get(`content-type`)
130
+ if (contentType && contentType.includes(`application/json`)) {
131
+ json = (await response.json()) as object
132
+ } else {
133
+ text = await response.text()
134
+ }
135
+
136
+ return new FetchError(status, text, json, headers, url)
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Reads updates to a shape from Electric using HTTP requests and long polling. Notifies subscribers
142
+ * when new messages come in. Doesn't maintain any history of the
143
+ * log but does keep track of the offset position and is the best way
144
+ * to consume the HTTP `GET /v1/shape` api.
145
+ *
146
+ * @constructor
147
+ * @param {ShapeStreamOptions} options
148
+ *
149
+ * Register a callback function to subscribe to the messages.
150
+ *
151
+ * const stream = new ShapeStream(options)
152
+ * stream.subscribe(messages => {
153
+ * // messages is 1 or more row updates
154
+ * })
155
+ *
156
+ * To abort the stream, abort the `signal`
157
+ * passed in via the `ShapeStreamOptions`.
158
+ *
159
+ * const aborter = new AbortController()
160
+ * const issueStream = new ShapeStream({
161
+ * url: `${BASE_URL}/${table}`
162
+ * subscribe: true,
163
+ * signal: aborter.signal,
164
+ * })
165
+ * // Later...
166
+ * aborter.abort()
167
+ */
168
+ export class ShapeStream {
169
+ private options: ShapeStreamOptions
170
+ private backoffOptions: BackoffOptions
171
+ private fetchClient: typeof fetch
172
+ private schema?: Schema
173
+
174
+ private subscribers = new Map<
175
+ number,
176
+ [MessageProcessor, ((error: Error) => void) | undefined]
177
+ >()
178
+ private upToDateSubscribers = new Map<
179
+ number,
180
+ [() => void, (error: FetchError | Error) => void]
181
+ >()
182
+
183
+ private lastOffset: Offset
184
+ private messageParser: MessageParser
185
+ public isUpToDate: boolean = false
186
+
187
+ shapeId?: string
188
+
189
+ constructor(options: ShapeStreamOptions) {
190
+ this.validateOptions(options)
191
+ this.options = { subscribe: true, ...options }
192
+ this.lastOffset = this.options.offset ?? `-1`
193
+ this.shapeId = this.options.shapeId
194
+ this.messageParser = new MessageParser(options.parser)
195
+
196
+ this.backoffOptions = options.backoffOptions ?? BackoffDefaults
197
+ this.fetchClient =
198
+ options.fetchClient ??
199
+ ((...args: ArgumentsType<typeof fetch>) => fetch(...args))
200
+
201
+ this.start()
202
+ }
203
+
204
+ async start() {
205
+ this.isUpToDate = false
206
+
207
+ const { url, where, signal } = this.options
208
+
209
+ while ((!signal?.aborted && !this.isUpToDate) || this.options.subscribe) {
210
+ const fetchUrl = new URL(url)
211
+ if (where) fetchUrl.searchParams.set(`where`, where)
212
+ fetchUrl.searchParams.set(`offset`, this.lastOffset)
213
+
214
+ if (this.isUpToDate) {
215
+ fetchUrl.searchParams.set(`live`, `true`)
216
+ }
217
+
218
+ if (this.shapeId) {
219
+ // This should probably be a header for better cache breaking?
220
+ fetchUrl.searchParams.set(`shape_id`, this.shapeId!)
221
+ }
222
+
223
+ let response!: Response
224
+
225
+ try {
226
+ const maybeResponse = await this.fetchWithBackoff(fetchUrl)
227
+ if (maybeResponse) response = maybeResponse
228
+ else break
229
+ } catch (e) {
230
+ if (!(e instanceof FetchError)) throw e // should never happen
231
+ if (e.status == 409) {
232
+ // Upon receiving a 409, we should start from scratch
233
+ // with the newly provided shape ID
234
+ const newShapeId = e.headers[`x-electric-shape-id`]
235
+ this.reset(newShapeId)
236
+ this.publish(e.json as Message[])
237
+ continue
238
+ } else if (e.status >= 400 && e.status < 500) {
239
+ // Notify subscribers
240
+ this.sendErrorToUpToDateSubscribers(e)
241
+ this.sendErrorToSubscribers(e)
242
+
243
+ // 400 errors are not actionable without additional user input, so we're throwing them.
244
+ throw e
245
+ }
246
+ }
247
+
248
+ const { headers, status } = response
249
+ const shapeId = headers.get(`X-Electric-Shape-Id`)
250
+ if (shapeId) {
251
+ this.shapeId = shapeId
252
+ }
253
+
254
+ const lastOffset = headers.get(`X-Electric-Chunk-Last-Offset`)
255
+ if (lastOffset) {
256
+ this.lastOffset = lastOffset as Offset
257
+ }
258
+
259
+ const getSchema = (): Schema => {
260
+ const schemaHeader = headers.get(`X-Electric-Schema`)
261
+ return schemaHeader ? JSON.parse(schemaHeader) : {}
262
+ }
263
+ this.schema = this.schema ?? getSchema()
264
+
265
+ const messages = status === 204 ? `[]` : await response.text()
266
+
267
+ const batch = this.messageParser.parse(messages, this.schema)
268
+
269
+ // Update isUpToDate
270
+ if (batch.length > 0) {
271
+ const lastMessage = batch[batch.length - 1]
272
+ if (
273
+ lastMessage.headers?.[`control`] === `up-to-date` &&
274
+ !this.isUpToDate
275
+ ) {
276
+ this.isUpToDate = true
277
+ this.notifyUpToDateSubscribers()
278
+ }
279
+
280
+ this.publish(batch)
281
+ }
282
+ }
283
+ }
284
+
285
+ subscribe(
286
+ callback: (messages: Message[]) => void | Promise<void>,
287
+ onError?: (error: FetchError | Error) => void
288
+ ) {
289
+ const subscriptionId = Math.random()
290
+ const subscriber = new MessageProcessor(callback)
291
+
292
+ this.subscribers.set(subscriptionId, [subscriber, onError])
293
+
294
+ return () => {
295
+ this.subscribers.delete(subscriptionId)
296
+ }
297
+ }
298
+
299
+ unsubscribeAll(): void {
300
+ this.subscribers.clear()
301
+ }
302
+
303
+ private publish(messages: Message[]) {
304
+ this.subscribers.forEach(([subscriber, _]) => {
305
+ subscriber.process(messages)
306
+ })
307
+ }
308
+
309
+ private sendErrorToSubscribers(error: Error) {
310
+ this.subscribers.forEach(([_, errorFn]) => {
311
+ errorFn?.(error)
312
+ })
313
+ }
314
+
315
+ subscribeOnceToUpToDate(
316
+ callback: () => void | Promise<void>,
317
+ error: (err: FetchError | Error) => void
318
+ ) {
319
+ const subscriptionId = Math.random()
320
+
321
+ this.upToDateSubscribers.set(subscriptionId, [callback, error])
322
+
323
+ return () => {
324
+ this.upToDateSubscribers.delete(subscriptionId)
325
+ }
326
+ }
327
+
328
+ unsubscribeAllUpToDateSubscribers(): void {
329
+ this.upToDateSubscribers.clear()
330
+ }
331
+
332
+ private notifyUpToDateSubscribers() {
333
+ this.upToDateSubscribers.forEach(([callback]) => {
334
+ callback()
335
+ })
336
+ }
337
+
338
+ private sendErrorToUpToDateSubscribers(error: FetchError | Error) {
339
+ // eslint-disable-next-line
340
+ this.upToDateSubscribers.forEach(([_, errorCallback]) =>
341
+ errorCallback(error)
342
+ )
343
+ }
344
+
345
+ /**
346
+ * Resets the state of the stream, optionally with a provided
347
+ * shape ID
348
+ */
349
+ private reset(shapeId?: string) {
350
+ this.lastOffset = `-1`
351
+ this.shapeId = shapeId
352
+ this.isUpToDate = false
353
+ this.schema = undefined
354
+ }
355
+
356
+ private validateOptions(options: ShapeStreamOptions): void {
357
+ if (!options.url) {
358
+ throw new Error(`Invalid shape option. It must provide the url`)
359
+ }
360
+ if (options.signal && !(options.signal instanceof AbortSignal)) {
361
+ throw new Error(
362
+ `Invalid signal option. It must be an instance of AbortSignal.`
363
+ )
364
+ }
365
+
366
+ if (
367
+ options.offset !== undefined &&
368
+ options.offset !== `-1` &&
369
+ !options.shapeId
370
+ ) {
371
+ throw new Error(
372
+ `shapeId is required if this isn't an initial fetch (i.e. offset > -1)`
373
+ )
374
+ }
375
+ }
376
+
377
+ private async fetchWithBackoff(url: URL) {
378
+ const { initialDelay, maxDelay, multiplier } = this.backoffOptions
379
+ const signal = this.options.signal
380
+
381
+ let delay = initialDelay
382
+ let attempt = 0
383
+
384
+ // eslint-disable-next-line no-constant-condition -- we're retrying with a lag until we get a non-500 response or the abort signal is triggered
385
+ while (true) {
386
+ try {
387
+ const result = await this.fetchClient(url.toString(), { signal })
388
+ if (result.ok) return result
389
+ else throw await FetchError.fromResponse(result, url.toString())
390
+ } catch (e) {
391
+ if (signal?.aborted) {
392
+ return undefined
393
+ } else if (
394
+ e instanceof FetchError &&
395
+ e.status >= 400 &&
396
+ e.status < 500
397
+ ) {
398
+ // Any client errors cannot be backed off on, leave it to the caller to handle.
399
+ throw e
400
+ } else {
401
+ // Exponentially backoff on errors.
402
+ // Wait for the current delay duration
403
+ await new Promise((resolve) => setTimeout(resolve, delay))
404
+
405
+ // Increase the delay for the next attempt
406
+ delay = Math.min(delay * multiplier, maxDelay)
407
+
408
+ attempt++
409
+ console.log(`Retry attempt #${attempt} after ${delay}ms`)
410
+ }
411
+ }
412
+ }
413
+ }
414
+ }
415
+
416
+ /**
417
+ * A Shape is an object that subscribes to a shape log,
418
+ * keeps a materialised shape `.value` in memory and
419
+ * notifies subscribers when the value has changed.
420
+ *
421
+ * It can be used without a framework and as a primitive
422
+ * to simplify developing framework hooks.
423
+ *
424
+ * @constructor
425
+ * @param {Shape}
426
+ *
427
+ * const shapeStream = new ShapeStream(url: 'http://localhost:3000/v1/shape/foo'})
428
+ * const shape = new Shape(shapeStream)
429
+ *
430
+ * `value` returns a promise that resolves the Shape data once the Shape has been
431
+ * fully loaded (and when resuming from being offline):
432
+ *
433
+ * const value = await shape.value
434
+ *
435
+ * `valueSync` returns the current data synchronously:
436
+ *
437
+ * const value = shape.valueSync
438
+ *
439
+ * Subscribe to updates. Called whenever the shape updates in Postgres.
440
+ *
441
+ * shape.subscribe(shapeData => {
442
+ * console.log(shapeData)
443
+ * })
444
+ */
445
+ export class Shape {
446
+ private stream: ShapeStream
447
+
448
+ private data: ShapeData = new Map()
449
+ private subscribers = new Map<number, ShapeChangedCallback>()
450
+ public error: FetchError | false = false
451
+ private hasNotifiedSubscribersUpToDate: boolean = false
452
+
453
+ constructor(stream: ShapeStream) {
454
+ this.stream = stream
455
+ this.stream.subscribe(this.process.bind(this), this.handleError.bind(this))
456
+ const unsubscribe = this.stream.subscribeOnceToUpToDate(
457
+ () => {
458
+ unsubscribe()
459
+ },
460
+ (e) => {
461
+ this.handleError(e)
462
+ throw e
463
+ }
464
+ )
465
+ }
466
+
467
+ get isUpToDate(): boolean {
468
+ return this.stream.isUpToDate
469
+ }
470
+
471
+ get value(): Promise<ShapeData> {
472
+ return new Promise((resolve) => {
473
+ if (this.stream.isUpToDate) {
474
+ resolve(this.valueSync)
475
+ } else {
476
+ const unsubscribe = this.stream.subscribeOnceToUpToDate(
477
+ () => {
478
+ unsubscribe()
479
+ resolve(this.valueSync)
480
+ },
481
+ (e) => {
482
+ throw e
483
+ }
484
+ )
485
+ }
486
+ })
487
+ }
488
+
489
+ get valueSync() {
490
+ return this.data
491
+ }
492
+
493
+ subscribe(callback: ShapeChangedCallback): () => void {
494
+ const subscriptionId = Math.random()
495
+
496
+ this.subscribers.set(subscriptionId, callback)
497
+
498
+ return () => {
499
+ this.subscribers.delete(subscriptionId)
500
+ }
501
+ }
502
+
503
+ unsubscribeAll(): void {
504
+ this.subscribers.clear()
505
+ }
506
+
507
+ get numSubscribers() {
508
+ return this.subscribers.size
509
+ }
510
+
511
+ private process(messages: Message[]): void {
512
+ let dataMayHaveChanged = false
513
+ let isUpToDate = false
514
+ let newlyUpToDate = false
515
+
516
+ messages.forEach((message) => {
517
+ if (`key` in message) {
518
+ dataMayHaveChanged = [`insert`, `update`, `delete`].includes(
519
+ message.headers.action
520
+ )
521
+
522
+ switch (message.headers.action) {
523
+ case `insert`:
524
+ this.data.set(message.key, message.value)
525
+ break
526
+ case `update`:
527
+ this.data.set(message.key, {
528
+ ...this.data.get(message.key)!,
529
+ ...message.value,
530
+ })
531
+ break
532
+ case `delete`:
533
+ this.data.delete(message.key)
534
+ break
535
+ }
536
+ }
537
+
538
+ if (message.headers?.[`control`] === `up-to-date`) {
539
+ isUpToDate = true
540
+ if (!this.hasNotifiedSubscribersUpToDate) {
541
+ newlyUpToDate = true
542
+ }
543
+ }
544
+
545
+ if (message.headers?.[`control`] === `must-refetch`) {
546
+ this.data.clear()
547
+ this.error = false
548
+ isUpToDate = false
549
+ newlyUpToDate = false
550
+ }
551
+ })
552
+
553
+ // Always notify subscribers when the Shape first is up to date.
554
+ // FIXME this would be cleaner with a simple state machine.
555
+ if (newlyUpToDate || (isUpToDate && dataMayHaveChanged)) {
556
+ this.hasNotifiedSubscribersUpToDate = true
557
+ this.notify()
558
+ }
559
+ }
560
+
561
+ private handleError(e: Error): void {
562
+ if (e instanceof FetchError) {
563
+ this.error = e
564
+ }
565
+ }
566
+
567
+ private notify(): void {
568
+ this.subscribers.forEach((callback) => {
569
+ callback(this.valueSync)
570
+ })
571
+ }
572
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './client'
2
+ export * from './types'