@electric-sql/client 1.0.9 → 1.0.10
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 +1 -1
- package/dist/cjs/index.cjs +84 -3
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +3 -1
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.legacy-esm.js +84 -3
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +84 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -2
- package/src/client.ts +40 -2
- package/src/constants.ts +2 -0
- package/src/expired-shapes-cache.ts +72 -0
- package/src/parser.ts +12 -1
- package/src/types.ts +8 -2
package/src/client.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
MaybePromise,
|
|
7
7
|
GetExtensions,
|
|
8
8
|
} from './types'
|
|
9
|
-
import { MessageParser, Parser } from './parser'
|
|
9
|
+
import { MessageParser, Parser, TransformFunction } from './parser'
|
|
10
10
|
import { getOffset, isUpToDateMessage } from './helpers'
|
|
11
11
|
import {
|
|
12
12
|
FetchError,
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
CHUNK_LAST_OFFSET_HEADER,
|
|
29
29
|
LIVE_CACHE_BUSTER_HEADER,
|
|
30
30
|
LIVE_CACHE_BUSTER_QUERY_PARAM,
|
|
31
|
+
EXPIRED_HANDLE_QUERY_PARAM,
|
|
31
32
|
COLUMNS_QUERY_PARAM,
|
|
32
33
|
LIVE_QUERY_PARAM,
|
|
33
34
|
OFFSET_QUERY_PARAM,
|
|
@@ -41,11 +42,13 @@ import {
|
|
|
41
42
|
FORCE_DISCONNECT_AND_REFRESH,
|
|
42
43
|
PAUSE_STREAM,
|
|
43
44
|
EXPERIMENTAL_LIVE_SSE_QUERY_PARAM,
|
|
45
|
+
ELECTRIC_PROTOCOL_QUERY_PARAMS,
|
|
44
46
|
} from './constants'
|
|
45
47
|
import {
|
|
46
48
|
EventSourceMessage,
|
|
47
49
|
fetchEventSource,
|
|
48
50
|
} from '@microsoft/fetch-event-source'
|
|
51
|
+
import { expiredShapesCache } from './expired-shapes-cache'
|
|
49
52
|
|
|
50
53
|
const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
|
|
51
54
|
LIVE_CACHE_BUSTER_QUERY_PARAM,
|
|
@@ -259,6 +262,7 @@ export interface ShapeStreamOptions<T = never> {
|
|
|
259
262
|
fetchClient?: typeof fetch
|
|
260
263
|
backoffOptions?: BackoffOptions
|
|
261
264
|
parser?: Parser<T>
|
|
265
|
+
transformer?: TransformFunction<T>
|
|
262
266
|
|
|
263
267
|
/**
|
|
264
268
|
* A function for handling shapestream errors.
|
|
@@ -293,6 +297,23 @@ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
|
|
|
293
297
|
forceDisconnectAndRefresh(): Promise<void>
|
|
294
298
|
}
|
|
295
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Creates a canonical shape key from a URL excluding only Electric protocol parameters
|
|
302
|
+
*/
|
|
303
|
+
function canonicalShapeKey(url: URL): string {
|
|
304
|
+
const cleanUrl = new URL(url.origin + url.pathname)
|
|
305
|
+
|
|
306
|
+
// Copy all params except Electric protocol ones that vary between requests
|
|
307
|
+
for (const [key, value] of url.searchParams) {
|
|
308
|
+
if (!ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) {
|
|
309
|
+
cleanUrl.searchParams.set(key, value)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
cleanUrl.searchParams.sort()
|
|
314
|
+
return cleanUrl.toString()
|
|
315
|
+
}
|
|
316
|
+
|
|
296
317
|
/**
|
|
297
318
|
* Reads updates to a shape from Electric using HTTP requests and long polling or
|
|
298
319
|
* Server-Sent Events (SSE).
|
|
@@ -379,7 +400,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
379
400
|
this.#lastOffset = this.options.offset ?? `-1`
|
|
380
401
|
this.#liveCacheBuster = ``
|
|
381
402
|
this.#shapeHandle = this.options.handle
|
|
382
|
-
this.#messageParser = new MessageParser<T>(
|
|
403
|
+
this.#messageParser = new MessageParser<T>(
|
|
404
|
+
options.parser,
|
|
405
|
+
options.transformer
|
|
406
|
+
)
|
|
383
407
|
this.#onError = this.options.onError
|
|
384
408
|
|
|
385
409
|
const baseFetchClient =
|
|
@@ -517,6 +541,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
517
541
|
// with the newly provided shape handle, or a fallback
|
|
518
542
|
// pseudo-handle based on the current one to act as a
|
|
519
543
|
// consistent cache buster
|
|
544
|
+
|
|
545
|
+
// Store the current shape URL as expired to avoid future 409s
|
|
546
|
+
if (this.#shapeHandle) {
|
|
547
|
+
const shapeKey = canonicalShapeKey(fetchUrl)
|
|
548
|
+
expiredShapesCache.markExpired(shapeKey, this.#shapeHandle)
|
|
549
|
+
}
|
|
550
|
+
|
|
520
551
|
const newShapeHandle =
|
|
521
552
|
e.headers[SHAPE_HANDLE_HEADER] || `${this.#shapeHandle!}-next`
|
|
522
553
|
this.#reset(newShapeHandle)
|
|
@@ -602,6 +633,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
602
633
|
fetchUrl.searchParams.set(SHAPE_HANDLE_QUERY_PARAM, this.#shapeHandle!)
|
|
603
634
|
}
|
|
604
635
|
|
|
636
|
+
// Add cache buster for shapes known to be expired to prevent 409s
|
|
637
|
+
const shapeKey = canonicalShapeKey(fetchUrl)
|
|
638
|
+
const expiredHandle = expiredShapesCache.getExpiredHandle(shapeKey)
|
|
639
|
+
if (expiredHandle) {
|
|
640
|
+
fetchUrl.searchParams.set(EXPIRED_HANDLE_QUERY_PARAM, expiredHandle)
|
|
641
|
+
}
|
|
642
|
+
|
|
605
643
|
// sort query params in-place for stable URLs and improved cache hits
|
|
606
644
|
fetchUrl.searchParams.sort()
|
|
607
645
|
|
package/src/constants.ts
CHANGED
|
@@ -5,6 +5,7 @@ export const SHAPE_SCHEMA_HEADER = `electric-schema`
|
|
|
5
5
|
export const CHUNK_UP_TO_DATE_HEADER = `electric-up-to-date`
|
|
6
6
|
export const COLUMNS_QUERY_PARAM = `columns`
|
|
7
7
|
export const LIVE_CACHE_BUSTER_QUERY_PARAM = `cursor`
|
|
8
|
+
export const EXPIRED_HANDLE_QUERY_PARAM = `expired_handle`
|
|
8
9
|
export const SHAPE_HANDLE_QUERY_PARAM = `handle`
|
|
9
10
|
export const LIVE_QUERY_PARAM = `live`
|
|
10
11
|
export const OFFSET_QUERY_PARAM = `offset`
|
|
@@ -22,4 +23,5 @@ export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
|
|
|
22
23
|
SHAPE_HANDLE_QUERY_PARAM,
|
|
23
24
|
OFFSET_QUERY_PARAM,
|
|
24
25
|
LIVE_CACHE_BUSTER_QUERY_PARAM,
|
|
26
|
+
EXPIRED_HANDLE_QUERY_PARAM,
|
|
25
27
|
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
interface ExpiredShapeCacheEntry {
|
|
2
|
+
expiredHandle: string
|
|
3
|
+
lastUsed: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* LRU cache for tracking expired shapes with automatic cleanup
|
|
8
|
+
*/
|
|
9
|
+
export class ExpiredShapesCache {
|
|
10
|
+
private data: Record<string, ExpiredShapeCacheEntry> = {}
|
|
11
|
+
private max: number = 250
|
|
12
|
+
private readonly storageKey = `electric_expired_shapes`
|
|
13
|
+
|
|
14
|
+
getExpiredHandle(shapeUrl: string): string | null {
|
|
15
|
+
const entry = this.data[shapeUrl]
|
|
16
|
+
if (entry) {
|
|
17
|
+
// Update last used time when accessed
|
|
18
|
+
entry.lastUsed = Date.now()
|
|
19
|
+
this.save()
|
|
20
|
+
return entry.expiredHandle
|
|
21
|
+
}
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
markExpired(shapeUrl: string, handle: string): void {
|
|
26
|
+
this.data[shapeUrl] = { expiredHandle: handle, lastUsed: Date.now() }
|
|
27
|
+
|
|
28
|
+
const keys = Object.keys(this.data)
|
|
29
|
+
if (keys.length > this.max) {
|
|
30
|
+
const oldest = keys.reduce((min, k) =>
|
|
31
|
+
this.data[k].lastUsed < this.data[min].lastUsed ? k : min
|
|
32
|
+
)
|
|
33
|
+
delete this.data[oldest]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this.save()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private save(): void {
|
|
40
|
+
if (typeof localStorage === `undefined`) return
|
|
41
|
+
try {
|
|
42
|
+
localStorage.setItem(this.storageKey, JSON.stringify(this.data))
|
|
43
|
+
} catch {
|
|
44
|
+
// Ignore localStorage errors
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private load(): void {
|
|
49
|
+
if (typeof localStorage === `undefined`) return
|
|
50
|
+
try {
|
|
51
|
+
const stored = localStorage.getItem(this.storageKey)
|
|
52
|
+
if (stored) {
|
|
53
|
+
this.data = JSON.parse(stored)
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// Ignore localStorage errors, start fresh
|
|
57
|
+
this.data = {}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
constructor() {
|
|
62
|
+
this.load()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
clear(): void {
|
|
66
|
+
this.data = {}
|
|
67
|
+
this.save()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Module-level singleton instance
|
|
72
|
+
export const expiredShapesCache = new ExpiredShapesCache()
|
package/src/parser.ts
CHANGED
|
@@ -19,6 +19,10 @@ export type Parser<Extensions = never> = {
|
|
|
19
19
|
[key: string]: ParseFunction<Extensions>
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export type TransformFunction<Extensions = never> = (
|
|
23
|
+
message: Row<Extensions>
|
|
24
|
+
) => Row<Extensions>
|
|
25
|
+
|
|
22
26
|
const parseNumber = (value: string) => Number(value)
|
|
23
27
|
const parseBool = (value: string) => value === `true` || value === `t`
|
|
24
28
|
const parseBigInt = (value: string) => BigInt(value)
|
|
@@ -94,11 +98,16 @@ export function pgArrayParser<Extensions>(
|
|
|
94
98
|
|
|
95
99
|
export class MessageParser<T extends Row<unknown>> {
|
|
96
100
|
private parser: Parser<GetExtensions<T>>
|
|
97
|
-
|
|
101
|
+
private transformer?: TransformFunction<GetExtensions<T>>
|
|
102
|
+
constructor(
|
|
103
|
+
parser?: Parser<GetExtensions<T>>,
|
|
104
|
+
transformer?: TransformFunction<GetExtensions<T>>
|
|
105
|
+
) {
|
|
98
106
|
// Merge the provided parser with the default parser
|
|
99
107
|
// to use the provided parser whenever defined
|
|
100
108
|
// and otherwise fall back to the default parser
|
|
101
109
|
this.parser = { ...defaultParser, ...parser }
|
|
110
|
+
this.transformer = transformer
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
parse<Result>(messages: string, schema: Schema): Result {
|
|
@@ -118,6 +127,8 @@ export class MessageParser<T extends Row<unknown>> {
|
|
|
118
127
|
Object.keys(row).forEach((key) => {
|
|
119
128
|
row[key] = this.parseRow(key, row[key] as NullableToken, schema)
|
|
120
129
|
})
|
|
130
|
+
|
|
131
|
+
if (this.transformer) value = this.transformer(value)
|
|
121
132
|
}
|
|
122
133
|
return value
|
|
123
134
|
}) as Result
|
package/src/types.ts
CHANGED
|
@@ -14,8 +14,14 @@ export type Value<Extensions = never> =
|
|
|
14
14
|
|
|
15
15
|
export type Row<Extensions = never> = Record<string, Value<Extensions>>
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// Check if `T` extends the base Row type without extensions
|
|
18
|
+
// if yes, it has no extensions so we return `never`
|
|
19
|
+
// otherwise, we infer the extensions from the Row type
|
|
20
|
+
export type GetExtensions<T> = [T] extends [Row<never>]
|
|
21
|
+
? never
|
|
22
|
+
: [T] extends [Row<infer E>]
|
|
23
|
+
? E
|
|
24
|
+
: never
|
|
19
25
|
|
|
20
26
|
export type Offset = `-1` | `${number}_${number}` | `${bigint}_${number}`
|
|
21
27
|
|