@electric-sql/client 1.5.5 → 1.5.7
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 +65 -8
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +4 -2
- package/dist/index.browser.mjs +4 -4
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.legacy-esm.js +65 -8
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +65 -8
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +56 -8
- package/src/fetch.ts +26 -0
- package/src/helpers.ts +15 -0
- package/src/shape-stream-state.ts +23 -0
- package/src/shape.ts +6 -2
- package/src/types.ts +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@electric-sql/client",
|
|
3
3
|
"description": "Postgres everywhere - your data, in sync, wherever you need it.",
|
|
4
|
-
"version": "1.5.
|
|
4
|
+
"version": "1.5.7",
|
|
5
5
|
"author": "ElectricSQL team and contributors.",
|
|
6
6
|
"bugs": {
|
|
7
7
|
"url": "https://github.com/electric-sql/electric/issues"
|
package/src/client.ts
CHANGED
|
@@ -15,7 +15,12 @@ import {
|
|
|
15
15
|
encodeWhereClause,
|
|
16
16
|
quoteIdentifier,
|
|
17
17
|
} from './column-mapper'
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
getOffset,
|
|
20
|
+
isUpToDateMessage,
|
|
21
|
+
isChangeMessage,
|
|
22
|
+
bigintSafeStringify,
|
|
23
|
+
} from './helpers'
|
|
19
24
|
import {
|
|
20
25
|
FetchError,
|
|
21
26
|
FetchBackoffAbortError,
|
|
@@ -987,7 +992,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
987
992
|
// Serialize params as JSON to keep the parameter name constant for proxy configs
|
|
988
993
|
fetchUrl.searchParams.set(
|
|
989
994
|
SUBSET_PARAM_WHERE_PARAMS,
|
|
990
|
-
|
|
995
|
+
bigintSafeStringify(subsetParams.params)
|
|
991
996
|
)
|
|
992
997
|
if (subsetParams.limit)
|
|
993
998
|
setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit)
|
|
@@ -1261,7 +1266,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1261
1266
|
const batch = this.#messageParser.parse<Array<Message<T>>>(messages, schema)
|
|
1262
1267
|
|
|
1263
1268
|
if (!Array.isArray(batch)) {
|
|
1264
|
-
const preview =
|
|
1269
|
+
const preview = bigintSafeStringify(batch)?.slice(0, 200)
|
|
1265
1270
|
throw new FetchError(
|
|
1266
1271
|
response.status,
|
|
1267
1272
|
`Received non-array response body from shape endpoint. ` +
|
|
@@ -1342,7 +1347,18 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1342
1347
|
// #start handles it correctly.
|
|
1343
1348
|
throw new FetchBackoffAbortError()
|
|
1344
1349
|
}
|
|
1345
|
-
throw
|
|
1350
|
+
// Re-throw known Electric errors so the caller can handle them
|
|
1351
|
+
// (e.g., 409 shape rotation, stale cache retry, missing headers).
|
|
1352
|
+
// Other errors (body parsing, SSE protocol failures, null body)
|
|
1353
|
+
// are SSE connection failures handled by the fallback mechanism
|
|
1354
|
+
// in the finally block below.
|
|
1355
|
+
if (
|
|
1356
|
+
error instanceof FetchError ||
|
|
1357
|
+
error instanceof StaleCacheError ||
|
|
1358
|
+
error instanceof MissingHeadersError
|
|
1359
|
+
) {
|
|
1360
|
+
throw error
|
|
1361
|
+
}
|
|
1346
1362
|
} finally {
|
|
1347
1363
|
// Check if the SSE connection closed too quickly
|
|
1348
1364
|
// This can happen when responses are cached or when the proxy/server
|
|
@@ -1639,7 +1655,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1639
1655
|
}, 30_000)
|
|
1640
1656
|
|
|
1641
1657
|
try {
|
|
1642
|
-
const { metadata, data } =
|
|
1658
|
+
const { metadata, data, responseOffset, responseHandle } =
|
|
1659
|
+
await this.fetchSnapshot(opts)
|
|
1643
1660
|
|
|
1644
1661
|
const dataWithEndBoundary = (data as Array<Message<T>>).concat([
|
|
1645
1662
|
{ headers: { control: `snapshot-end`, ...metadata } },
|
|
@@ -1652,6 +1669,31 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1652
1669
|
)
|
|
1653
1670
|
this.#onMessages(dataWithEndBoundary, false)
|
|
1654
1671
|
|
|
1672
|
+
// On cold start the stream's offset is still at "now". Advance it
|
|
1673
|
+
// to the snapshot's position so no updates are missed in between.
|
|
1674
|
+
if (responseOffset !== null || responseHandle !== null) {
|
|
1675
|
+
const transition = this.#syncState.handleResponseMetadata({
|
|
1676
|
+
status: 200,
|
|
1677
|
+
responseHandle,
|
|
1678
|
+
responseOffset,
|
|
1679
|
+
responseCursor: null,
|
|
1680
|
+
expiredHandle: null,
|
|
1681
|
+
now: Date.now(),
|
|
1682
|
+
maxStaleCacheRetries: this.#maxStaleCacheRetries,
|
|
1683
|
+
createCacheBuster: () =>
|
|
1684
|
+
`${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
|
1685
|
+
})
|
|
1686
|
+
if (transition.action === `accepted`) {
|
|
1687
|
+
this.#syncState = transition.state
|
|
1688
|
+
} else {
|
|
1689
|
+
console.warn(
|
|
1690
|
+
`[Electric] Snapshot response metadata was not accepted ` +
|
|
1691
|
+
`by state "${this.#syncState.kind}" (action: ${transition.action}). ` +
|
|
1692
|
+
`Stream offset was not advanced from snapshot.`
|
|
1693
|
+
)
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1655
1697
|
return {
|
|
1656
1698
|
metadata,
|
|
1657
1699
|
data,
|
|
@@ -1671,11 +1713,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1671
1713
|
* `subsetMethod: 'POST'` on the stream to send parameters in the request body instead.
|
|
1672
1714
|
*
|
|
1673
1715
|
* @param opts - The options for the snapshot request.
|
|
1674
|
-
* @returns The metadata and the
|
|
1716
|
+
* @returns The metadata, data, and the response's offset/handle for state advancement.
|
|
1675
1717
|
*/
|
|
1676
1718
|
async fetchSnapshot(opts: SubsetParams): Promise<{
|
|
1677
1719
|
metadata: SnapshotMetadata
|
|
1678
1720
|
data: Array<ChangeMessage<T>>
|
|
1721
|
+
responseOffset: Offset | null
|
|
1722
|
+
responseHandle: string | null
|
|
1679
1723
|
}> {
|
|
1680
1724
|
const method = opts.method ?? this.options.subsetMethod ?? `GET`
|
|
1681
1725
|
const usePost = method === `POST`
|
|
@@ -1692,7 +1736,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1692
1736
|
...result.requestHeaders,
|
|
1693
1737
|
'Content-Type': `application/json`,
|
|
1694
1738
|
},
|
|
1695
|
-
body:
|
|
1739
|
+
body: bigintSafeStringify(this.#buildSubsetBody(opts)),
|
|
1696
1740
|
}
|
|
1697
1741
|
} else {
|
|
1698
1742
|
const result = await this.#constructUrl(this.options.url, true, opts)
|
|
@@ -1746,7 +1790,11 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1746
1790
|
schema
|
|
1747
1791
|
)
|
|
1748
1792
|
|
|
1749
|
-
|
|
1793
|
+
const responseOffset =
|
|
1794
|
+
(response.headers.get(CHUNK_LAST_OFFSET_HEADER) as Offset) || null
|
|
1795
|
+
const responseHandle = response.headers.get(SHAPE_HANDLE_HEADER)
|
|
1796
|
+
|
|
1797
|
+
return { metadata, data, responseOffset, responseHandle }
|
|
1750
1798
|
}
|
|
1751
1799
|
|
|
1752
1800
|
#buildSubsetBody(opts: SubsetParams): Record<string, unknown> {
|
package/src/fetch.ts
CHANGED
|
@@ -226,6 +226,17 @@ export function createFetchWithChunkBuffer(
|
|
|
226
226
|
|
|
227
227
|
const prefetchClient = async (...args: Parameters<typeof fetchClient>) => {
|
|
228
228
|
const url = args[0].toString()
|
|
229
|
+
const method = getRequestMethod(args[0], args[1])
|
|
230
|
+
|
|
231
|
+
// Prefetch is only valid for GET requests. The prefetch queue matches
|
|
232
|
+
// requests by URL alone and ignores HTTP method/body, so a POST request
|
|
233
|
+
// with the same URL would incorrectly consume the prefetched stream
|
|
234
|
+
// response instead of making its own request.
|
|
235
|
+
if (method !== `GET`) {
|
|
236
|
+
prefetchQueue?.abort()
|
|
237
|
+
prefetchQueue = undefined
|
|
238
|
+
return fetchClient(...args)
|
|
239
|
+
}
|
|
229
240
|
|
|
230
241
|
// try to consume from the prefetch queue first, and if request is
|
|
231
242
|
// not present abort the prefetch queue as it must no longer be valid
|
|
@@ -494,3 +505,18 @@ function chainAborter(
|
|
|
494
505
|
}
|
|
495
506
|
|
|
496
507
|
function noop() {}
|
|
508
|
+
|
|
509
|
+
function getRequestMethod(
|
|
510
|
+
input: Parameters<typeof fetch>[0],
|
|
511
|
+
init?: Parameters<typeof fetch>[1]
|
|
512
|
+
): string {
|
|
513
|
+
if (init?.method) {
|
|
514
|
+
return init.method.toUpperCase()
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (typeof Request !== `undefined` && input instanceof Request) {
|
|
518
|
+
return input.method.toUpperCase()
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return `GET`
|
|
522
|
+
}
|
package/src/helpers.ts
CHANGED
|
@@ -71,6 +71,21 @@ export function getOffset(message: ControlMessage): Offset | undefined {
|
|
|
71
71
|
return lsn ? (`${lsn}_0` as Offset) : undefined
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
function bigintReplacer(_key: string, value: unknown): unknown {
|
|
75
|
+
return typeof value === `bigint` ? value.toString() : value
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* BigInt-safe version of JSON.stringify.
|
|
80
|
+
* Converts BigInt values to their string representation (as JSON strings,
|
|
81
|
+
* e.g. `{ id: 42n }` becomes `{"id":"42"}`) instead of throwing.
|
|
82
|
+
* Assumes input is a JSON-serializable value — passing `undefined` at the
|
|
83
|
+
* top level will return `undefined` (matching `JSON.stringify` behavior).
|
|
84
|
+
*/
|
|
85
|
+
export function bigintSafeStringify(value: unknown): string {
|
|
86
|
+
return JSON.stringify(value, bigintReplacer)
|
|
87
|
+
}
|
|
88
|
+
|
|
74
89
|
/**
|
|
75
90
|
* Checks if a transaction is visible in a snapshot.
|
|
76
91
|
*
|
|
@@ -380,6 +380,19 @@ abstract class FetchingState extends ActiveState {
|
|
|
380
380
|
if (staleResult) return staleResult
|
|
381
381
|
|
|
382
382
|
const shared = this.parseResponseFields(input)
|
|
383
|
+
|
|
384
|
+
// NOTE: 204s are deprecated, the Electric server should not send these
|
|
385
|
+
// in latest versions but this is here for backwards compatibility.
|
|
386
|
+
// A 204 means "no content, you're caught up" — transition to live.
|
|
387
|
+
// Skip SSE detection: a 204 gives no indication SSE will work, and
|
|
388
|
+
// the 3-attempt fallback cycle adds unnecessary latency.
|
|
389
|
+
if (input.status === 204) {
|
|
390
|
+
return {
|
|
391
|
+
action: `accepted`,
|
|
392
|
+
state: new LiveState(shared, { sseFallbackToLongPolling: true }),
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
383
396
|
return { action: `accepted`, state: new SyncingState(shared) }
|
|
384
397
|
}
|
|
385
398
|
|
|
@@ -691,6 +704,16 @@ export class PausedState extends ShapeStreamState {
|
|
|
691
704
|
return this.previousState.replayCursor
|
|
692
705
|
}
|
|
693
706
|
|
|
707
|
+
handleResponseMetadata(
|
|
708
|
+
input: ResponseMetadataInput
|
|
709
|
+
): ResponseMetadataTransition {
|
|
710
|
+
const transition = this.previousState.handleResponseMetadata(input)
|
|
711
|
+
if (transition.action === `accepted`) {
|
|
712
|
+
return { action: `accepted`, state: new PausedState(transition.state) }
|
|
713
|
+
}
|
|
714
|
+
return transition
|
|
715
|
+
}
|
|
716
|
+
|
|
694
717
|
withHandle(handle: string): PausedState {
|
|
695
718
|
return new PausedState(this.previousState.withHandle(handle))
|
|
696
719
|
}
|
package/src/shape.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { Message, Offset, Row } from './types'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
isChangeMessage,
|
|
4
|
+
isControlMessage,
|
|
5
|
+
bigintSafeStringify,
|
|
6
|
+
} from './helpers'
|
|
3
7
|
import { FetchError } from './error'
|
|
4
8
|
import { LogMode, ShapeStreamInterface } from './client'
|
|
5
9
|
|
|
@@ -141,7 +145,7 @@ export class Shape<T extends Row<unknown> = Row> {
|
|
|
141
145
|
params: Parameters<ShapeStreamInterface<T>[`requestSnapshot`]>[0]
|
|
142
146
|
): Promise<void> {
|
|
143
147
|
// Track this snapshot request for future re-execution on shape rotation
|
|
144
|
-
const key =
|
|
148
|
+
const key = bigintSafeStringify(params)
|
|
145
149
|
this.#requestedSubSnapshots.add(key)
|
|
146
150
|
// Ensure the stream is up-to-date so schema is available for parsing
|
|
147
151
|
await this.#awaitUpToDate()
|
package/src/types.ts
CHANGED
|
@@ -91,7 +91,7 @@ export type SubsetParams = {
|
|
|
91
91
|
/** Legacy string format WHERE clause */
|
|
92
92
|
where?: string
|
|
93
93
|
/** Positional parameter values for WHERE clause */
|
|
94
|
-
params?: Record<string, string>
|
|
94
|
+
params?: Record<string, string | bigint | number>
|
|
95
95
|
/** Maximum number of rows to return */
|
|
96
96
|
limit?: number
|
|
97
97
|
/** Number of rows to skip */
|