@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.
- package/dist/flock.d.ts +2 -2
- package/dist/flock.js +4 -3
- package/dist/flock.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/loro.d.ts +2 -2
- package/dist/loro.js +2 -2
- package/dist/{stream-id-DJYQlTE2.d.ts → stream-id-BuXMoCvo.d.ts} +190 -12
- package/dist/{stream-id-CvJLq-0s.js → stream-id-CEp4zEQJ.js} +274 -28
- package/dist/stream-id-CEp4zEQJ.js.map +1 -0
- package/package.json +1 -1
- package/dist/stream-id-CvJLq-0s.js.map +0 -1
|
@@ -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
|
|
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
|
-
|
|
3105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
3629
|
-
|
|
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 =
|
|
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,
|
|
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-
|
|
4019
|
+
//# sourceMappingURL=stream-id-CEp4zEQJ.js.map
|