@electric-sql/client 1.0.14 → 1.1.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/dist/index.d.ts CHANGED
@@ -135,16 +135,38 @@ interface BackoffOptions {
135
135
  initialDelay: number;
136
136
  /**
137
137
  * Maximum retry delay in milliseconds
138
+ * After reaching this, delay stays constant (e.g., retry every 60s)
138
139
  */
139
140
  maxDelay: number;
140
141
  multiplier: number;
141
142
  onFailedAttempt?: () => void;
142
143
  debug?: boolean;
144
+ /**
145
+ * Maximum number of retry attempts before giving up.
146
+ * Set to Infinity (default) for indefinite retries - needed for offline scenarios
147
+ * where clients may go offline and come back later.
148
+ *
149
+ * The retry budget provides protection against retry storms even with infinite retries.
150
+ */
151
+ maxRetries?: number;
152
+ /**
153
+ * Percentage of requests that can be retries (0.1 = 10%)
154
+ *
155
+ * This is the primary load shedding mechanism. It limits the *rate* of retries,
156
+ * not the total count. Even with infinite retries, at most 10% of your traffic
157
+ * will be retries, preventing retry storms from amplifying server load.
158
+ *
159
+ * The budget resets every 60 seconds, so a temporary spike of errors won't
160
+ * permanently exhaust the budget.
161
+ */
162
+ retryBudgetPercent?: number;
143
163
  }
144
164
  declare const BackoffDefaults: {
145
165
  initialDelay: number;
146
166
  maxDelay: number;
147
167
  multiplier: number;
168
+ maxRetries: number;
169
+ retryBudgetPercent: number;
148
170
  };
149
171
 
150
172
  declare const LIVE_CACHE_BUSTER_QUERY_PARAM = "cursor";
@@ -278,9 +300,13 @@ interface ShapeStreamOptions<T = never> {
278
300
  */
279
301
  subscribe?: boolean;
280
302
  /**
281
- * Experimental support for Server-Sent Events (SSE) for live updates.
303
+ * @deprecated No longer experimental, use {@link liveSse} instead.
282
304
  */
283
305
  experimentalLiveSse?: boolean;
306
+ /**
307
+ * Use Server-Sent Events (SSE) for live updates.
308
+ */
309
+ liveSse?: boolean;
284
310
  /**
285
311
  * Initial data loading mode
286
312
  */
@@ -348,7 +374,7 @@ interface ShapeStreamInterface<T extends Row<unknown> = Row> {
348
374
  * ```
349
375
  * const stream = new ShapeStream({
350
376
  * url: `http://localhost:3000/v1/shape`,
351
- * experimentalLiveSse: true
377
+ * liveSse: true
352
378
  * })
353
379
  * ```
354
380
  *
@@ -289,6 +289,7 @@ var WHERE_QUERY_PARAM = `where`;
289
289
  var REPLICA_PARAM = `replica`;
290
290
  var WHERE_PARAMS_PARAM = `params`;
291
291
  var EXPERIMENTAL_LIVE_SSE_QUERY_PARAM = `experimental_live_sse`;
292
+ var LIVE_SSE_QUERY_PARAM = `live_sse`;
292
293
  var FORCE_DISCONNECT_AND_REFRESH = `force-disconnect-and-refresh`;
293
294
  var PAUSE_STREAM = `pause-stream`;
294
295
  var LOG_MODE_QUERY_PARAM = `log`;
@@ -315,8 +316,13 @@ var ELECTRIC_PROTOCOL_QUERY_PARAMS = [
315
316
  var HTTP_RETRY_STATUS_CODES = [429];
316
317
  var BackoffDefaults = {
317
318
  initialDelay: 100,
318
- maxDelay: 1e4,
319
- multiplier: 1.3
319
+ maxDelay: 6e4,
320
+ // Cap at 60s - reasonable for long-lived connections
321
+ multiplier: 1.3,
322
+ maxRetries: Infinity,
323
+ // Retry forever - clients may go offline and come back
324
+ retryBudgetPercent: 0.1
325
+ // 10% retry budget prevents amplification
320
326
  };
321
327
  function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
322
328
  const {
@@ -324,8 +330,29 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
324
330
  maxDelay,
325
331
  multiplier,
326
332
  debug = false,
327
- onFailedAttempt
333
+ onFailedAttempt,
334
+ maxRetries = Infinity,
335
+ retryBudgetPercent = 0.1
328
336
  } = backoffOptions;
337
+ let totalRequests = 0;
338
+ let totalRetries = 0;
339
+ let budgetResetTime = Date.now() + 6e4;
340
+ function checkRetryBudget(percent) {
341
+ const now = Date.now();
342
+ if (now > budgetResetTime) {
343
+ totalRequests = 0;
344
+ totalRetries = 0;
345
+ budgetResetTime = now + 6e4;
346
+ }
347
+ totalRequests++;
348
+ if (totalRequests < 10) return true;
349
+ const currentRetryRate = totalRetries / totalRequests;
350
+ const hasCapacity = currentRetryRate < percent;
351
+ if (hasCapacity) {
352
+ totalRetries++;
353
+ }
354
+ return hasCapacity;
355
+ }
329
356
  return async (...args) => {
330
357
  var _a;
331
358
  const url = args[0];
@@ -335,7 +362,10 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
335
362
  while (true) {
336
363
  try {
337
364
  const result = await fetchClient(...args);
338
- if (result.ok) return result;
365
+ if (result.ok) {
366
+ delay = initialDelay;
367
+ return result;
368
+ }
339
369
  const err = await FetchError.fromResponse(result, url.toString());
340
370
  throw err;
341
371
  } catch (e) {
@@ -345,12 +375,51 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
345
375
  } else if (e instanceof FetchError && !HTTP_RETRY_STATUS_CODES.includes(e.status) && e.status >= 400 && e.status < 500) {
346
376
  throw e;
347
377
  } else {
348
- await new Promise((resolve) => setTimeout(resolve, delay));
349
- delay = Math.min(delay * multiplier, maxDelay);
378
+ attempt++;
379
+ if (attempt >= maxRetries) {
380
+ if (debug) {
381
+ console.log(
382
+ `Max retries reached (${attempt}/${maxRetries}), giving up`
383
+ );
384
+ }
385
+ throw e;
386
+ }
387
+ if (!checkRetryBudget(retryBudgetPercent)) {
388
+ if (debug) {
389
+ console.log(
390
+ `Retry budget exhausted (attempt ${attempt}), backing off`
391
+ );
392
+ }
393
+ await new Promise((resolve) => setTimeout(resolve, maxDelay));
394
+ continue;
395
+ }
396
+ let serverMinimumMs = 0;
397
+ if (e instanceof FetchError && e.headers) {
398
+ const retryAfter = e.headers[`retry-after`];
399
+ if (retryAfter) {
400
+ const retryAfterSec = Number(retryAfter);
401
+ if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
402
+ serverMinimumMs = retryAfterSec * 1e3;
403
+ } else {
404
+ const retryDate = Date.parse(retryAfter);
405
+ if (!isNaN(retryDate)) {
406
+ const deltaMs = retryDate - Date.now();
407
+ serverMinimumMs = Math.max(0, Math.min(deltaMs, 36e5));
408
+ }
409
+ }
410
+ }
411
+ }
412
+ const jitter = Math.random() * delay;
413
+ const clientBackoffMs = Math.min(jitter, maxDelay);
414
+ const waitMs = Math.max(serverMinimumMs, clientBackoffMs);
350
415
  if (debug) {
351
- attempt++;
352
- console.log(`Retry attempt #${attempt} after ${delay}ms`);
416
+ const source = serverMinimumMs > 0 ? `server+client` : `client`;
417
+ console.log(
418
+ `Retry attempt #${attempt} after ${waitMs}ms (${source}, serverMin=${serverMinimumMs}ms, clientBackoff=${clientBackoffMs}ms)`
419
+ );
353
420
  }
421
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
422
+ delay = Math.min(delay * multiplier, maxDelay);
354
423
  }
355
424
  }
356
425
  }
@@ -359,6 +428,7 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
359
428
  var NO_BODY_STATUS_CODES = [201, 204, 205];
360
429
  function createFetchWithConsumedMessages(fetchClient) {
361
430
  return async (...args) => {
431
+ var _a, _b;
362
432
  const url = args[0];
363
433
  const res = await fetchClient(...args);
364
434
  try {
@@ -368,6 +438,9 @@ function createFetchWithConsumedMessages(fetchClient) {
368
438
  const text = await res.text();
369
439
  return new Response(text, res);
370
440
  } catch (err) {
441
+ if ((_b = (_a = args[1]) == null ? void 0 : _a.signal) == null ? void 0 : _b.aborted) {
442
+ throw new FetchBackoffAbortError();
443
+ }
371
444
  throw new FetchError(
372
445
  res.status,
373
446
  void 0,
@@ -986,7 +1059,7 @@ requestShape_fn = async function() {
986
1059
  }
987
1060
  const newShapeHandle = e.headers[SHAPE_HANDLE_HEADER] || `${__privateGet(this, _shapeHandle)}-next`;
988
1061
  __privateMethod(this, _ShapeStream_instances, reset_fn).call(this, newShapeHandle);
989
- await __privateMethod(this, _ShapeStream_instances, publish_fn).call(this, e.json);
1062
+ await __privateMethod(this, _ShapeStream_instances, publish_fn).call(this, Array.isArray(e.json) ? e.json : [e.json]);
990
1063
  return __privateMethod(this, _ShapeStream_instances, requestShape_fn).call(this);
991
1064
  } else {
992
1065
  __privateMethod(this, _ShapeStream_instances, sendErrorToSubscribers_fn).call(this, e);
@@ -1129,8 +1202,11 @@ onMessages_fn = async function(batch, isSseMessage = false) {
1129
1202
  }
1130
1203
  };
1131
1204
  fetchShape_fn = async function(opts) {
1132
- if (__privateGet(this, _isUpToDate) && this.options.experimentalLiveSse && !__privateGet(this, _isRefreshing) && !opts.resumingFromPause) {
1205
+ var _a;
1206
+ const useSse = (_a = this.options.liveSse) != null ? _a : this.options.experimentalLiveSse;
1207
+ if (__privateGet(this, _isUpToDate) && useSse && !__privateGet(this, _isRefreshing) && !opts.resumingFromPause) {
1133
1208
  opts.fetchUrl.searchParams.set(EXPERIMENTAL_LIVE_SSE_QUERY_PARAM, `true`);
1209
+ opts.fetchUrl.searchParams.set(LIVE_SSE_QUERY_PARAM, `true`);
1134
1210
  return __privateMethod(this, _ShapeStream_instances, requestShapeSSE_fn).call(this, opts);
1135
1211
  }
1136
1212
  return __privateMethod(this, _ShapeStream_instances, requestShapeLongPoll_fn).call(this, opts);