@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.
@@ -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
- if (isEncryptedEnvelope(snapshot)) return await this.openPayload("snapshot", snapshot);
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 snapshot = result.result.snapshotOffset === "-1" ? new Uint8Array() : result.result.snapshot?.body ?? new Uint8Array();
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) this.authRefreshInFlight = this.resolveToken({
1305
- reason: "unauthorized",
1306
- status,
1307
- previousToken
1308
- }).finally(() => {
1309
- this.authRefreshInFlight = void 0;
1310
- });
1311
- return await this.authRefreshInFlight;
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.pendingLocal.length;
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.pendingLocal.length;
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
- state.pendingLocal.pushBack(update);
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) try {
1827
- await this.client.appendEphemeralUpdate(update);
1828
- state.pendingLocal.popFront();
1829
- this.notifySyncWaiters(state, 1);
1830
- this.setWriteStatus(state, "ok");
1831
- break;
1832
- } catch (error) {
1833
- const classified = classifyFetchError(error);
1834
- if (!classified.retriable || isAuthOrProtocolError(error)) {
1835
- this.rejectSyncWaiters(state, /* @__PURE__ */ new Error(`ephemeral append failed: ${classified.code}`));
1836
- this.setWriteStatus(state, "error");
1837
- return;
1838
- }
1839
- if (attempt >= this.reconnectConfig.maxAttempts) {
1840
- this.rejectSyncWaiters(state, /* @__PURE__ */ new Error("ephemeral append retry budget exhausted"));
1841
- this.setWriteStatus(state, "error");
1842
- return;
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
- notifySyncWaiters(state, flushedCount) {
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.remaining -= flushedCount;
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
- constructor(unresolved) {
2072
- super("CRDT apply incomplete: unresolved remote updates remain");
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
- async applyBootstrapPayload(cursor, snapshot, updates, nextOffset, upToDate = false) {
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
- const outcome = normalizeApplyReturn(await this.adapter.applySnapshot(this.decodeSnapshot == null ? snapshot : await this.decodeSnapshot(snapshot)));
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
- const joined = await this.performInitialJoinSync(state.liveMode, state.requestController?.signal);
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
- if (exportedLocal.batch != null) {
2959
- const committed = await this.appendLocalBatchDurably(cursor, exportedLocal.batch, void 0, "direct", streamCursor);
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
- state.cursor = await this.enqueueExclusive(async () => this.appendLocalBatch(state.cursor, drained.batch, drained.count, state.streamCursor));
3758
- const committedStreamCursor = this.consumeCommittedLocalStreamCursor();
3759
- if (committedStreamCursor != null) state.streamCursor = committedStreamCursor;
3760
- const committedCount = this.consumeCommittedPendingCount() ?? drained.count;
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
- return await persistRemoteCursor({
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-CggbAC6b.js.map
4204
+ //# sourceMappingURL=stream-id-DwQujw_H.js.map