@loro-dev/streams-crdt 0.13.0 → 0.14.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.
@@ -547,6 +547,28 @@ var AuthProviderError = class extends Error {
547
547
  if (options?.cause !== void 0) this.cause = options.cause;
548
548
  }
549
549
  };
550
+ /**
551
+ * Rejection value used for `waitUntilSynced()` when the local append pipeline
552
+ * fails. Carries the structured {@link TransportError} so downstream callers
553
+ * (e.g. CLIs surfacing "N updates not yet uploaded") can branch on
554
+ * `transportError.code` instead of parsing the message.
555
+ */
556
+ var LocalAppendFailedError = class extends Error {
557
+ transportError;
558
+ constructor(transportError) {
559
+ super(`local append failed: ${transportError.code}`);
560
+ this.name = "LocalAppendFailedError";
561
+ this.transportError = transportError;
562
+ }
563
+ };
564
+ /**
565
+ * True for HTTP 413 responses. The server (or an intermediary proxy) rejected
566
+ * the request body as too large — a deterministic failure for a given payload:
567
+ * retrying the identical bytes cannot succeed while the limit is unchanged.
568
+ */
569
+ function isPayloadTooLargeError(error) {
570
+ return error instanceof HttpError && error.status === 413;
571
+ }
550
572
  function isObject(value) {
551
573
  return typeof value === "object" && value != null;
552
574
  }
@@ -676,6 +698,18 @@ function classifyFetchError(err) {
676
698
  retriable: false,
677
699
  atMs: Date.now()
678
700
  };
701
+ if (status === 413) return {
702
+ code: "payload_too_large",
703
+ message,
704
+ retriable: false,
705
+ atMs: Date.now()
706
+ };
707
+ if (status === 429) return {
708
+ code: "server",
709
+ message,
710
+ retriable: true,
711
+ atMs: Date.now()
712
+ };
679
713
  if (typeof status === "number" && status >= 500 && status < 600) return {
680
714
  code: "server",
681
715
  message,
@@ -763,6 +797,13 @@ function toTransportError(error) {
763
797
  message: error.message,
764
798
  ...error.body !== void 0 && { body: error.body }
765
799
  };
800
+ if (isPayloadTooLargeError(error)) return {
801
+ code: "payload_too_large",
802
+ retryable: false,
803
+ status: 413,
804
+ message: error.message,
805
+ ...error.body !== void 0 && { body: error.body }
806
+ };
766
807
  if (error instanceof ProtocolError) return {
767
808
  code: "protocol_error",
768
809
  retryable: false,
@@ -790,7 +831,7 @@ function toTransportError(error) {
790
831
  message: error.message,
791
832
  ...error.body !== void 0 && { body: error.body }
792
833
  };
793
- if (error instanceof HttpError && error.status >= 500 && error.status < 600) return {
834
+ if (error instanceof HttpError && (error.status === 429 || error.status >= 500 && error.status < 600)) return {
794
835
  code: "server_error",
795
836
  retryable: true,
796
837
  status: error.status,
@@ -1684,6 +1725,9 @@ var EphemeralStreamCrdt = class {
1684
1725
  get status() {
1685
1726
  return state.status;
1686
1727
  },
1728
+ get pendingLocalCount() {
1729
+ return state.pendingLocal.length;
1730
+ },
1687
1731
  onStatusChange: (listener) => this.onStatusChange(state, listener),
1688
1732
  waitUntilSynced: async () => {
1689
1733
  if (state.closed) throw closedSubscriptionError();
@@ -2335,6 +2379,10 @@ function createJoinState(cursor, liveMode = "sse") {
2335
2379
  flushingLocal: false,
2336
2380
  retryAttempt: 0,
2337
2381
  retrySleepController: new AbortController(),
2382
+ readLoopRunning: false,
2383
+ readErrorRetryAttempt: 0,
2384
+ writeErrorRetryAttempt: 0,
2385
+ flushHeadBatchOnly: false,
2338
2386
  cursor,
2339
2387
  liveMode,
2340
2388
  snapshot: createTransportSnapshotState(),
@@ -2346,6 +2394,14 @@ function createJoinState(cursor, liveMode = "sse") {
2346
2394
  async function disposeJoinState(state, options) {
2347
2395
  state.closed = true;
2348
2396
  options.snapshotManager.dispose(state);
2397
+ if (state.readRestartTimer != null) {
2398
+ clearTimeout(state.readRestartTimer);
2399
+ state.readRestartTimer = void 0;
2400
+ }
2401
+ if (state.writeRetryTimer != null) {
2402
+ clearTimeout(state.writeRetryTimer);
2403
+ state.writeRetryTimer = void 0;
2404
+ }
2349
2405
  const requestController = state.requestController;
2350
2406
  state.requestController = void 0;
2351
2407
  const unsubscribeLocal = state.unsubscribeLocal;
@@ -2400,6 +2456,8 @@ var StreamsCrdt = class StreamsCrdt {
2400
2456
  writeOnlyDisabledReason;
2401
2457
  joinState;
2402
2458
  opQueue = Promise.resolve();
2459
+ /** Wall-clock ms of the last durably committed local append, if any. */
2460
+ lastLocalAppendAtMs;
2403
2461
  /**
2404
2462
  * Internal catch-up result used when callers need to handle 410 by
2405
2463
  * preserving local state before rebuilding through bootstrap.
@@ -2519,6 +2577,10 @@ var StreamsCrdt = class StreamsCrdt {
2519
2577
  }
2520
2578
  if ((state.readSubStatus === "ok" || state.readSubStatus === "connecting") && state.writeSubStatus === "ok") return;
2521
2579
  state.retryAttempt = 0;
2580
+ state.readErrorRetryAttempt = 0;
2581
+ state.writeErrorRetryAttempt = 0;
2582
+ this.clearReadRestartTimer(state);
2583
+ this.clearWriteRetryTimer(state);
2522
2584
  state.retrySleepController.abort();
2523
2585
  state.retrySleepController = new AbortController();
2524
2586
  const previousController = state.requestController;
@@ -2535,6 +2597,23 @@ var StreamsCrdt = class StreamsCrdt {
2535
2597
  }
2536
2598
  }
2537
2599
  /**
2600
+ * Reports current local upload health for this transport.
2601
+ *
2602
+ * In join mode this reflects the live room's pending queue; in write-only
2603
+ * mode it reflects the write-only queue. Intended for downstream tooling
2604
+ * (e.g. a CLI showing "N updates not yet uploaded" plus the blocking
2605
+ * error) without needing a `TransportSubscription` reference.
2606
+ */
2607
+ getLocalSyncStats() {
2608
+ const state = this.joinState;
2609
+ return {
2610
+ pendingLocalCount: state != null && !state.closed ? state.pendingLocal.length : this.writeOnlyDisabledReason == null ? this.pendingWriteOnly.length : 0,
2611
+ ...this.lastLocalAppendAtMs !== void 0 && { lastLocalAppendAtMs: this.lastLocalAppendAtMs },
2612
+ ...state?.lastError !== void 0 && { lastError: state.lastError },
2613
+ ...state?.lastWriteError !== void 0 && { lastWriteError: state.lastWriteError }
2614
+ };
2615
+ }
2616
+ /**
2538
2617
  * Reads the latest remote snapshot offset, if the server exposes one.
2539
2618
  */
2540
2619
  async getSnapshotOffset() {
@@ -2763,6 +2842,7 @@ var StreamsCrdt = class StreamsCrdt {
2763
2842
  }
2764
2843
  }
2765
2844
  createSubscription(state, params) {
2845
+ const self = this;
2766
2846
  state.refCount += 1;
2767
2847
  const statusListener = params?.onStatusChange;
2768
2848
  if (statusListener != null) {
@@ -2790,6 +2870,15 @@ var StreamsCrdt = class StreamsCrdt {
2790
2870
  get lastError() {
2791
2871
  return state.lastError;
2792
2872
  },
2873
+ get pendingLocalCount() {
2874
+ return state.pendingLocal.length;
2875
+ },
2876
+ get lastLocalAppendAtMs() {
2877
+ return self.lastLocalAppendAtMs;
2878
+ },
2879
+ get lastWriteError() {
2880
+ return state.lastWriteError;
2881
+ },
2793
2882
  onStatusChange: (listener) => {
2794
2883
  state.statusListeners.add(listener);
2795
2884
  listener(state.status);
@@ -2809,6 +2898,7 @@ var StreamsCrdt = class StreamsCrdt {
2809
2898
  throw new Error(te.code ? `initial sync failed: ${te.code}` : "initial sync failed");
2810
2899
  }
2811
2900
  if (state.writeSubStatus === "error") {
2901
+ if (state.lastWriteError != null) throw new LocalAppendFailedError(state.lastWriteError);
2812
2902
  const msg = state.lastError?.code ? `local append failed: ${state.lastError.code}` : "local append failed";
2813
2903
  throw new Error(msg);
2814
2904
  }
@@ -3093,7 +3183,7 @@ var StreamsCrdt = class StreamsCrdt {
3093
3183
  state.lastError = void 0;
3094
3184
  this.setReadSubStatus(state, "ok");
3095
3185
  if (state.pendingLocal.length > 0) this.flushPendingLocal(state);
3096
- return true;
3186
+ return "recovered";
3097
3187
  } catch (error) {
3098
3188
  const classified = classifyFetchError(error);
3099
3189
  this.logError("live incomplete apply recovery failed", error, {
@@ -3101,8 +3191,12 @@ var StreamsCrdt = class StreamsCrdt {
3101
3191
  code: classified.code
3102
3192
  });
3103
3193
  state.lastError = toTransportError(error);
3104
- this.setReadSubStatus(state, classified.retriable ? "disconnected" : "error");
3105
- return false;
3194
+ if (classified.retriable && !isAuthOrProtocolError(error)) {
3195
+ this.setReadSubStatus(state, "reconnecting");
3196
+ return "retry";
3197
+ }
3198
+ this.setReadSubStatus(state, "error");
3199
+ return "terminal";
3106
3200
  }
3107
3201
  }
3108
3202
  async appendLocalBatch(cursor, batch, pendingCount, streamCursor) {
@@ -3227,6 +3321,7 @@ var StreamsCrdt = class StreamsCrdt {
3227
3321
  this.producerEpoch = ack.epoch;
3228
3322
  this.nextProducerSeq = ack.seq + 1;
3229
3323
  this.frozenLocalAppend = void 0;
3324
+ this.lastLocalAppendAtMs = Date.now();
3230
3325
  }
3231
3326
  abandonFrozenLocalAppend(frozen) {
3232
3327
  if (this.frozenLocalAppend !== frozen) return;
@@ -3348,6 +3443,98 @@ var StreamsCrdt = class StreamsCrdt {
3348
3443
  }
3349
3444
  }
3350
3445
  async runReadLoop(state) {
3446
+ if (state.readLoopRunning) return;
3447
+ state.readLoopRunning = true;
3448
+ this.clearReadRestartTimer(state);
3449
+ try {
3450
+ await this.runReadLoopIterations(state);
3451
+ } finally {
3452
+ state.readLoopRunning = false;
3453
+ }
3454
+ this.maybeScheduleReadLoopRestart(state);
3455
+ }
3456
+ /**
3457
+ * Schedules a background self-restart of the read loop after it exits with
3458
+ * a terminal-class ("error") failure.
3459
+ *
3460
+ * Non-retryable errors (auth failure, protocol error, stream 404) used to
3461
+ * stop the live read loop permanently — the room's remote cursor silently
3462
+ * froze until a process restart. Instead, the room stays in "error" status
3463
+ * (callers still see the failure via `status`/`lastError`) while a capped
3464
+ * exponential backoff (floor 1s, cap = last reconnect-table delay, with
3465
+ * jitter) keeps retrying in the background. Each retry issues fresh
3466
+ * requests, and every request re-runs the auth callback — on a 401/403 the
3467
+ * HTTP layer additionally forces a token refresh (`reason: "unauthorized"`)
3468
+ * and retries that request once — so refreshed credentials are picked up
3469
+ * without any caller intervention.
3470
+ *
3471
+ * "disconnected" (a finite `maxAttempts` budget exhausted on retryable
3472
+ * errors) intentionally stays terminal-until-`rejoin()`: a finite budget is
3473
+ * an explicit opt-in to giving up.
3474
+ */
3475
+ maybeScheduleReadLoopRestart(state) {
3476
+ if (state.closed || this.joinState !== state || !this.readLoopNeedsRestart(state) || state.readRestartTimer != null || state.readLoopRunning) return;
3477
+ const delay = reconnectDelayWithFloor(state.readErrorRetryAttempt, this.reconnectConfig);
3478
+ state.readErrorRetryAttempt += 1;
3479
+ state.readRestartTimer = setTimeout(() => {
3480
+ state.readRestartTimer = void 0;
3481
+ if (state.closed || this.joinState !== state || !this.readLoopNeedsRestart(state)) return;
3482
+ this.runReadLoop(state);
3483
+ }, delay);
3484
+ }
3485
+ /**
3486
+ * Whether an exited read loop still needs a background restart.
3487
+ *
3488
+ * Only ever evaluated after the loop has unwound (from
3489
+ * `maybeScheduleReadLoopRestart`, called at `runReadLoop`'s tail). "error" is
3490
+ * the ordinary terminal-class exit. "reconnecting" here is the anomalous case
3491
+ * where `rejoin()` ran re-entrantly from a status listener *during* the loop's
3492
+ * terminal `setReadSubStatus("error")`: it flipped the sub-status to
3493
+ * "reconnecting" and fire-and-forgot `runReadLoop`, but that no-oped because
3494
+ * the loop was still on the stack — so without this, the loop would exit and
3495
+ * nothing would ever restart it (a permanent stall, the very failure mode this
3496
+ * file removes). "disconnected" (a finite `maxAttempts` budget) stays terminal
3497
+ * by design, and "ok"/"connecting" don't reach this path via an exit.
3498
+ */
3499
+ readLoopNeedsRestart(state) {
3500
+ return state.readSubStatus === "error" || state.readSubStatus === "reconnecting";
3501
+ }
3502
+ clearReadRestartTimer(state) {
3503
+ if (state.readRestartTimer != null) {
3504
+ clearTimeout(state.readRestartTimer);
3505
+ state.readRestartTimer = void 0;
3506
+ }
3507
+ }
3508
+ clearWriteRetryTimer(state) {
3509
+ if (state.writeRetryTimer != null) {
3510
+ clearTimeout(state.writeRetryTimer);
3511
+ state.writeRetryTimer = void 0;
3512
+ }
3513
+ }
3514
+ /**
3515
+ * Applies the budgeted backoff for a retryable read-loop failure.
3516
+ *
3517
+ * Returns `true` when the loop should continue with the next attempt, or
3518
+ * `false` (after surfacing "disconnected") when a finite retry budget is
3519
+ * exhausted.
3520
+ */
3521
+ async awaitReadRetryBackoff(state, error) {
3522
+ const maxAttempts = this.reconnectConfig.maxAttempts;
3523
+ if (state.retryAttempt >= maxAttempts) {
3524
+ this.logError("live read reconnect exhausted", error, {
3525
+ streamUrl: this.streamUrl,
3526
+ attempts: state.retryAttempt
3527
+ });
3528
+ state.lastError = toTransportError(error);
3529
+ this.setReadSubStatus(state, "disconnected");
3530
+ return false;
3531
+ }
3532
+ const delay = computeRetryDelay(state.retryAttempt, this.reconnectConfig, error instanceof HttpError ? error.retryAfterMs : void 0);
3533
+ state.retryAttempt += 1;
3534
+ await sleep(delay, state.retrySleepController.signal);
3535
+ return true;
3536
+ }
3537
+ async runReadLoopIterations(state) {
3351
3538
  state.requestController ??= new AbortController();
3352
3539
  while (!state.closed) {
3353
3540
  const capturedController = state.requestController;
@@ -3480,7 +3667,10 @@ var StreamsCrdt = class StreamsCrdt {
3480
3667
  if (state.closed) return;
3481
3668
  if (error instanceof CrdtApplyIncompleteError) {
3482
3669
  if (capturedController?.signal.aborted && state.requestController === capturedController) state.requestController = new AbortController();
3483
- if (await this.recoverLiveIncompleteRemoteApply(state)) continue;
3670
+ const recovery = await this.recoverLiveIncompleteRemoteApply(state);
3671
+ if (recovery === "recovered") continue;
3672
+ if (recovery === "terminal") return;
3673
+ if (await this.awaitReadRetryBackoff(state, error)) continue;
3484
3674
  return;
3485
3675
  }
3486
3676
  if (capturedController?.signal.aborted) {
@@ -3511,7 +3701,10 @@ var StreamsCrdt = class StreamsCrdt {
3511
3701
  const flushError = abortReason instanceof Error ? abortReason : error;
3512
3702
  if (flushError instanceof CrdtApplyIncompleteError) {
3513
3703
  if (state.requestController === capturedController) state.requestController = new AbortController();
3514
- if (await this.recoverLiveIncompleteRemoteApply(state)) continue;
3704
+ const recovery = await this.recoverLiveIncompleteRemoteApply(state);
3705
+ if (recovery === "recovered") continue;
3706
+ if (recovery === "terminal") return;
3707
+ if (await this.awaitReadRetryBackoff(state, flushError)) continue;
3515
3708
  return;
3516
3709
  }
3517
3710
  if (flushError instanceof Error && !(flushError instanceof DOMException && flushError.name === "AbortError")) {
@@ -3542,19 +3735,7 @@ var StreamsCrdt = class StreamsCrdt {
3542
3735
  }
3543
3736
  this.setReadSubStatus(state, "reconnecting");
3544
3737
  if (sseOpenedAtMs > 0 && Date.now() - sseOpenedAtMs >= 5e3) state.retryAttempt = 0;
3545
- const maxAttempts = this.reconnectConfig.maxAttempts;
3546
- if (state.retryAttempt >= maxAttempts) {
3547
- this.logError("live read reconnect exhausted", error, {
3548
- streamUrl: this.streamUrl,
3549
- attempts: state.retryAttempt
3550
- });
3551
- state.lastError = toTransportError(error);
3552
- this.setReadSubStatus(state, "disconnected");
3553
- return;
3554
- }
3555
- const delay = computeRetryDelay(state.retryAttempt, this.reconnectConfig, error instanceof HttpError ? error.retryAfterMs : void 0);
3556
- state.retryAttempt += 1;
3557
- await sleep(delay, state.retrySleepController.signal);
3738
+ if (!await this.awaitReadRetryBackoff(state, error)) return;
3558
3739
  }
3559
3740
  }
3560
3741
  }
@@ -3564,16 +3745,19 @@ var StreamsCrdt = class StreamsCrdt {
3564
3745
  * Drain up to `MAX_BATCH_BYTES` worth of queued batches from the front of
3565
3746
  * `pendingLocal` and merge them into a single `CrdtUpdateBatch`.
3566
3747
  *
3748
+ * `maxCount` optionally caps how many queued batches may be merged (used to
3749
+ * isolate the head batch after a 413 rejection).
3750
+ *
3567
3751
  * Returns `undefined` when the queue is empty.
3568
3752
  */
3569
- async drainQueuedBatch(pendingLocal) {
3753
+ async drainQueuedBatch(pendingLocal, maxCount) {
3570
3754
  const first = pendingLocal.peekFront();
3571
3755
  if (first == null) return;
3572
3756
  const maxBytes = StreamsCrdt.MAX_BATCH_BYTES;
3573
3757
  let mergedVersion = first.version;
3574
3758
  const allUpdates = Array.from(first.updates);
3575
3759
  let count = 1;
3576
- while (count < pendingLocal.length) {
3760
+ while (count < pendingLocal.length && (maxCount == null || count < maxCount)) {
3577
3761
  const next = pendingLocal.at(count);
3578
3762
  const candidateUpdates = [...allUpdates, ...next.updates];
3579
3763
  if (await this.measureAppendBodyByteLength(candidateUpdates) > maxBytes) break;
@@ -3594,17 +3778,32 @@ var StreamsCrdt = class StreamsCrdt {
3594
3778
  };
3595
3779
  }
3596
3780
  drainPendingBatch(state) {
3597
- return this.drainQueuedBatch(state.pendingLocal);
3781
+ return this.drainQueuedBatch(state.pendingLocal, state.flushHeadBatchOnly ? 1 : void 0);
3598
3782
  }
3599
3783
  drainPendingWriteOnlyBatch() {
3600
3784
  return this.drainQueuedBatch(this.pendingWriteOnly);
3601
3785
  }
3786
+ /**
3787
+ * Drains `pendingLocal` to the server in strict FIFO order.
3788
+ *
3789
+ * FIFO invariant: batches are only popped after a durable append, and a
3790
+ * failing head batch is retried in place — never skipped, dropped, or
3791
+ * reordered. See the `JoinState.pendingLocal` doc for why this strictness
3792
+ * (rather than a version-vector diff upload) is what prevents undetectable
3793
+ * per-author holes.
3794
+ *
3795
+ * Failure handling is self-healing: a terminal-class failure surfaces
3796
+ * `"error"` write status (with `lastWriteError` set) and then retries via
3797
+ * this flush's own backoff timer (`maybeScheduleWriteRetry`) — it does not
3798
+ * depend on a future local op to re-trigger the flush.
3799
+ */
3602
3800
  async flushPendingLocal(state) {
3603
3801
  if (state.flushingLocal || state.closed) return;
3604
3802
  if (!this.cursorManager.isCursorComplete(state.cursor)) return;
3803
+ this.clearWriteRetryTimer(state);
3605
3804
  state.flushingLocal = true;
3606
3805
  try {
3607
- while (!state.closed) {
3806
+ outer: while (!state.closed) {
3608
3807
  const drained = await this.drainPendingBatch(state);
3609
3808
  if (drained == null) return;
3610
3809
  let attempts = 0;
@@ -3615,24 +3814,34 @@ var StreamsCrdt = class StreamsCrdt {
3615
3814
  const committedCount = this.consumeCommittedPendingCount() ?? drained.count;
3616
3815
  for (let i = 0; i < committedCount; i += 1) state.pendingLocal.popFront();
3617
3816
  this.notifySyncWaiters(state, committedCount);
3817
+ state.flushHeadBatchOnly = false;
3818
+ state.lastWriteError = void 0;
3618
3819
  this.setWriteSubStatus(state, "ok");
3619
3820
  this.snapshotManager.schedule(state);
3620
3821
  break;
3621
3822
  } catch (error) {
3823
+ if (isPayloadTooLargeError(error) && drained.count > 1 && this.abandonPendingFrozenLocalAppend()) {
3824
+ state.flushHeadBatchOnly = true;
3825
+ continue outer;
3826
+ }
3622
3827
  const classified = classifyFetchError(error);
3828
+ const transportError = toTransportError(error);
3623
3829
  if (!classified.retriable || isAuthOrProtocolError(error)) {
3830
+ if (isPayloadTooLargeError(error)) state.flushHeadBatchOnly = true;
3624
3831
  this.logError("local append failed", error, {
3625
3832
  streamUrl: this.streamUrl,
3626
3833
  code: classified.code
3627
3834
  });
3628
- state.lastError = toTransportError(error);
3629
- this.rejectSyncWaiters(state, /* @__PURE__ */ new Error(`local append failed: ${classified.code}`));
3835
+ state.lastError = transportError;
3836
+ state.lastWriteError = transportError;
3837
+ this.rejectSyncWaiters(state, new LocalAppendFailedError(transportError));
3630
3838
  this.setWriteSubStatus(state, "error");
3631
3839
  return;
3632
3840
  }
3633
3841
  const maxAttempts = this.reconnectConfig.maxAttempts;
3634
3842
  if (attempts >= maxAttempts) {
3635
- state.lastError = toTransportError(error);
3843
+ state.lastError = transportError;
3844
+ state.lastWriteError = transportError;
3636
3845
  this.rejectSyncWaiters(state, /* @__PURE__ */ new Error("local append retry budget exhausted"));
3637
3846
  this.setWriteSubStatus(state, "error");
3638
3847
  return;
@@ -3645,9 +3854,44 @@ var StreamsCrdt = class StreamsCrdt {
3645
3854
  }
3646
3855
  } finally {
3647
3856
  state.flushingLocal = false;
3857
+ this.maybeScheduleWriteRetry(state);
3648
3858
  }
3649
3859
  }
3650
3860
  /**
3861
+ * Abandons the current frozen "pending" append, fencing its producer
3862
+ * session. Only safe when the server definitively did not append the frozen
3863
+ * body (e.g. a 413 rejection happens before any append). Returns `false`
3864
+ * when there is no matching frozen pending append to abandon.
3865
+ */
3866
+ abandonPendingFrozenLocalAppend() {
3867
+ const frozen = this.frozenLocalAppend;
3868
+ if (frozen == null || frozen.source !== "pending") return false;
3869
+ this.abandonFrozenLocalAppend(frozen);
3870
+ return true;
3871
+ }
3872
+ /**
3873
+ * Schedules a self-driven retry of the pending-local flush after a
3874
+ * terminal-class failure.
3875
+ *
3876
+ * Historically a non-retryable append error parked the queue until the next
3877
+ * local op happened to trigger a flush — an idle session with stranded
3878
+ * updates would never upload them (data stayed local until process
3879
+ * restart). The write queue now owns its retry timer: capped exponential
3880
+ * backoff (floor 1s, cap = last reconnect-table delay, jittered),
3881
+ * independent of new local ops. `rejoin()` and fresh local ops still retry
3882
+ * immediately.
3883
+ */
3884
+ maybeScheduleWriteRetry(state) {
3885
+ if (state.closed || this.joinState !== state || state.writeSubStatus !== "error" || state.pendingLocal.length === 0 || state.writeRetryTimer != null || state.flushingLocal) return;
3886
+ const delay = reconnectDelayWithFloor(state.writeErrorRetryAttempt, this.reconnectConfig);
3887
+ state.writeErrorRetryAttempt += 1;
3888
+ state.writeRetryTimer = setTimeout(() => {
3889
+ state.writeRetryTimer = void 0;
3890
+ if (state.closed || this.joinState !== state) return;
3891
+ this.flushPendingLocal(state);
3892
+ }, delay);
3893
+ }
3894
+ /**
3651
3895
  * Decrements all sync waiters by the number of flushed batches and
3652
3896
  * resolves any that have reached zero.
3653
3897
  */
@@ -3675,11 +3919,13 @@ var StreamsCrdt = class StreamsCrdt {
3675
3919
  });
3676
3920
  }
3677
3921
  setReadSubStatus(state, sub) {
3922
+ if (sub === "ok") state.readErrorRetryAttempt = 0;
3678
3923
  if (state.readSubStatus === sub) return;
3679
3924
  state.readSubStatus = sub;
3680
3925
  this.recomputeJoinStatus(state);
3681
3926
  }
3682
3927
  setWriteSubStatus(state, sub) {
3928
+ if (sub === "ok") state.writeErrorRetryAttempt = 0;
3683
3929
  if (state.writeSubStatus === sub) return;
3684
3930
  state.writeSubStatus = sub;
3685
3931
  this.recomputeJoinStatus(state);
@@ -3768,6 +4014,6 @@ function createStreamUrl(input) {
3768
4014
  return `${baseUrl}/ds/${encodeURIComponent(input.bucketId)}/${encodeURIComponent(streamId)}`;
3769
4015
  }
3770
4016
  //#endregion
3771
- export { InMemoryRemoteCursorStore as a, EphemeralStreamCrdt as c, StreamsCrdt as i, PayloadProtectionError as l, isValidBucketId as n, IndexedDbRemoteCursorStore as o, isValidRillId as r, createInitialRemoteCursor as s, createStreamUrl as t };
4017
+ export { InMemoryRemoteCursorStore as a, EphemeralStreamCrdt as c, StreamsCrdt as i, LocalAppendFailedError as l, isValidBucketId as n, IndexedDbRemoteCursorStore as o, isValidRillId as r, createInitialRemoteCursor as s, createStreamUrl as t, PayloadProtectionError as u };
3772
4018
 
3773
- //# sourceMappingURL=stream-id-CvJLq-0s.js.map
4019
+ //# sourceMappingURL=stream-id-CEp4zEQJ.js.map