@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.
- package/dist/flock.d.ts +2 -2
- package/dist/flock.js +2 -2
- 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-BZhLwAOj.js → stream-id-CEp4zEQJ.js} +317 -28
- package/dist/stream-id-CEp4zEQJ.js.map +1 -0
- package/package.json +3 -3
- package/dist/stream-id-BZhLwAOj.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,
|
|
@@ -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
|
|
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
|
-
|
|
3063
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
3586
|
-
|
|
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 =
|
|
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,
|
|
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-
|
|
4019
|
+
//# sourceMappingURL=stream-id-CEp4zEQJ.js.map
|