@loro-dev/streams-crdt 0.12.3 → 0.14.0

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,
@@ -1112,6 +1153,11 @@ var StreamsTransportClient = class {
1112
1153
  const snapshotOffset = result.result.snapshotOffset;
1113
1154
  return snapshotOffset == null || snapshotOffset.length === 0 ? null : snapshotOffset;
1114
1155
  }
1156
+ async streamSupportsAppendCas() {
1157
+ const result = await this.client.streamSupportsAppendCas();
1158
+ if (result.ok) return result.result;
1159
+ throw this.toTransportFailure(result.result);
1160
+ }
1115
1161
  async putSnapshotAt(snapshotOffset, snapshot) {
1116
1162
  const result = await this.client.putSnapshot({
1117
1163
  offset: snapshotOffset,
@@ -1151,6 +1197,43 @@ var StreamsTransportClient = class {
1151
1197
  };
1152
1198
  throw this.toTransportFailure(error);
1153
1199
  }
1200
+ async appendUpdatesCas(expectedOffset, updates, producerSession) {
1201
+ const result = await this.client.appendCas({
1202
+ expectedOffset,
1203
+ part: {
1204
+ contentType: BINARY_CONTENT_TYPE,
1205
+ body: encodeItems(updates)
1206
+ },
1207
+ producer: {
1208
+ producerId: producerSession.producerId,
1209
+ epoch: producerSession.producerEpoch,
1210
+ seq: producerSession.nextProducerSeq
1211
+ }
1212
+ });
1213
+ if (result.ok) {
1214
+ if (result.result.kind === "mismatch") return {
1215
+ kind: "cas-mismatch",
1216
+ expectedOffset: result.result.expectedOffset,
1217
+ currentOffset: result.result.currentOffset
1218
+ };
1219
+ return {
1220
+ kind: "ok",
1221
+ ackEpoch: result.result.value.producer?.epoch ?? producerSession.producerEpoch,
1222
+ ackSeq: result.result.value.producer?.seq ?? producerSession.nextProducerSeq,
1223
+ nextOffset: result.result.value.nextOffset
1224
+ };
1225
+ }
1226
+ const error = result.result;
1227
+ if (error.code === "conflict" && error.detail?.kind === "producer_seq_gap") return {
1228
+ kind: "seq-gap",
1229
+ expectedSeq: error.detail.expectedSeq
1230
+ };
1231
+ if (error.code === "forbidden" && error.detail?.kind === "stale_producer_epoch") return {
1232
+ kind: "stale-epoch",
1233
+ serverEpoch: error.detail.serverEpoch
1234
+ };
1235
+ throw this.toTransportFailure(error);
1236
+ }
1154
1237
  async appendItems(updates) {
1155
1238
  const result = await this.client.append({ part: {
1156
1239
  contentType: BINARY_CONTENT_TYPE,
@@ -1642,6 +1725,9 @@ var EphemeralStreamCrdt = class {
1642
1725
  get status() {
1643
1726
  return state.status;
1644
1727
  },
1728
+ get pendingLocalCount() {
1729
+ return state.pendingLocal.length;
1730
+ },
1645
1731
  onStatusChange: (listener) => this.onStatusChange(state, listener),
1646
1732
  waitUntilSynced: async () => {
1647
1733
  if (state.closed) throw closedSubscriptionError();
@@ -2293,6 +2379,10 @@ function createJoinState(cursor, liveMode = "sse") {
2293
2379
  flushingLocal: false,
2294
2380
  retryAttempt: 0,
2295
2381
  retrySleepController: new AbortController(),
2382
+ readLoopRunning: false,
2383
+ readErrorRetryAttempt: 0,
2384
+ writeErrorRetryAttempt: 0,
2385
+ flushHeadBatchOnly: false,
2296
2386
  cursor,
2297
2387
  liveMode,
2298
2388
  snapshot: createTransportSnapshotState(),
@@ -2304,6 +2394,14 @@ function createJoinState(cursor, liveMode = "sse") {
2304
2394
  async function disposeJoinState(state, options) {
2305
2395
  state.closed = true;
2306
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
+ }
2307
2405
  const requestController = state.requestController;
2308
2406
  state.requestController = void 0;
2309
2407
  const unsubscribeLocal = state.unsubscribeLocal;
@@ -2358,6 +2456,8 @@ var StreamsCrdt = class StreamsCrdt {
2358
2456
  writeOnlyDisabledReason;
2359
2457
  joinState;
2360
2458
  opQueue = Promise.resolve();
2459
+ /** Wall-clock ms of the last durably committed local append, if any. */
2460
+ lastLocalAppendAtMs;
2361
2461
  /**
2362
2462
  * Internal catch-up result used when callers need to handle 410 by
2363
2463
  * preserving local state before rebuilding through bootstrap.
@@ -2477,6 +2577,10 @@ var StreamsCrdt = class StreamsCrdt {
2477
2577
  }
2478
2578
  if ((state.readSubStatus === "ok" || state.readSubStatus === "connecting") && state.writeSubStatus === "ok") return;
2479
2579
  state.retryAttempt = 0;
2580
+ state.readErrorRetryAttempt = 0;
2581
+ state.writeErrorRetryAttempt = 0;
2582
+ this.clearReadRestartTimer(state);
2583
+ this.clearWriteRetryTimer(state);
2480
2584
  state.retrySleepController.abort();
2481
2585
  state.retrySleepController = new AbortController();
2482
2586
  const previousController = state.requestController;
@@ -2493,6 +2597,23 @@ var StreamsCrdt = class StreamsCrdt {
2493
2597
  }
2494
2598
  }
2495
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
+ /**
2496
2617
  * Reads the latest remote snapshot offset, if the server exposes one.
2497
2618
  */
2498
2619
  async getSnapshotOffset() {
@@ -2721,6 +2842,7 @@ var StreamsCrdt = class StreamsCrdt {
2721
2842
  }
2722
2843
  }
2723
2844
  createSubscription(state, params) {
2845
+ const self = this;
2724
2846
  state.refCount += 1;
2725
2847
  const statusListener = params?.onStatusChange;
2726
2848
  if (statusListener != null) {
@@ -2748,6 +2870,15 @@ var StreamsCrdt = class StreamsCrdt {
2748
2870
  get lastError() {
2749
2871
  return state.lastError;
2750
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
+ },
2751
2882
  onStatusChange: (listener) => {
2752
2883
  state.statusListeners.add(listener);
2753
2884
  listener(state.status);
@@ -2767,6 +2898,7 @@ var StreamsCrdt = class StreamsCrdt {
2767
2898
  throw new Error(te.code ? `initial sync failed: ${te.code}` : "initial sync failed");
2768
2899
  }
2769
2900
  if (state.writeSubStatus === "error") {
2901
+ if (state.lastWriteError != null) throw new LocalAppendFailedError(state.lastWriteError);
2770
2902
  const msg = state.lastError?.code ? `local append failed: ${state.lastError.code}` : "local append failed";
2771
2903
  throw new Error(msg);
2772
2904
  }
@@ -3051,7 +3183,7 @@ var StreamsCrdt = class StreamsCrdt {
3051
3183
  state.lastError = void 0;
3052
3184
  this.setReadSubStatus(state, "ok");
3053
3185
  if (state.pendingLocal.length > 0) this.flushPendingLocal(state);
3054
- return true;
3186
+ return "recovered";
3055
3187
  } catch (error) {
3056
3188
  const classified = classifyFetchError(error);
3057
3189
  this.logError("live incomplete apply recovery failed", error, {
@@ -3059,8 +3191,12 @@ var StreamsCrdt = class StreamsCrdt {
3059
3191
  code: classified.code
3060
3192
  });
3061
3193
  state.lastError = toTransportError(error);
3062
- this.setReadSubStatus(state, classified.retriable ? "disconnected" : "error");
3063
- 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";
3064
3200
  }
3065
3201
  }
3066
3202
  async appendLocalBatch(cursor, batch, pendingCount, streamCursor) {
@@ -3157,6 +3293,7 @@ var StreamsCrdt = class StreamsCrdt {
3157
3293
  };
3158
3294
  continue;
3159
3295
  }
3296
+ if (result.kind === "cas-mismatch") throw new Error("unexpected CAS mismatch from unconditional append");
3160
3297
  frozen.producerSession = {
3161
3298
  ...frozen.producerSession,
3162
3299
  producerEpoch: result.serverEpoch + 1,
@@ -3184,6 +3321,7 @@ var StreamsCrdt = class StreamsCrdt {
3184
3321
  this.producerEpoch = ack.epoch;
3185
3322
  this.nextProducerSeq = ack.seq + 1;
3186
3323
  this.frozenLocalAppend = void 0;
3324
+ this.lastLocalAppendAtMs = Date.now();
3187
3325
  }
3188
3326
  abandonFrozenLocalAppend(frozen) {
3189
3327
  if (this.frozenLocalAppend !== frozen) return;
@@ -3305,6 +3443,98 @@ var StreamsCrdt = class StreamsCrdt {
3305
3443
  }
3306
3444
  }
3307
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) {
3308
3538
  state.requestController ??= new AbortController();
3309
3539
  while (!state.closed) {
3310
3540
  const capturedController = state.requestController;
@@ -3437,7 +3667,10 @@ var StreamsCrdt = class StreamsCrdt {
3437
3667
  if (state.closed) return;
3438
3668
  if (error instanceof CrdtApplyIncompleteError) {
3439
3669
  if (capturedController?.signal.aborted && state.requestController === capturedController) state.requestController = new AbortController();
3440
- 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;
3441
3674
  return;
3442
3675
  }
3443
3676
  if (capturedController?.signal.aborted) {
@@ -3468,7 +3701,10 @@ var StreamsCrdt = class StreamsCrdt {
3468
3701
  const flushError = abortReason instanceof Error ? abortReason : error;
3469
3702
  if (flushError instanceof CrdtApplyIncompleteError) {
3470
3703
  if (state.requestController === capturedController) state.requestController = new AbortController();
3471
- 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;
3472
3708
  return;
3473
3709
  }
3474
3710
  if (flushError instanceof Error && !(flushError instanceof DOMException && flushError.name === "AbortError")) {
@@ -3499,19 +3735,7 @@ var StreamsCrdt = class StreamsCrdt {
3499
3735
  }
3500
3736
  this.setReadSubStatus(state, "reconnecting");
3501
3737
  if (sseOpenedAtMs > 0 && Date.now() - sseOpenedAtMs >= 5e3) state.retryAttempt = 0;
3502
- const maxAttempts = this.reconnectConfig.maxAttempts;
3503
- if (state.retryAttempt >= maxAttempts) {
3504
- this.logError("live read reconnect exhausted", error, {
3505
- streamUrl: this.streamUrl,
3506
- attempts: state.retryAttempt
3507
- });
3508
- state.lastError = toTransportError(error);
3509
- this.setReadSubStatus(state, "disconnected");
3510
- return;
3511
- }
3512
- const delay = computeRetryDelay(state.retryAttempt, this.reconnectConfig, error instanceof HttpError ? error.retryAfterMs : void 0);
3513
- state.retryAttempt += 1;
3514
- await sleep(delay, state.retrySleepController.signal);
3738
+ if (!await this.awaitReadRetryBackoff(state, error)) return;
3515
3739
  }
3516
3740
  }
3517
3741
  }
@@ -3521,16 +3745,19 @@ var StreamsCrdt = class StreamsCrdt {
3521
3745
  * Drain up to `MAX_BATCH_BYTES` worth of queued batches from the front of
3522
3746
  * `pendingLocal` and merge them into a single `CrdtUpdateBatch`.
3523
3747
  *
3748
+ * `maxCount` optionally caps how many queued batches may be merged (used to
3749
+ * isolate the head batch after a 413 rejection).
3750
+ *
3524
3751
  * Returns `undefined` when the queue is empty.
3525
3752
  */
3526
- async drainQueuedBatch(pendingLocal) {
3753
+ async drainQueuedBatch(pendingLocal, maxCount) {
3527
3754
  const first = pendingLocal.peekFront();
3528
3755
  if (first == null) return;
3529
3756
  const maxBytes = StreamsCrdt.MAX_BATCH_BYTES;
3530
3757
  let mergedVersion = first.version;
3531
3758
  const allUpdates = Array.from(first.updates);
3532
3759
  let count = 1;
3533
- while (count < pendingLocal.length) {
3760
+ while (count < pendingLocal.length && (maxCount == null || count < maxCount)) {
3534
3761
  const next = pendingLocal.at(count);
3535
3762
  const candidateUpdates = [...allUpdates, ...next.updates];
3536
3763
  if (await this.measureAppendBodyByteLength(candidateUpdates) > maxBytes) break;
@@ -3551,17 +3778,32 @@ var StreamsCrdt = class StreamsCrdt {
3551
3778
  };
3552
3779
  }
3553
3780
  drainPendingBatch(state) {
3554
- return this.drainQueuedBatch(state.pendingLocal);
3781
+ return this.drainQueuedBatch(state.pendingLocal, state.flushHeadBatchOnly ? 1 : void 0);
3555
3782
  }
3556
3783
  drainPendingWriteOnlyBatch() {
3557
3784
  return this.drainQueuedBatch(this.pendingWriteOnly);
3558
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
+ */
3559
3800
  async flushPendingLocal(state) {
3560
3801
  if (state.flushingLocal || state.closed) return;
3561
3802
  if (!this.cursorManager.isCursorComplete(state.cursor)) return;
3803
+ this.clearWriteRetryTimer(state);
3562
3804
  state.flushingLocal = true;
3563
3805
  try {
3564
- while (!state.closed) {
3806
+ outer: while (!state.closed) {
3565
3807
  const drained = await this.drainPendingBatch(state);
3566
3808
  if (drained == null) return;
3567
3809
  let attempts = 0;
@@ -3572,24 +3814,34 @@ var StreamsCrdt = class StreamsCrdt {
3572
3814
  const committedCount = this.consumeCommittedPendingCount() ?? drained.count;
3573
3815
  for (let i = 0; i < committedCount; i += 1) state.pendingLocal.popFront();
3574
3816
  this.notifySyncWaiters(state, committedCount);
3817
+ state.flushHeadBatchOnly = false;
3818
+ state.lastWriteError = void 0;
3575
3819
  this.setWriteSubStatus(state, "ok");
3576
3820
  this.snapshotManager.schedule(state);
3577
3821
  break;
3578
3822
  } catch (error) {
3823
+ if (isPayloadTooLargeError(error) && drained.count > 1 && this.abandonPendingFrozenLocalAppend()) {
3824
+ state.flushHeadBatchOnly = true;
3825
+ continue outer;
3826
+ }
3579
3827
  const classified = classifyFetchError(error);
3828
+ const transportError = toTransportError(error);
3580
3829
  if (!classified.retriable || isAuthOrProtocolError(error)) {
3830
+ if (isPayloadTooLargeError(error)) state.flushHeadBatchOnly = true;
3581
3831
  this.logError("local append failed", error, {
3582
3832
  streamUrl: this.streamUrl,
3583
3833
  code: classified.code
3584
3834
  });
3585
- state.lastError = toTransportError(error);
3586
- 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));
3587
3838
  this.setWriteSubStatus(state, "error");
3588
3839
  return;
3589
3840
  }
3590
3841
  const maxAttempts = this.reconnectConfig.maxAttempts;
3591
3842
  if (attempts >= maxAttempts) {
3592
- state.lastError = toTransportError(error);
3843
+ state.lastError = transportError;
3844
+ state.lastWriteError = transportError;
3593
3845
  this.rejectSyncWaiters(state, /* @__PURE__ */ new Error("local append retry budget exhausted"));
3594
3846
  this.setWriteSubStatus(state, "error");
3595
3847
  return;
@@ -3602,9 +3854,44 @@ var StreamsCrdt = class StreamsCrdt {
3602
3854
  }
3603
3855
  } finally {
3604
3856
  state.flushingLocal = false;
3857
+ this.maybeScheduleWriteRetry(state);
3605
3858
  }
3606
3859
  }
3607
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
+ /**
3608
3895
  * Decrements all sync waiters by the number of flushed batches and
3609
3896
  * resolves any that have reached zero.
3610
3897
  */
@@ -3632,11 +3919,13 @@ var StreamsCrdt = class StreamsCrdt {
3632
3919
  });
3633
3920
  }
3634
3921
  setReadSubStatus(state, sub) {
3922
+ if (sub === "ok") state.readErrorRetryAttempt = 0;
3635
3923
  if (state.readSubStatus === sub) return;
3636
3924
  state.readSubStatus = sub;
3637
3925
  this.recomputeJoinStatus(state);
3638
3926
  }
3639
3927
  setWriteSubStatus(state, sub) {
3928
+ if (sub === "ok") state.writeErrorRetryAttempt = 0;
3640
3929
  if (state.writeSubStatus === sub) return;
3641
3930
  state.writeSubStatus = sub;
3642
3931
  this.recomputeJoinStatus(state);
@@ -3725,6 +4014,6 @@ function createStreamUrl(input) {
3725
4014
  return `${baseUrl}/ds/${encodeURIComponent(input.bucketId)}/${encodeURIComponent(streamId)}`;
3726
4015
  }
3727
4016
  //#endregion
3728
- 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 };
3729
4018
 
3730
- //# sourceMappingURL=stream-id-BZhLwAOj.js.map
4019
+ //# sourceMappingURL=stream-id-CEp4zEQJ.js.map