@electric-sql/client 1.1.2 → 1.1.4
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 +74 -12
- package/dist/cjs/index.cjs +58 -12
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +53 -4
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +53 -4
- package/dist/index.legacy-esm.js +58 -12
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +58 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +70 -6
- package/src/constants.ts +1 -0
- package/src/fetch.ts +75 -9
package/README.md
CHANGED
|
@@ -99,26 +99,84 @@ shape.subscribe(({ rows }) => {
|
|
|
99
99
|
|
|
100
100
|
### Error Handling
|
|
101
101
|
|
|
102
|
-
The ShapeStream provides
|
|
102
|
+
The ShapeStream provides robust error handling with automatic retry support:
|
|
103
103
|
|
|
104
|
-
1.
|
|
104
|
+
#### 1. Stream-level error handler with retry control
|
|
105
|
+
|
|
106
|
+
The `onError` handler gives you full control over error recovery:
|
|
105
107
|
|
|
106
108
|
```typescript
|
|
107
109
|
const stream = new ShapeStream({
|
|
108
110
|
url: `${BASE_URL}/v1/shape`,
|
|
109
|
-
params: {
|
|
110
|
-
table: `foo`,
|
|
111
|
-
},
|
|
111
|
+
params: { table: `foo` },
|
|
112
112
|
onError: (error) => {
|
|
113
|
-
// Handle all stream errors here
|
|
114
113
|
console.error('Stream error:', error)
|
|
114
|
+
|
|
115
|
+
// IMPORTANT: Return an object to keep syncing!
|
|
116
|
+
// Return void/undefined to stop syncing permanently.
|
|
117
|
+
|
|
118
|
+
// Note: 5xx errors and network errors are automatically retried,
|
|
119
|
+
// so onError is mainly for handling client errors (4xx)
|
|
120
|
+
|
|
121
|
+
if (error instanceof FetchError) {
|
|
122
|
+
if (error.status === 401) {
|
|
123
|
+
// Unauthorized - refresh token and retry
|
|
124
|
+
const newToken = getRefreshedToken()
|
|
125
|
+
return {
|
|
126
|
+
headers: {
|
|
127
|
+
Authorization: `Bearer ${newToken}`,
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (error.status === 403) {
|
|
133
|
+
// Forbidden - maybe change user context
|
|
134
|
+
return {
|
|
135
|
+
params: {
|
|
136
|
+
table: `foo`,
|
|
137
|
+
where: `user_id = $1`,
|
|
138
|
+
params: [fallbackUserId],
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Stop syncing for other errors (return void)
|
|
115
145
|
},
|
|
116
146
|
})
|
|
117
147
|
```
|
|
118
148
|
|
|
119
|
-
|
|
149
|
+
**Critical**: The `onError` callback's return value controls whether syncing continues:
|
|
120
150
|
|
|
121
|
-
|
|
151
|
+
- **Return an object** (even empty `{}`) to retry syncing:
|
|
152
|
+
- `{}` - Retry with same params and headers
|
|
153
|
+
- `{ params }` - Retry with modified params
|
|
154
|
+
- `{ headers }` - Retry with modified headers
|
|
155
|
+
- `{ params, headers }` - Retry with both modified
|
|
156
|
+
- **Return void/undefined** to stop the stream permanently
|
|
157
|
+
|
|
158
|
+
The handler supports async operations:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
onError: async (error) => {
|
|
162
|
+
if (error instanceof FetchError && error.status === 401) {
|
|
163
|
+
// Perform async token refresh
|
|
164
|
+
const newToken = await refreshAuthToken()
|
|
165
|
+
return {
|
|
166
|
+
headers: { Authorization: `Bearer ${newToken}` },
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return {} // Retry other errors
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**Automatic retries**: The client automatically retries 5xx server errors, network errors, and 429 rate limits with exponential backoff. The `onError` callback is only invoked after these retries are exhausted, or for non-retryable errors like 4xx client errors.
|
|
174
|
+
|
|
175
|
+
**Without `onError`**: If no `onError` handler is provided, non-retryable errors (like 4xx client errors) will be thrown and the stream will stop.
|
|
176
|
+
|
|
177
|
+
#### 2. Subscription-level error callbacks
|
|
178
|
+
|
|
179
|
+
Individual subscribers can handle errors specific to their subscription:
|
|
122
180
|
|
|
123
181
|
```typescript
|
|
124
182
|
stream.subscribe(
|
|
@@ -132,7 +190,11 @@ stream.subscribe(
|
|
|
132
190
|
)
|
|
133
191
|
```
|
|
134
192
|
|
|
135
|
-
|
|
193
|
+
Note: Subscription error callbacks cannot control retry behavior - use the stream-level `onError` for that.
|
|
194
|
+
|
|
195
|
+
#### Common Error Types
|
|
196
|
+
|
|
197
|
+
Setup errors:
|
|
136
198
|
|
|
137
199
|
- `MissingShapeUrlError`: Missing required URL parameter
|
|
138
200
|
- `InvalidSignalError`: Invalid AbortSignal instance
|
|
@@ -140,12 +202,12 @@ Common error types include:
|
|
|
140
202
|
|
|
141
203
|
Runtime errors:
|
|
142
204
|
|
|
143
|
-
- `FetchError`: HTTP errors during shape fetching
|
|
205
|
+
- `FetchError`: HTTP errors during shape fetching (includes `status`, `url`, `headers`)
|
|
144
206
|
- `FetchBackoffAbortError`: Fetch aborted using AbortSignal
|
|
145
207
|
- `MissingShapeHandleError`: Missing required shape handle
|
|
146
|
-
- `ParserNullValueError`:
|
|
208
|
+
- `ParserNullValueError`: NULL value in a non-nullable column
|
|
147
209
|
|
|
148
|
-
See the [
|
|
210
|
+
See the [TypeScript client docs](https://electric-sql.com/docs/api/clients/typescript#error-handling) for more details.
|
|
149
211
|
|
|
150
212
|
And in general, see the [docs website](https://electric-sql.com) and [examples](https://electric-sql.com/demos) for more information.
|
|
151
213
|
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -353,6 +353,7 @@ var SUBSET_PARAM_ORDER_BY = `subset__order_by`;
|
|
|
353
353
|
var SUBSET_PARAM_WHERE_PARAMS = `subset__params`;
|
|
354
354
|
var ELECTRIC_PROTOCOL_QUERY_PARAMS = [
|
|
355
355
|
LIVE_QUERY_PARAM,
|
|
356
|
+
LIVE_SSE_QUERY_PARAM,
|
|
356
357
|
SHAPE_HANDLE_QUERY_PARAM,
|
|
357
358
|
OFFSET_QUERY_PARAM,
|
|
358
359
|
LIVE_CACHE_BUSTER_QUERY_PARAM,
|
|
@@ -369,16 +370,33 @@ var ELECTRIC_PROTOCOL_QUERY_PARAMS = [
|
|
|
369
370
|
var HTTP_RETRY_STATUS_CODES = [429];
|
|
370
371
|
var BackoffDefaults = {
|
|
371
372
|
initialDelay: 100,
|
|
372
|
-
maxDelay:
|
|
373
|
-
|
|
373
|
+
maxDelay: 6e4,
|
|
374
|
+
// Cap at 60s - reasonable for long-lived connections
|
|
375
|
+
multiplier: 1.3,
|
|
376
|
+
maxRetries: Infinity
|
|
377
|
+
// Retry forever - clients may go offline and come back
|
|
374
378
|
};
|
|
379
|
+
function parseRetryAfterHeader(retryAfter) {
|
|
380
|
+
if (!retryAfter) return 0;
|
|
381
|
+
const retryAfterSec = Number(retryAfter);
|
|
382
|
+
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
|
|
383
|
+
return retryAfterSec * 1e3;
|
|
384
|
+
}
|
|
385
|
+
const retryDate = Date.parse(retryAfter);
|
|
386
|
+
if (!isNaN(retryDate)) {
|
|
387
|
+
const deltaMs = retryDate - Date.now();
|
|
388
|
+
return Math.max(0, Math.min(deltaMs, 36e5));
|
|
389
|
+
}
|
|
390
|
+
return 0;
|
|
391
|
+
}
|
|
375
392
|
function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
|
|
376
393
|
const {
|
|
377
394
|
initialDelay,
|
|
378
395
|
maxDelay,
|
|
379
396
|
multiplier,
|
|
380
397
|
debug = false,
|
|
381
|
-
onFailedAttempt
|
|
398
|
+
onFailedAttempt,
|
|
399
|
+
maxRetries = Infinity
|
|
382
400
|
} = backoffOptions;
|
|
383
401
|
return (...args) => __async(this, null, function* () {
|
|
384
402
|
var _a;
|
|
@@ -389,7 +407,9 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
|
|
|
389
407
|
while (true) {
|
|
390
408
|
try {
|
|
391
409
|
const result = yield fetchClient(...args);
|
|
392
|
-
if (result.ok)
|
|
410
|
+
if (result.ok) {
|
|
411
|
+
return result;
|
|
412
|
+
}
|
|
393
413
|
const err = yield FetchError.fromResponse(result, url.toString());
|
|
394
414
|
throw err;
|
|
395
415
|
} catch (e) {
|
|
@@ -399,12 +419,27 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
|
|
|
399
419
|
} else if (e instanceof FetchError && !HTTP_RETRY_STATUS_CODES.includes(e.status) && e.status >= 400 && e.status < 500) {
|
|
400
420
|
throw e;
|
|
401
421
|
} else {
|
|
402
|
-
|
|
403
|
-
|
|
422
|
+
attempt++;
|
|
423
|
+
if (attempt > maxRetries) {
|
|
424
|
+
if (debug) {
|
|
425
|
+
console.log(
|
|
426
|
+
`Max retries reached (${attempt}/${maxRetries}), giving up`
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
throw e;
|
|
430
|
+
}
|
|
431
|
+
const serverMinimumMs = e instanceof FetchError && e.headers ? parseRetryAfterHeader(e.headers[`retry-after`]) : 0;
|
|
432
|
+
const jitter = Math.random() * delay;
|
|
433
|
+
const clientBackoffMs = Math.min(jitter, maxDelay);
|
|
434
|
+
const waitMs = Math.max(serverMinimumMs, clientBackoffMs);
|
|
404
435
|
if (debug) {
|
|
405
|
-
|
|
406
|
-
console.log(
|
|
436
|
+
const source = serverMinimumMs > 0 ? `server+client` : `client`;
|
|
437
|
+
console.log(
|
|
438
|
+
`Retry attempt #${attempt} after ${waitMs}ms (${source}, serverMin=${serverMinimumMs}ms, clientBackoff=${clientBackoffMs}ms)`
|
|
439
|
+
);
|
|
407
440
|
}
|
|
441
|
+
yield new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
442
|
+
delay = Math.min(delay * multiplier, maxDelay);
|
|
408
443
|
}
|
|
409
444
|
}
|
|
410
445
|
}
|
|
@@ -775,9 +810,8 @@ function canonicalShapeKey(url) {
|
|
|
775
810
|
cleanUrl.searchParams.sort();
|
|
776
811
|
return cleanUrl.toString();
|
|
777
812
|
}
|
|
778
|
-
var _error, _fetchClient2, _sseFetchClient, _messageParser, _subscribers, _started, _state, _lastOffset, _liveCacheBuster, _lastSyncedAt, _isUpToDate, _isMidStream, _connected, _shapeHandle, _mode, _schema, _onError, _requestAbortController, _isRefreshing, _tickPromise, _tickPromiseResolver, _tickPromiseRejecter, _messageChain, _snapshotTracker, _activeSnapshotRequests, _midStreamPromise, _midStreamPromiseResolver, _lastSseConnectionStartTime, _minSseConnectionDuration, _consecutiveShortSseConnections, _maxShortSseConnections, _sseFallbackToLongPolling, _sseBackoffBaseDelay, _sseBackoffMaxDelay, _ShapeStream_instances, start_fn, requestShape_fn, constructUrl_fn, createAbortListener_fn, onInitialResponse_fn, onMessages_fn, fetchShape_fn, requestShapeLongPoll_fn, requestShapeSSE_fn, pause_fn, resume_fn, nextTick_fn, waitForStreamEnd_fn, publish_fn, sendErrorToSubscribers_fn, subscribeToVisibilityChanges_fn, reset_fn, fetchSnapshot_fn;
|
|
813
|
+
var _error, _fetchClient2, _sseFetchClient, _messageParser, _subscribers, _started, _state, _lastOffset, _liveCacheBuster, _lastSyncedAt, _isUpToDate, _isMidStream, _connected, _shapeHandle, _mode, _schema, _onError, _requestAbortController, _isRefreshing, _tickPromise, _tickPromiseResolver, _tickPromiseRejecter, _messageChain, _snapshotTracker, _activeSnapshotRequests, _midStreamPromise, _midStreamPromiseResolver, _lastSseConnectionStartTime, _minSseConnectionDuration, _consecutiveShortSseConnections, _maxShortSseConnections, _sseFallbackToLongPolling, _sseBackoffBaseDelay, _sseBackoffMaxDelay, _unsubscribeFromVisibilityChanges, _ShapeStream_instances, start_fn, requestShape_fn, constructUrl_fn, createAbortListener_fn, onInitialResponse_fn, onMessages_fn, fetchShape_fn, requestShapeLongPoll_fn, requestShapeSSE_fn, pause_fn, resume_fn, nextTick_fn, waitForStreamEnd_fn, publish_fn, sendErrorToSubscribers_fn, subscribeToVisibilityChanges_fn, reset_fn, fetchSnapshot_fn;
|
|
779
814
|
var ShapeStream = class {
|
|
780
|
-
// Maximum delay cap (ms)
|
|
781
815
|
constructor(options) {
|
|
782
816
|
__privateAdd(this, _ShapeStream_instances);
|
|
783
817
|
__privateAdd(this, _error, null);
|
|
@@ -821,6 +855,8 @@ var ShapeStream = class {
|
|
|
821
855
|
__privateAdd(this, _sseBackoffBaseDelay, 100);
|
|
822
856
|
// Base delay for exponential backoff (ms)
|
|
823
857
|
__privateAdd(this, _sseBackoffMaxDelay, 5e3);
|
|
858
|
+
// Maximum delay cap (ms)
|
|
859
|
+
__privateAdd(this, _unsubscribeFromVisibilityChanges);
|
|
824
860
|
var _a, _b, _c, _d;
|
|
825
861
|
this.options = __spreadValues({ subscribe: true }, options);
|
|
826
862
|
validateOptions(this.options);
|
|
@@ -876,7 +912,9 @@ var ShapeStream = class {
|
|
|
876
912
|
};
|
|
877
913
|
}
|
|
878
914
|
unsubscribeAll() {
|
|
915
|
+
var _a;
|
|
879
916
|
__privateGet(this, _subscribers).clear();
|
|
917
|
+
(_a = __privateGet(this, _unsubscribeFromVisibilityChanges)) == null ? void 0 : _a.call(this);
|
|
880
918
|
}
|
|
881
919
|
/** Unix time at which we last synced. Undefined when `isLoading` is true. */
|
|
882
920
|
lastSyncedAt() {
|
|
@@ -1003,6 +1041,7 @@ _maxShortSseConnections = new WeakMap();
|
|
|
1003
1041
|
_sseFallbackToLongPolling = new WeakMap();
|
|
1004
1042
|
_sseBackoffBaseDelay = new WeakMap();
|
|
1005
1043
|
_sseBackoffMaxDelay = new WeakMap();
|
|
1044
|
+
_unsubscribeFromVisibilityChanges = new WeakMap();
|
|
1006
1045
|
_ShapeStream_instances = new WeakSet();
|
|
1007
1046
|
start_fn = function() {
|
|
1008
1047
|
return __async(this, null, function* () {
|
|
@@ -1072,7 +1111,8 @@ requestShape_fn = function() {
|
|
|
1072
1111
|
return __privateMethod(this, _ShapeStream_instances, requestShape_fn).call(this);
|
|
1073
1112
|
}
|
|
1074
1113
|
if (e instanceof FetchBackoffAbortError) {
|
|
1075
|
-
|
|
1114
|
+
const currentState = __privateGet(this, _state);
|
|
1115
|
+
if (requestAbortController.signal.aborted && requestAbortController.signal.reason === PAUSE_STREAM && currentState === `pause-requested`) {
|
|
1076
1116
|
__privateSet(this, _state, `paused`);
|
|
1077
1117
|
}
|
|
1078
1118
|
return;
|
|
@@ -1333,7 +1373,10 @@ pause_fn = function() {
|
|
|
1333
1373
|
}
|
|
1334
1374
|
};
|
|
1335
1375
|
resume_fn = function() {
|
|
1336
|
-
if (__privateGet(this, _started) && __privateGet(this, _state) === `paused`) {
|
|
1376
|
+
if (__privateGet(this, _started) && (__privateGet(this, _state) === `paused` || __privateGet(this, _state) === `pause-requested`)) {
|
|
1377
|
+
if (__privateGet(this, _state) === `pause-requested`) {
|
|
1378
|
+
__privateSet(this, _state, `active`);
|
|
1379
|
+
}
|
|
1337
1380
|
__privateMethod(this, _ShapeStream_instances, start_fn).call(this);
|
|
1338
1381
|
}
|
|
1339
1382
|
};
|
|
@@ -1405,6 +1448,9 @@ subscribeToVisibilityChanges_fn = function() {
|
|
|
1405
1448
|
}
|
|
1406
1449
|
};
|
|
1407
1450
|
document.addEventListener(`visibilitychange`, visibilityHandler);
|
|
1451
|
+
__privateSet(this, _unsubscribeFromVisibilityChanges, () => {
|
|
1452
|
+
document.removeEventListener(`visibilitychange`, visibilityHandler);
|
|
1453
|
+
});
|
|
1408
1454
|
}
|
|
1409
1455
|
};
|
|
1410
1456
|
/**
|