@loro-dev/streams-crdt 0.15.0 → 0.16.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/README.md +11 -8
- package/dist/flock.d.ts +1 -1
- package/dist/flock.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/loro.d.ts +9 -11
- package/dist/loro.js +43 -3
- package/dist/loro.js.map +1 -1
- package/dist/{stream-id-CO65-58E.d.ts → stream-id-BEEukuNT.d.ts} +68 -3
- package/dist/{stream-id-CggbAC6b.js → stream-id-DwQujw_H.js} +337 -81
- package/dist/stream-id-DwQujw_H.js.map +1 -0
- package/package.json +2 -2
- package/dist/stream-id-CggbAC6b.js.map +0 -1
|
@@ -261,27 +261,29 @@ var PayloadProtectionRuntime = class {
|
|
|
261
261
|
}
|
|
262
262
|
return out;
|
|
263
263
|
}
|
|
264
|
-
async encodeSnapshot(snapshot) {
|
|
264
|
+
async encodeSnapshot(snapshot, continuationOffset) {
|
|
265
|
+
checkContinuationOffset(continuationOffset);
|
|
265
266
|
if (this.writePolicy === "plaintext") return snapshot;
|
|
266
|
-
return await this.sealPayload("snapshot", snapshot);
|
|
267
|
+
return await this.sealPayload("snapshot", snapshot, continuationOffset);
|
|
267
268
|
}
|
|
268
|
-
async decodeSnapshot(snapshot) {
|
|
269
|
-
|
|
269
|
+
async decodeSnapshot(snapshot, continuationOffset) {
|
|
270
|
+
checkContinuationOffset(continuationOffset);
|
|
271
|
+
if (isEncryptedEnvelope(snapshot)) return await this.openPayload("snapshot", snapshot, continuationOffset);
|
|
270
272
|
this.assertPlaintextAllowed("snapshot");
|
|
271
273
|
return snapshot;
|
|
272
274
|
}
|
|
273
|
-
async sealPayload(kind, plaintext) {
|
|
274
|
-
return await this.sealProviderPayload(kind, plaintext);
|
|
275
|
+
async sealPayload(kind, plaintext, continuationOffset) {
|
|
276
|
+
return await this.sealProviderPayload(kind, plaintext, continuationOffset);
|
|
275
277
|
}
|
|
276
|
-
async openPayload(expectedKind, envelope) {
|
|
278
|
+
async openPayload(expectedKind, envelope, continuationOffset) {
|
|
277
279
|
const version = envelope[4];
|
|
278
|
-
if (version === PROVIDER_ENVELOPE_VERSION) return await this.openProviderPayload(expectedKind, envelope);
|
|
280
|
+
if (version === PROVIDER_ENVELOPE_VERSION) return await this.openProviderPayload(expectedKind, envelope, continuationOffset);
|
|
279
281
|
throw new PayloadProtectionError("invalid_envelope", `unsupported streams-crdt protected payload envelope version '${String(version)}'`);
|
|
280
282
|
}
|
|
281
|
-
async sealProviderPayload(kind, plaintext) {
|
|
283
|
+
async sealProviderPayload(kind, plaintext, continuationOffset) {
|
|
282
284
|
const provider = this.provider;
|
|
283
285
|
if (provider == null) throw new PayloadProtectionError("encrypt_failed", "provider-backed E2EE writes require a provider");
|
|
284
|
-
const context = createProviderContext(kind);
|
|
286
|
+
const context = createProviderContext(kind, continuationOffset);
|
|
285
287
|
let authenticatedHeader;
|
|
286
288
|
let additionalDataCalls = 0;
|
|
287
289
|
let result;
|
|
@@ -314,12 +316,12 @@ var PayloadProtectionRuntime = class {
|
|
|
314
316
|
throw new PayloadProtectionError(reason, payloadProtectionFailureMessage(reason));
|
|
315
317
|
}
|
|
316
318
|
}
|
|
317
|
-
async openProviderPayload(expectedKind, envelope) {
|
|
319
|
+
async openProviderPayload(expectedKind, envelope, continuationOffset) {
|
|
318
320
|
const parsed = parseProviderEnvelope(envelope);
|
|
319
321
|
if (parsed.kind !== expectedKind) throw new PayloadProtectionError("wrong_payload_kind", `expected ${expectedKind} payload, got ${parsed.kind}`);
|
|
320
322
|
const provider = this.provider;
|
|
321
323
|
if (provider == null) throw new PayloadProtectionError("missing_read_key", "provider-backed E2EE reads require a provider");
|
|
322
|
-
const context = createProviderContext(parsed.kind);
|
|
324
|
+
const context = createProviderContext(parsed.kind, continuationOffset);
|
|
323
325
|
try {
|
|
324
326
|
const plaintext = await provider.open({
|
|
325
327
|
sealed: parsed.sealed,
|
|
@@ -370,13 +372,26 @@ function payloadProtectionFailureMessage(reason) {
|
|
|
370
372
|
function isEncryptedEnvelope(payload) {
|
|
371
373
|
return payload.byteLength >= MAGIC.byteLength && MAGIC.every((byte, index) => payload[index] === byte);
|
|
372
374
|
}
|
|
373
|
-
function createProviderContext(kind) {
|
|
375
|
+
function createProviderContext(kind, continuationOffset) {
|
|
376
|
+
if (kind === "snapshot") {
|
|
377
|
+
checkContinuationOffset(continuationOffset);
|
|
378
|
+
return {
|
|
379
|
+
protocol: PAYLOAD_PROTECTION_PROTOCOL,
|
|
380
|
+
version: PROVIDER_ENVELOPE_VERSION,
|
|
381
|
+
kind,
|
|
382
|
+
continuationOffset
|
|
383
|
+
};
|
|
384
|
+
}
|
|
374
385
|
return {
|
|
375
386
|
protocol: PAYLOAD_PROTECTION_PROTOCOL,
|
|
376
387
|
version: PROVIDER_ENVELOPE_VERSION,
|
|
377
388
|
kind
|
|
378
389
|
};
|
|
379
390
|
}
|
|
391
|
+
/** Opaque offset token only. Not a timestamp, Org, bucket, or role. */
|
|
392
|
+
function checkContinuationOffset(value) {
|
|
393
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 1024 || value === "now") throw new PayloadProtectionError("invalid_envelope", "invalid continuation offset");
|
|
394
|
+
}
|
|
380
395
|
function createProviderEnvelopeHeader(kind, providerHeader) {
|
|
381
396
|
const normalizedHeader = normalizeProviderHeader(providerHeader);
|
|
382
397
|
const header = new Uint8Array(PROVIDER_PREFIX_BYTES + normalizedHeader.byteLength);
|
|
@@ -1083,11 +1098,13 @@ var StreamsTransportClient = class {
|
|
|
1083
1098
|
async bootstrap(signal, timeoutMode = "default") {
|
|
1084
1099
|
const result = await (timeoutMode === "initial" ? this.initialSyncClient : this.client).bootstrap({ signal });
|
|
1085
1100
|
if (!result.ok) throw this.toTransportFailure(result.result);
|
|
1086
|
-
const
|
|
1101
|
+
const snapshotOffset = result.result.snapshotOffset;
|
|
1102
|
+
const snapshot = snapshotOffset === "-1" ? new Uint8Array() : result.result.snapshot?.body ?? new Uint8Array();
|
|
1087
1103
|
return {
|
|
1088
1104
|
nextOffset: result.result.nextOffset,
|
|
1089
1105
|
upToDate: result.result.upToDate,
|
|
1090
1106
|
snapshot,
|
|
1107
|
+
snapshotOffset,
|
|
1091
1108
|
updates: result.result.updates.flatMap((part) => decodeMultipartPartItems(part.body)),
|
|
1092
1109
|
streamCursor: result.result.cursor
|
|
1093
1110
|
};
|
|
@@ -1265,14 +1282,14 @@ var StreamsTransportClient = class {
|
|
|
1265
1282
|
}
|
|
1266
1283
|
async fetchAuthorized(input, init, timeoutMs, phase) {
|
|
1267
1284
|
const requestUrl = this.routeRequestUrl(input, init);
|
|
1268
|
-
const firstToken = await this.resolveToken({ reason: "request" });
|
|
1285
|
+
const firstToken = this.authProvider == null ? void 0 : await withTokenTimeout(this.resolveToken({ reason: "request" }), timeoutMs, phase);
|
|
1269
1286
|
const firstResponse = await this.fetchWithTimeout(requestUrl, {
|
|
1270
1287
|
...init,
|
|
1271
1288
|
headers: this.attachAuthorization(init.headers, firstToken)
|
|
1272
1289
|
}, timeoutMs, phase);
|
|
1273
1290
|
if (this.authProvider == null || firstResponse.status !== 401 && firstResponse.status !== 403) return firstResponse;
|
|
1274
1291
|
await firstResponse.body?.cancel().catch(() => {});
|
|
1275
|
-
const refreshedToken = await this.refreshTokenAfterFailure(firstResponse.status, firstToken);
|
|
1292
|
+
const refreshedToken = await this.refreshTokenAfterFailure(firstResponse.status, firstToken, timeoutMs, phase);
|
|
1276
1293
|
return this.fetchWithTimeout(requestUrl, {
|
|
1277
1294
|
...init,
|
|
1278
1295
|
headers: this.attachAuthorization(init.headers, refreshedToken)
|
|
@@ -1299,16 +1316,25 @@ var StreamsTransportClient = class {
|
|
|
1299
1316
|
async resolveToken(context) {
|
|
1300
1317
|
return await this.authProvider?.(context);
|
|
1301
1318
|
}
|
|
1302
|
-
async refreshTokenAfterFailure(status, previousToken) {
|
|
1319
|
+
async refreshTokenAfterFailure(status, previousToken, timeoutMs, phase) {
|
|
1303
1320
|
if (this.authProvider == null) return;
|
|
1304
|
-
if (this.authRefreshInFlight == null)
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1321
|
+
if (this.authRefreshInFlight == null) {
|
|
1322
|
+
const inFlight = this.resolveToken({
|
|
1323
|
+
reason: "unauthorized",
|
|
1324
|
+
status,
|
|
1325
|
+
previousToken
|
|
1326
|
+
}).finally(() => {
|
|
1327
|
+
if (this.authRefreshInFlight === inFlight) this.authRefreshInFlight = void 0;
|
|
1328
|
+
});
|
|
1329
|
+
this.authRefreshInFlight = inFlight;
|
|
1330
|
+
}
|
|
1331
|
+
const inFlight = this.authRefreshInFlight;
|
|
1332
|
+
try {
|
|
1333
|
+
return await withTokenTimeout(inFlight, timeoutMs, phase);
|
|
1334
|
+
} catch (error) {
|
|
1335
|
+
if (error instanceof TimeoutError && this.authRefreshInFlight === inFlight) this.authRefreshInFlight = void 0;
|
|
1336
|
+
throw error;
|
|
1337
|
+
}
|
|
1312
1338
|
}
|
|
1313
1339
|
attachAuthorization(base, token) {
|
|
1314
1340
|
const headers = new Headers(base);
|
|
@@ -1502,8 +1528,26 @@ function parseRetryAfterMs(value) {
|
|
|
1502
1528
|
const delta = parsed - Date.now();
|
|
1503
1529
|
return delta > 0 ? delta : void 0;
|
|
1504
1530
|
}
|
|
1531
|
+
async function withTokenTimeout(promise, timeoutMs, phase) {
|
|
1532
|
+
if (!(timeoutMs > 0)) return await promise;
|
|
1533
|
+
let timer;
|
|
1534
|
+
try {
|
|
1535
|
+
return await Promise.race([promise, new Promise((_, reject) => {
|
|
1536
|
+
timer = setTimeout(() => {
|
|
1537
|
+
reject(new TimeoutError(phase, timeoutMs));
|
|
1538
|
+
}, timeoutMs);
|
|
1539
|
+
})]);
|
|
1540
|
+
} finally {
|
|
1541
|
+
clearTimeout(timer);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1505
1544
|
//#endregion
|
|
1506
1545
|
//#region src/ephemeral-runtime.ts
|
|
1546
|
+
/**
|
|
1547
|
+
* Superseded entries are skipped lazily; once they outnumber live entries
|
|
1548
|
+
* (and exceed this floor) the queue is rebuilt so it stays O(live entries).
|
|
1549
|
+
*/
|
|
1550
|
+
const PENDING_LOCAL_REBUILD_MIN_SUPERSEDED = 64;
|
|
1507
1551
|
function createNoopAdapter() {
|
|
1508
1552
|
return {
|
|
1509
1553
|
emptyVersion: () => ({}),
|
|
@@ -1624,6 +1668,11 @@ var EphemeralStreamCrdt = class {
|
|
|
1624
1668
|
writeStatus: "connecting",
|
|
1625
1669
|
statusListeners: /* @__PURE__ */ new Set(),
|
|
1626
1670
|
pendingLocal: new Deque(),
|
|
1671
|
+
pendingLocalByKey: /* @__PURE__ */ new Map(),
|
|
1672
|
+
pendingLocalSuperseded: 0,
|
|
1673
|
+
pendingLocalCount: 0,
|
|
1674
|
+
lastChainId: 0,
|
|
1675
|
+
liveChains: 0,
|
|
1627
1676
|
syncWaiters: [],
|
|
1628
1677
|
flushingLocal: false,
|
|
1629
1678
|
retryAttempt: 0,
|
|
@@ -1674,15 +1723,17 @@ var EphemeralStreamCrdt = class {
|
|
|
1674
1723
|
return state.status;
|
|
1675
1724
|
},
|
|
1676
1725
|
get pendingLocalCount() {
|
|
1677
|
-
return state.
|
|
1726
|
+
return state.pendingLocalCount;
|
|
1678
1727
|
},
|
|
1679
1728
|
onStatusChange: (listener) => this.onStatusChange(state, listener),
|
|
1680
1729
|
waitUntilSynced: async () => {
|
|
1681
1730
|
if (state.closed) throw closedSubscriptionError();
|
|
1682
|
-
const remaining = state.
|
|
1731
|
+
const remaining = state.liveChains;
|
|
1732
|
+
const lastChainId = state.lastChainId;
|
|
1683
1733
|
if (remaining === 0) return;
|
|
1684
1734
|
return new Promise((resolve, reject) => {
|
|
1685
1735
|
state.syncWaiters.push({
|
|
1736
|
+
lastChainId,
|
|
1686
1737
|
remaining,
|
|
1687
1738
|
resolve,
|
|
1688
1739
|
reject
|
|
@@ -1812,7 +1863,33 @@ var EphemeralStreamCrdt = class {
|
|
|
1812
1863
|
}
|
|
1813
1864
|
enqueueLocal(state, update) {
|
|
1814
1865
|
if (state.closed || update == null || update.byteLength === 0) return;
|
|
1815
|
-
|
|
1866
|
+
const key = this.localUpdateKey(update);
|
|
1867
|
+
const previous = key == null ? void 0 : state.pendingLocalByKey.get(key);
|
|
1868
|
+
let chainId;
|
|
1869
|
+
if (previous == null) {
|
|
1870
|
+
state.lastChainId += 1;
|
|
1871
|
+
state.liveChains += 1;
|
|
1872
|
+
chainId = state.lastChainId;
|
|
1873
|
+
} else chainId = previous.chainId;
|
|
1874
|
+
const entry = {
|
|
1875
|
+
update,
|
|
1876
|
+
key,
|
|
1877
|
+
count: 1,
|
|
1878
|
+
chainId,
|
|
1879
|
+
superseded: false,
|
|
1880
|
+
inFlight: false
|
|
1881
|
+
};
|
|
1882
|
+
if (previous != null) {
|
|
1883
|
+
previous.superseded = true;
|
|
1884
|
+
previous.update = null;
|
|
1885
|
+
entry.count += previous.count;
|
|
1886
|
+
previous.count = 0;
|
|
1887
|
+
state.pendingLocalSuperseded += 1;
|
|
1888
|
+
}
|
|
1889
|
+
if (key != null) state.pendingLocalByKey.set(key, entry);
|
|
1890
|
+
state.pendingLocal.pushBack(entry);
|
|
1891
|
+
state.pendingLocalCount += 1;
|
|
1892
|
+
this.maybeRebuildPendingLocal(state);
|
|
1816
1893
|
if (state.writeStatus !== "connecting") this.flushPendingLocal(state);
|
|
1817
1894
|
}
|
|
1818
1895
|
async flushPendingLocal(state) {
|
|
@@ -1820,42 +1897,94 @@ var EphemeralStreamCrdt = class {
|
|
|
1820
1897
|
state.flushingLocal = true;
|
|
1821
1898
|
try {
|
|
1822
1899
|
while (!state.closed) {
|
|
1823
|
-
const update = state.pendingLocal.peekFront();
|
|
1824
|
-
if (update == null) return;
|
|
1825
1900
|
let attempt = 0;
|
|
1826
|
-
while (!state.closed)
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
this.
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1901
|
+
while (!state.closed) {
|
|
1902
|
+
const entry = this.nextPendingLocal(state);
|
|
1903
|
+
if (entry == null) return;
|
|
1904
|
+
try {
|
|
1905
|
+
await this.client.appendEphemeralUpdate(entry.update);
|
|
1906
|
+
state.pendingLocal.popFront();
|
|
1907
|
+
state.pendingLocalCount -= entry.count;
|
|
1908
|
+
state.liveChains -= 1;
|
|
1909
|
+
this.notifyChainAcknowledged(state, entry.chainId);
|
|
1910
|
+
this.setWriteStatus(state, "ok");
|
|
1911
|
+
break;
|
|
1912
|
+
} catch (error) {
|
|
1913
|
+
const classified = classifyFetchError(error);
|
|
1914
|
+
if (!classified.retriable || isAuthOrProtocolError(error)) {
|
|
1915
|
+
this.rejectSyncWaiters(state, /* @__PURE__ */ new Error(`ephemeral append failed: ${classified.code}`));
|
|
1916
|
+
this.setWriteStatus(state, "error");
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
if (attempt >= this.reconnectConfig.maxAttempts) {
|
|
1920
|
+
this.rejectSyncWaiters(state, /* @__PURE__ */ new Error("ephemeral append retry budget exhausted"));
|
|
1921
|
+
this.setWriteStatus(state, "error");
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
this.setWriteStatus(state, "reconnecting");
|
|
1925
|
+
const delay = computeRetryDelay(attempt, this.reconnectConfig);
|
|
1926
|
+
attempt += 1;
|
|
1927
|
+
await sleep(delay, state.retrySleepController.signal);
|
|
1843
1928
|
}
|
|
1844
|
-
this.setWriteStatus(state, "reconnecting");
|
|
1845
|
-
const delay = computeRetryDelay(attempt, this.reconnectConfig);
|
|
1846
|
-
attempt += 1;
|
|
1847
|
-
await sleep(delay, state.retrySleepController.signal);
|
|
1848
1929
|
}
|
|
1849
1930
|
}
|
|
1850
1931
|
} finally {
|
|
1851
1932
|
state.flushingLocal = false;
|
|
1852
1933
|
}
|
|
1853
1934
|
}
|
|
1854
|
-
|
|
1935
|
+
localUpdateKey(update) {
|
|
1936
|
+
const adaptor = this.adaptor;
|
|
1937
|
+
if (adaptor.localUpdateKey == null) return;
|
|
1938
|
+
try {
|
|
1939
|
+
return adaptor.localUpdateKey(update) ?? void 0;
|
|
1940
|
+
} catch (error) {
|
|
1941
|
+
this.logError("ephemeral local update key lookup failed", error);
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
/**
|
|
1946
|
+
* Pops superseded entries off the head and returns the next entry to send,
|
|
1947
|
+
* marking it in-flight so later same-key updates queue behind it.
|
|
1948
|
+
*/
|
|
1949
|
+
nextPendingLocal(state) {
|
|
1950
|
+
for (;;) {
|
|
1951
|
+
const head = state.pendingLocal.peekFront();
|
|
1952
|
+
if (head == null) return;
|
|
1953
|
+
if (head.superseded) {
|
|
1954
|
+
state.pendingLocal.popFront();
|
|
1955
|
+
state.pendingLocalSuperseded -= 1;
|
|
1956
|
+
continue;
|
|
1957
|
+
}
|
|
1958
|
+
if (!head.inFlight) {
|
|
1959
|
+
head.inFlight = true;
|
|
1960
|
+
if (head.key != null && state.pendingLocalByKey.get(head.key) === head) state.pendingLocalByKey.delete(head.key);
|
|
1961
|
+
}
|
|
1962
|
+
return head;
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
/** Drops superseded markers once they dominate the queue (amortized O(1)). */
|
|
1966
|
+
maybeRebuildPendingLocal(state) {
|
|
1967
|
+
if (state.pendingLocalSuperseded < PENDING_LOCAL_REBUILD_MIN_SUPERSEDED || state.pendingLocalSuperseded * 2 <= state.pendingLocal.length) return;
|
|
1968
|
+
const live = state.pendingLocal.toArray().filter((entry) => !entry.superseded);
|
|
1969
|
+
state.pendingLocal.clear();
|
|
1970
|
+
for (const entry of live) state.pendingLocal.pushBack(entry);
|
|
1971
|
+
state.pendingLocalSuperseded = 0;
|
|
1972
|
+
}
|
|
1973
|
+
/**
|
|
1974
|
+
* Completes waiters that tracked `chainId`. Each chain is acknowledged
|
|
1975
|
+
* exactly once (its entry is never superseded after it goes in flight), so
|
|
1976
|
+
* a waiter resolves only after every chain live at call time has had its
|
|
1977
|
+
* latest value acknowledged.
|
|
1978
|
+
*/
|
|
1979
|
+
notifyChainAcknowledged(state, chainId) {
|
|
1855
1980
|
let i = 0;
|
|
1856
1981
|
while (i < state.syncWaiters.length) {
|
|
1857
1982
|
const waiter = state.syncWaiters[i];
|
|
1858
|
-
waiter.
|
|
1983
|
+
if (chainId > waiter.lastChainId) {
|
|
1984
|
+
i += 1;
|
|
1985
|
+
continue;
|
|
1986
|
+
}
|
|
1987
|
+
waiter.remaining -= 1;
|
|
1859
1988
|
if (waiter.remaining <= 0) {
|
|
1860
1989
|
state.syncWaiters.splice(i, 1);
|
|
1861
1990
|
waiter.resolve();
|
|
@@ -2066,11 +2195,32 @@ function createInitialRemoteCursor(input) {
|
|
|
2066
2195
|
}
|
|
2067
2196
|
//#endregion
|
|
2068
2197
|
//#region src/transport-cursor.ts
|
|
2198
|
+
const MAX_SPANS_IN_MESSAGE = 8;
|
|
2199
|
+
/**
|
|
2200
|
+
* Renders a bounded summary of unresolved spans for the error message, so a
|
|
2201
|
+
* caller's error log names the author(s) whose ops are missing from the
|
|
2202
|
+
* stream without the caller having to unwrap `error.unresolved` itself.
|
|
2203
|
+
*/
|
|
2204
|
+
function formatUnresolvedSpans(unresolved) {
|
|
2205
|
+
if (unresolved.length === 0) return "";
|
|
2206
|
+
const shown = unresolved.slice(0, MAX_SPANS_IN_MESSAGE).map((span) => `${span.peer}@[${span.start}..${span.end})`).join(", ");
|
|
2207
|
+
const suffix = unresolved.length > MAX_SPANS_IN_MESSAGE ? `, +${unresolved.length - MAX_SPANS_IN_MESSAGE} more` : "";
|
|
2208
|
+
return ` (missing deps for ${unresolved.length} span(s): ${shown}${suffix})`;
|
|
2209
|
+
}
|
|
2069
2210
|
var CrdtApplyIncompleteError = class extends Error {
|
|
2070
2211
|
unresolved;
|
|
2071
|
-
|
|
2072
|
-
|
|
2212
|
+
/**
|
|
2213
|
+
* The volatile server version at the moment apply failed: everything the
|
|
2214
|
+
* server delivered that the local doc actually applied (success spans
|
|
2215
|
+
* only — pending spans never advance it). `exportUpdates(appliedVersion)`
|
|
2216
|
+
* is therefore exactly "the ops this replica holds that the server does
|
|
2217
|
+
* not", which is what the join-time salvage append publishes.
|
|
2218
|
+
*/
|
|
2219
|
+
appliedVersion;
|
|
2220
|
+
constructor(unresolved, appliedVersion) {
|
|
2221
|
+
super(`CRDT apply incomplete: unresolved remote updates remain${formatUnresolvedSpans(unresolved)}`);
|
|
2073
2222
|
this.unresolved = unresolved;
|
|
2223
|
+
this.appliedVersion = appliedVersion;
|
|
2074
2224
|
}
|
|
2075
2225
|
};
|
|
2076
2226
|
function isApplyOutcome(value) {
|
|
@@ -2098,12 +2248,29 @@ var TransportCursorManager = class {
|
|
|
2098
2248
|
isCursorComplete(cursor) {
|
|
2099
2249
|
return !this.incompleteCursors.has(cursor);
|
|
2100
2250
|
}
|
|
2101
|
-
|
|
2251
|
+
/**
|
|
2252
|
+
* Marks `cursor` incomplete without an apply.
|
|
2253
|
+
*
|
|
2254
|
+
* The `CrdtApplyIncompleteError` throw path discards its candidate cursor
|
|
2255
|
+
* BEFORE the usual WeakMap marking, so a caller's retained cursor keeps
|
|
2256
|
+
* whatever completeness it had. For a caught-up live room the first
|
|
2257
|
+
* poisoned event typically arrives with `up_to_date=true`, so the retained
|
|
2258
|
+
* cursor still reads "complete" for the whole wedge — leaving the degraded
|
|
2259
|
+
* local flush unengaged and the snapshot-upload clean gate open, exactly
|
|
2260
|
+
* the two consumers keyed on this marking. The read loop calls this when
|
|
2261
|
+
* it catches the error so the room's incomplete state is visible on the
|
|
2262
|
+
* cursor it actually holds.
|
|
2263
|
+
*/
|
|
2264
|
+
markCursorIncomplete(cursor, unresolved) {
|
|
2265
|
+
this.incompleteCursors.set(cursor, unresolved);
|
|
2266
|
+
}
|
|
2267
|
+
async applyBootstrapPayload(cursor, snapshot, updates, nextOffset, upToDate = false, snapshotOffset = "-1") {
|
|
2102
2268
|
let nextVersion = cursor.serverLowerBoundVersion;
|
|
2103
2269
|
let complete = true;
|
|
2104
2270
|
let unresolved = [];
|
|
2105
2271
|
if (snapshot.byteLength > 0) {
|
|
2106
|
-
|
|
2272
|
+
if (typeof snapshotOffset !== "string" || snapshotOffset.length === 0 || snapshotOffset.length > 1024 || snapshotOffset === "now" || snapshotOffset === "-1") throw new Error("bootstrap snapshot offset does not match an admitted continuation");
|
|
2273
|
+
const outcome = normalizeApplyReturn(await this.adapter.applySnapshot(this.decodeSnapshot == null ? snapshot : await this.decodeSnapshot(snapshot, snapshotOffset)));
|
|
2107
2274
|
nextVersion = outcome.version;
|
|
2108
2275
|
complete = outcome.complete;
|
|
2109
2276
|
unresolved = outcome.unresolved ?? [];
|
|
@@ -2121,9 +2288,9 @@ var TransportCursorManager = class {
|
|
|
2121
2288
|
serverLowerBoundVersion: nextVersion
|
|
2122
2289
|
}, "bootstrap", complete, unresolved, upToDate);
|
|
2123
2290
|
}
|
|
2124
|
-
async applyBootstrapItems(cursor, items, nextOffset, upToDate = false) {
|
|
2291
|
+
async applyBootstrapItems(cursor, items, nextOffset, upToDate = false, snapshotOffset = "-1") {
|
|
2125
2292
|
const snapshot = items[0] ?? new Uint8Array();
|
|
2126
|
-
return await this.applyBootstrapPayload(cursor, snapshot, items.slice(1), nextOffset, upToDate);
|
|
2293
|
+
return await this.applyBootstrapPayload(cursor, snapshot, items.slice(1), nextOffset, upToDate, snapshotOffset);
|
|
2127
2294
|
}
|
|
2128
2295
|
async applyRemoteItems(cursor, items, nextOffset) {
|
|
2129
2296
|
return await this.applyRemotePayload(cursor, [...items], nextOffset, "remote", void 0);
|
|
@@ -2171,7 +2338,7 @@ var TransportCursorManager = class {
|
|
|
2171
2338
|
}
|
|
2172
2339
|
async finalizeCursor(cursor, source, complete, unresolved, upToDate) {
|
|
2173
2340
|
if (!complete) {
|
|
2174
|
-
if (upToDate) throw new CrdtApplyIncompleteError(unresolved);
|
|
2341
|
+
if (upToDate) throw new CrdtApplyIncompleteError(unresolved, cursor.serverLowerBoundVersion);
|
|
2175
2342
|
this.incompleteCursors.set(cursor, unresolved);
|
|
2176
2343
|
return cursor;
|
|
2177
2344
|
}
|
|
@@ -2287,7 +2454,7 @@ var TransportSnapshotManager = class {
|
|
|
2287
2454
|
if (tailBytes <= snapshotBytes) return;
|
|
2288
2455
|
if (tailBytes - snapshotBytes < config.minBytesSinceRemoteSnapshot) return;
|
|
2289
2456
|
const snapshot = this.adapter.exportSnapshot();
|
|
2290
|
-
await this.putSnapshotAt(state.cursor.nextOffset, this.encodeSnapshot == null ? snapshot : await this.encodeSnapshot(snapshot));
|
|
2457
|
+
await this.putSnapshotAt(state.cursor.nextOffset, this.encodeSnapshot == null ? snapshot : await this.encodeSnapshot(snapshot, state.cursor.nextOffset));
|
|
2291
2458
|
} catch (error) {
|
|
2292
2459
|
this.logError("snapshot upload failed", error);
|
|
2293
2460
|
}
|
|
@@ -2326,6 +2493,7 @@ function createJoinState(cursor, liveMode = "sse") {
|
|
|
2326
2493
|
pendingLocal: new Deque(),
|
|
2327
2494
|
flushingLocal: false,
|
|
2328
2495
|
retryAttempt: 0,
|
|
2496
|
+
incompleteRecoveryAttempt: 0,
|
|
2329
2497
|
retrySleepController: new AbortController(),
|
|
2330
2498
|
readLoopRunning: false,
|
|
2331
2499
|
readErrorRetryAttempt: 0,
|
|
@@ -2468,16 +2636,16 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
2468
2636
|
}
|
|
2469
2637
|
composeSnapshotDecoder(payloadProtection, decompressSnapshot) {
|
|
2470
2638
|
if (payloadProtection == null && decompressSnapshot == null) return;
|
|
2471
|
-
return async (snapshot) => {
|
|
2472
|
-
const clearSnapshot = payloadProtection == null ? snapshot : await payloadProtection.decodeSnapshot(snapshot);
|
|
2639
|
+
return async (snapshot, continuationOffset) => {
|
|
2640
|
+
const clearSnapshot = payloadProtection == null ? snapshot : await payloadProtection.decodeSnapshot(snapshot, continuationOffset);
|
|
2473
2641
|
return decompressSnapshot == null ? clearSnapshot : await decompressSnapshot(clearSnapshot);
|
|
2474
2642
|
};
|
|
2475
2643
|
}
|
|
2476
2644
|
composeSnapshotEncoder(payloadProtection, compressSnapshot) {
|
|
2477
2645
|
if (payloadProtection == null && compressSnapshot == null) return;
|
|
2478
|
-
return async (snapshot) => {
|
|
2646
|
+
return async (snapshot, continuationOffset) => {
|
|
2479
2647
|
const compressedSnapshot = compressSnapshot == null ? snapshot : await compressSnapshot(snapshot);
|
|
2480
|
-
return payloadProtection == null ? compressedSnapshot : await payloadProtection.encodeSnapshot(compressedSnapshot);
|
|
2648
|
+
return payloadProtection == null ? compressedSnapshot : await payloadProtection.encodeSnapshot(compressedSnapshot, continuationOffset);
|
|
2481
2649
|
};
|
|
2482
2650
|
}
|
|
2483
2651
|
/**
|
|
@@ -2586,7 +2754,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
2586
2754
|
await this.adapter.flushLocalExports?.();
|
|
2587
2755
|
const cursor = await this.syncOnce("sync");
|
|
2588
2756
|
const rawSnapshot = this.adapter.exportSnapshot();
|
|
2589
|
-
const snapshot = this.encodeSnapshot == null ? rawSnapshot : await this.encodeSnapshot(rawSnapshot);
|
|
2757
|
+
const snapshot = this.encodeSnapshot == null ? rawSnapshot : await this.encodeSnapshot(rawSnapshot, cursor.nextOffset);
|
|
2590
2758
|
await this.client.putSnapshotAt(cursor.nextOffset, snapshot);
|
|
2591
2759
|
return {
|
|
2592
2760
|
ok: true,
|
|
@@ -2909,7 +3077,13 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
2909
3077
|
async startJoin(state) {
|
|
2910
3078
|
try {
|
|
2911
3079
|
state.requestController ??= new AbortController();
|
|
2912
|
-
|
|
3080
|
+
let joined;
|
|
3081
|
+
try {
|
|
3082
|
+
joined = await this.performInitialJoinSync(state.liveMode, state.requestController?.signal);
|
|
3083
|
+
} catch (error) {
|
|
3084
|
+
if (!(error instanceof CrdtApplyIncompleteError) || state.closed || !await this.salvageLocalBacklogOnIncompleteInitialSync(state, error)) throw error;
|
|
3085
|
+
joined = await this.performInitialJoinSync(state.liveMode, state.requestController?.signal);
|
|
3086
|
+
}
|
|
2913
3087
|
state.cursor = joined.cursor;
|
|
2914
3088
|
state.streamCursor = joined.streamCursor;
|
|
2915
3089
|
state.liveMode = joined.liveMode;
|
|
@@ -2941,6 +3115,65 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
2941
3115
|
throw error;
|
|
2942
3116
|
}
|
|
2943
3117
|
}
|
|
3118
|
+
/**
|
|
3119
|
+
* Join-time salvage for a stream whose head cannot be fully applied
|
|
3120
|
+
* (apply_incomplete at `up_to_date`, even after bootstrap): before the
|
|
3121
|
+
* join surfaces the error, durably append everything this replica holds
|
|
3122
|
+
* that the server does not.
|
|
3123
|
+
*
|
|
3124
|
+
* Why this exists: the dual-author wedge is usually two-sided — the stream
|
|
3125
|
+
* is missing some author's ops, and a replica that HOLDS those ops (e.g.
|
|
3126
|
+
* received out-of-band over a local data plane) may itself be unable to
|
|
3127
|
+
* complete the join because of a second gap. The regular join-time export
|
|
3128
|
+
* runs only AFTER a successful initial sync, so without this salvage such
|
|
3129
|
+
* a replica can never publish the healing ops, and its own backlog from a
|
|
3130
|
+
* previous process run stays local for as long as the stream stays
|
|
3131
|
+
* poisoned.
|
|
3132
|
+
*
|
|
3133
|
+
* `error.appliedVersion` advances only from success spans, so
|
|
3134
|
+
* `exportUpdates(appliedVersion)` is exactly "ops this replica holds that
|
|
3135
|
+
* the server does not" — the failed initial sync already imported
|
|
3136
|
+
* everything the server DOES have, so the delta cannot re-append
|
|
3137
|
+
* server-known data. Cursor safety is untouched: the append is write-only
|
|
3138
|
+
* style (producer-acked, no read catch-up, no durable-cursor save), and
|
|
3139
|
+
* the retried initial sync re-derives its cursor from the server.
|
|
3140
|
+
*
|
|
3141
|
+
* Returns `true` when anything was durably appended; the caller then
|
|
3142
|
+
* retries the initial sync once. Best-effort: a salvage failure logs and
|
|
3143
|
+
* returns `false` so the original apply-incomplete error is what callers
|
|
3144
|
+
* observe.
|
|
3145
|
+
*/
|
|
3146
|
+
async salvageLocalBacklogOnIncompleteInitialSync(state, error) {
|
|
3147
|
+
const appliedVersion = error.appliedVersion;
|
|
3148
|
+
if (appliedVersion == null) return false;
|
|
3149
|
+
try {
|
|
3150
|
+
const appended = await this.enqueueExclusive(async () => {
|
|
3151
|
+
let didAppend = false;
|
|
3152
|
+
const frozen = this.frozenLocalAppend;
|
|
3153
|
+
if (frozen != null) {
|
|
3154
|
+
const ack = await this.appendFrozenLocalBatchRemote(frozen);
|
|
3155
|
+
this.commitProducerAck(ack.producerAck);
|
|
3156
|
+
didAppend = true;
|
|
3157
|
+
}
|
|
3158
|
+
await this.adapter.flushLocalExports?.();
|
|
3159
|
+
const localBatch = this.exportUpdates(appliedVersion);
|
|
3160
|
+
if (localBatch.batch != null) {
|
|
3161
|
+
const appendedBatch = await this.appendLocalBatchRemote({
|
|
3162
|
+
...state.cursor,
|
|
3163
|
+
serverLowerBoundVersion: appliedVersion
|
|
3164
|
+
}, localBatch.batch, void 0, "direct");
|
|
3165
|
+
this.commitProducerAck(appendedBatch.producerAck);
|
|
3166
|
+
didAppend = true;
|
|
3167
|
+
}
|
|
3168
|
+
return didAppend;
|
|
3169
|
+
});
|
|
3170
|
+
if (appended) this.logDebug("join salvage appended local backlog", { streamUrl: this.streamUrl });
|
|
3171
|
+
return appended;
|
|
3172
|
+
} catch (salvageError) {
|
|
3173
|
+
this.logError("join salvage append failed", salvageError, { streamUrl: this.streamUrl });
|
|
3174
|
+
return false;
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
2944
3177
|
async syncOnce(operation, parentSignal) {
|
|
2945
3178
|
let cursor = (await this.finalizeDirectLocalAppendIfNeeded(await this.resolveInitialRemoteState(operation, parentSignal))).cursor;
|
|
2946
3179
|
const localBatch = this.exportUpdates(cursor.serverLowerBoundVersion);
|
|
@@ -2955,8 +3188,11 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
2955
3188
|
let liveMode = preferredLiveMode;
|
|
2956
3189
|
const exportedLocal = this.exportUpdates(localExportRefVersion);
|
|
2957
3190
|
localExportRefVersion = exportedLocal.nextRefVersion;
|
|
2958
|
-
|
|
2959
|
-
|
|
3191
|
+
const exportedBatch = exportedLocal.batch;
|
|
3192
|
+
if (exportedBatch != null) {
|
|
3193
|
+
const appendCursor = cursor;
|
|
3194
|
+
const appendStreamCursor = streamCursor;
|
|
3195
|
+
const committed = await this.enqueueExclusive(() => this.appendLocalBatchDurably(appendCursor, exportedBatch, void 0, "direct", appendStreamCursor));
|
|
2960
3196
|
cursor = committed.cursor;
|
|
2961
3197
|
streamCursor = committed.streamCursor;
|
|
2962
3198
|
}
|
|
@@ -3044,7 +3280,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3044
3280
|
async bootstrapState(cursor, options = {}) {
|
|
3045
3281
|
const cursorManager = options.cursorManager ?? this.cursorManager;
|
|
3046
3282
|
const result = await this.client.bootstrap(options.signal, options.timeoutMode);
|
|
3047
|
-
let saved = await cursorManager.applyBootstrapPayload(cursor, result.snapshot, result.updates, result.nextOffset, result.upToDate);
|
|
3283
|
+
let saved = await cursorManager.applyBootstrapPayload(cursor, result.snapshot, result.updates, result.nextOffset, result.upToDate, result.snapshotOffset);
|
|
3048
3284
|
let bootstrapCursor = result.streamCursor;
|
|
3049
3285
|
if (!result.upToDate) {
|
|
3050
3286
|
const caughtUp = await this.catchupOrGone(saved, {
|
|
@@ -3116,7 +3352,8 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3116
3352
|
};
|
|
3117
3353
|
}
|
|
3118
3354
|
}
|
|
3119
|
-
async recoverLiveIncompleteRemoteApply(state) {
|
|
3355
|
+
async recoverLiveIncompleteRemoteApply(state, error) {
|
|
3356
|
+
this.cursorManager.markCursorIncomplete(state.cursor, error.unresolved);
|
|
3120
3357
|
try {
|
|
3121
3358
|
const bootstrapped = await this.bootstrapState({
|
|
3122
3359
|
...state.cursor,
|
|
@@ -3126,6 +3363,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3126
3363
|
state.cursor = bootstrapped.cursor;
|
|
3127
3364
|
state.streamCursor = bootstrapped.streamCursor;
|
|
3128
3365
|
state.retryAttempt = 0;
|
|
3366
|
+
state.incompleteRecoveryAttempt = 0;
|
|
3129
3367
|
state.lastError = void 0;
|
|
3130
3368
|
this.setReadSubStatus(state, "ok");
|
|
3131
3369
|
if (state.pendingLocal.length > 0) this.flushPendingLocal(state);
|
|
@@ -3137,7 +3375,10 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3137
3375
|
code: classified.code
|
|
3138
3376
|
});
|
|
3139
3377
|
state.lastError = toTransportError(error);
|
|
3378
|
+
if (state.pendingLocal.length > 0) this.flushPendingLocal(state);
|
|
3140
3379
|
if (classified.retriable && !isAuthOrProtocolError(error)) {
|
|
3380
|
+
state.incompleteRecoveryAttempt += 1;
|
|
3381
|
+
state.retryAttempt = Math.max(state.retryAttempt, state.incompleteRecoveryAttempt);
|
|
3141
3382
|
this.setReadSubStatus(state, "reconnecting");
|
|
3142
3383
|
return "retry";
|
|
3143
3384
|
}
|
|
@@ -3613,7 +3854,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3613
3854
|
if (state.closed) return;
|
|
3614
3855
|
if (error instanceof CrdtApplyIncompleteError) {
|
|
3615
3856
|
if (capturedController?.signal.aborted && state.requestController === capturedController) state.requestController = new AbortController();
|
|
3616
|
-
const recovery = await this.recoverLiveIncompleteRemoteApply(state);
|
|
3857
|
+
const recovery = await this.recoverLiveIncompleteRemoteApply(state, error);
|
|
3617
3858
|
if (recovery === "recovered") continue;
|
|
3618
3859
|
if (recovery === "terminal") return;
|
|
3619
3860
|
if (await this.awaitReadRetryBackoff(state, error)) continue;
|
|
@@ -3647,7 +3888,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3647
3888
|
const flushError = abortReason instanceof Error ? abortReason : error;
|
|
3648
3889
|
if (flushError instanceof CrdtApplyIncompleteError) {
|
|
3649
3890
|
if (state.requestController === capturedController) state.requestController = new AbortController();
|
|
3650
|
-
const recovery = await this.recoverLiveIncompleteRemoteApply(state);
|
|
3891
|
+
const recovery = await this.recoverLiveIncompleteRemoteApply(state, flushError);
|
|
3651
3892
|
if (recovery === "recovered") continue;
|
|
3652
3893
|
if (recovery === "terminal") return;
|
|
3653
3894
|
if (await this.awaitReadRetryBackoff(state, flushError)) continue;
|
|
@@ -3742,10 +3983,15 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3742
3983
|
* `"error"` write status (with `lastWriteError` set) and then retries via
|
|
3743
3984
|
* this flush's own backoff timer (`maybeScheduleWriteRetry`) — it does not
|
|
3744
3985
|
* depend on a future local op to re-trigger the flush.
|
|
3986
|
+
*
|
|
3987
|
+
* While the cursor is incomplete (unresolved remote spans), the flush
|
|
3988
|
+
* degrades to an append-only mode instead of pausing: batches still reach
|
|
3989
|
+
* the server, but read progress, durable-cursor saves, and snapshot
|
|
3990
|
+
* scheduling stay untouched. See the inline comment in the incomplete
|
|
3991
|
+
* branch and specs/loro-pending.md "Local Append Rules".
|
|
3745
3992
|
*/
|
|
3746
3993
|
async flushPendingLocal(state) {
|
|
3747
3994
|
if (state.flushingLocal || state.closed) return;
|
|
3748
|
-
if (!this.cursorManager.isCursorComplete(state.cursor)) return;
|
|
3749
3995
|
this.clearWriteRetryTimer(state);
|
|
3750
3996
|
state.flushingLocal = true;
|
|
3751
3997
|
try {
|
|
@@ -3754,16 +4000,23 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3754
4000
|
if (drained == null) return;
|
|
3755
4001
|
let attempts = 0;
|
|
3756
4002
|
while (!state.closed) try {
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
4003
|
+
let committedCount;
|
|
4004
|
+
if (this.cursorManager.isCursorComplete(state.cursor)) {
|
|
4005
|
+
state.cursor = await this.enqueueExclusive(async () => this.appendLocalBatch(state.cursor, drained.batch, drained.count, state.streamCursor));
|
|
4006
|
+
const committedStreamCursor = this.consumeCommittedLocalStreamCursor();
|
|
4007
|
+
if (committedStreamCursor != null) state.streamCursor = committedStreamCursor;
|
|
4008
|
+
committedCount = this.consumeCommittedPendingCount() ?? drained.count;
|
|
4009
|
+
this.snapshotManager.schedule(state);
|
|
4010
|
+
} else {
|
|
4011
|
+
const appended = await this.enqueueExclusive(async () => this.appendLocalBatchRemote(state.cursor, drained.batch, drained.count, "pending"));
|
|
4012
|
+
this.commitProducerAck(appended.producerAck);
|
|
4013
|
+
committedCount = appended.pendingCount ?? drained.count;
|
|
4014
|
+
}
|
|
3761
4015
|
for (let i = 0; i < committedCount; i += 1) state.pendingLocal.popFront();
|
|
3762
4016
|
this.notifySyncWaiters(state, committedCount);
|
|
3763
4017
|
state.flushHeadBatchOnly = false;
|
|
3764
4018
|
state.lastWriteError = void 0;
|
|
3765
4019
|
this.setWriteSubStatus(state, "ok");
|
|
3766
|
-
this.snapshotManager.schedule(state);
|
|
3767
4020
|
break;
|
|
3768
4021
|
} catch (error) {
|
|
3769
4022
|
if (isPayloadTooLargeError(error) && drained.count > 1 && this.abandonPendingFrozenLocalAppend()) {
|
|
@@ -3857,12 +4110,15 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3857
4110
|
for (const waiter of state.syncWaiters.splice(0)) waiter.reject(error);
|
|
3858
4111
|
}
|
|
3859
4112
|
async saveRemoteCursor(cursor, source) {
|
|
3860
|
-
|
|
4113
|
+
const saved = await persistRemoteCursor({
|
|
3861
4114
|
remoteCursorStore: this.remoteCursorStore,
|
|
3862
4115
|
cursor,
|
|
3863
4116
|
source,
|
|
3864
4117
|
beforeRemoteCursorSave: this.beforeRemoteCursorSave
|
|
3865
4118
|
});
|
|
4119
|
+
const state = this.joinState;
|
|
4120
|
+
if (state != null && !state.closed) state.incompleteRecoveryAttempt = 0;
|
|
4121
|
+
return saved;
|
|
3866
4122
|
}
|
|
3867
4123
|
setReadSubStatus(state, sub) {
|
|
3868
4124
|
if (sub === "ok") state.readErrorRetryAttempt = 0;
|
|
@@ -3945,4 +4201,4 @@ function isValidRillId(id) {
|
|
|
3945
4201
|
//#endregion
|
|
3946
4202
|
export { createInitialRemoteCursor as a, PayloadProtectionError as c, IndexedDbRemoteCursorStore as i, StreamsCrdt as n, EphemeralStreamCrdt as o, InMemoryRemoteCursorStore as r, LocalAppendFailedError as s, isValidRillId as t };
|
|
3947
4203
|
|
|
3948
|
-
//# sourceMappingURL=stream-id-
|
|
4204
|
+
//# sourceMappingURL=stream-id-DwQujw_H.js.map
|