@loro-dev/streams-crdt 0.8.0 → 0.8.2

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.
@@ -597,6 +597,12 @@ function mergeAbortSignals(...signals) {
597
597
  function classifyFetchError(err) {
598
598
  const message = err instanceof Error ? err.message : String(err);
599
599
  const status = err instanceof HttpError ? err.status : void 0;
600
+ if (isCrdtApplyIncompleteError(err)) return {
601
+ code: "apply_incomplete",
602
+ message,
603
+ retriable: true,
604
+ atMs: Date.now()
605
+ };
600
606
  if (status === 401 || status === 403) return {
601
607
  code: "auth_failed",
602
608
  message,
@@ -650,6 +656,12 @@ function isStreamNotFoundError(error) {
650
656
  return error instanceof HttpError && error.status === 404;
651
657
  }
652
658
  function toTransportError(error) {
659
+ if (isCrdtApplyIncompleteError(error)) return {
660
+ code: "apply_incomplete",
661
+ retryable: true,
662
+ reason: "loro_pending_updates",
663
+ message: error instanceof Error ? error.message : String(error)
664
+ };
653
665
  if (isStreamNotFoundError(error)) return {
654
666
  code: "stream_not_found",
655
667
  retryable: false
@@ -710,6 +722,9 @@ function toTransportError(error) {
710
722
  message: String(error)
711
723
  };
712
724
  }
725
+ function isCrdtApplyIncompleteError(error) {
726
+ return error instanceof Error && Array.isArray(error.unresolved) && error.message.includes("CRDT apply incomplete");
727
+ }
713
728
  function isReadNoProgress(requestOffset, result) {
714
729
  return !result.upToDate && result.updates.length === 0 && result.nextOffset === requestOffset;
715
730
  }
@@ -1231,8 +1246,6 @@ var EphemeralStreamCrdt = class {
1231
1246
  client;
1232
1247
  /** Retry policy shared by the read loop and local write queue. */
1233
1248
  reconnectConfig;
1234
- /** Period for republishing full local ephemeral state, or `false` to disable it. */
1235
- heartbeatMs;
1236
1249
  /** Enables diagnostic console errors for failed read/write loops. */
1237
1250
  debug;
1238
1251
  /** Current shared subscription state, if the stream is joined. */
@@ -1244,7 +1257,6 @@ var EphemeralStreamCrdt = class {
1244
1257
  ...DEFAULT_RECONNECT_CONFIG,
1245
1258
  ...options.reconnectConfig
1246
1259
  };
1247
- this.heartbeatMs = options.heartbeatMs ?? 15e3;
1248
1260
  this.debug = options.debug ?? false;
1249
1261
  this.client = new StreamsTransportClient({
1250
1262
  streamUrl: options.streamUrl,
@@ -1325,18 +1337,15 @@ var EphemeralStreamCrdt = class {
1325
1337
  };
1326
1338
  }
1327
1339
  async startJoin(state) {
1340
+ state.unsubscribeLocal = this.adaptor.subscribeLocalUpdates((update) => {
1341
+ this.enqueueLocal(state, normalizeEphemeralUpdate(update));
1342
+ });
1328
1343
  state.requestController = new AbortController();
1329
1344
  const firstOpen = this.waitForFirstOpen(state);
1330
1345
  this.runReadLoop(state);
1331
1346
  await firstOpen;
1332
1347
  if (state.closed) return;
1333
- state.unsubscribeLocal = this.adaptor.subscribeLocalUpdates((update) => {
1334
- this.enqueueLocal(state, normalizeEphemeralUpdate(update));
1335
- });
1336
1348
  this.setWriteStatus(state, "ok");
1337
- const fullState = normalizeEphemeralUpdate(await this.adaptor.encodeAll());
1338
- if (fullState != null) state.pendingLocal.pushBack(fullState);
1339
- this.startHeartbeat(state);
1340
1349
  await this.flushPendingLocal(state);
1341
1350
  if (state.status === "error" || state.status === "disconnected") throw new Error(`ephemeral stream failed to join: ${state.status}`);
1342
1351
  }
@@ -1394,7 +1403,6 @@ var EphemeralStreamCrdt = class {
1394
1403
  if (state.closed) return;
1395
1404
  state.closed = true;
1396
1405
  state.unsubscribeLocal?.();
1397
- if (state.heartbeatTimer != null) clearInterval(state.heartbeatTimer);
1398
1406
  state.requestController?.abort();
1399
1407
  state.retrySleepController.abort();
1400
1408
  this.rejectFirstOpen(state, closedSubscriptionError());
@@ -1420,7 +1428,6 @@ var EphemeralStreamCrdt = class {
1420
1428
  if (bootstrap.byteLength > 0) await this.adaptor.applyRemoteUpdates([bootstrap]);
1421
1429
  this.setReadStatus(state, "ok");
1422
1430
  this.resolveFirstOpen(state);
1423
- if (state.unsubscribeLocal != null) this.enqueueFullState(state);
1424
1431
  }, async (update) => {
1425
1432
  if (update.byteLength > 0) await this.adaptor.applyRemoteUpdates([update]);
1426
1433
  }, capturedController.signal);
@@ -1466,25 +1473,10 @@ var EphemeralStreamCrdt = class {
1466
1473
  }
1467
1474
  }
1468
1475
  }
1469
- startHeartbeat(state) {
1470
- if (this.heartbeatMs === false || this.heartbeatMs <= 0) return;
1471
- state.heartbeatTimer = setInterval(() => {
1472
- this.enqueueFullState(state);
1473
- }, this.heartbeatMs);
1474
- }
1475
- async enqueueFullState(state) {
1476
- try {
1477
- const update = await this.adaptor.encodeAll();
1478
- this.enqueueLocal(state, normalizeEphemeralUpdate(update));
1479
- } catch (error) {
1480
- this.logError("ephemeral encodeAll failed", error, { streamUrl: this.streamUrl });
1481
- this.setWriteStatus(state, "error");
1482
- }
1483
- }
1484
1476
  enqueueLocal(state, update) {
1485
1477
  if (state.closed || update == null || update.byteLength === 0) return;
1486
1478
  state.pendingLocal.pushBack(update);
1487
- this.flushPendingLocal(state);
1479
+ if (state.writeStatus !== "connecting") this.flushPendingLocal(state);
1488
1480
  }
1489
1481
  async flushPendingLocal(state) {
1490
1482
  if (state.flushingLocal || state.closed) return;
@@ -1737,67 +1729,115 @@ function createInitialRemoteCursor(input) {
1737
1729
  }
1738
1730
  //#endregion
1739
1731
  //#region src/transport-cursor.ts
1732
+ var CrdtApplyIncompleteError = class extends Error {
1733
+ unresolved;
1734
+ constructor(unresolved) {
1735
+ super("CRDT apply incomplete: unresolved remote updates remain");
1736
+ this.unresolved = unresolved;
1737
+ }
1738
+ };
1739
+ function isApplyOutcome(value) {
1740
+ return typeof value === "object" && value != null && "version" in value && "complete" in value && typeof value.complete === "boolean";
1741
+ }
1742
+ function normalizeApplyReturn(value) {
1743
+ if (isApplyOutcome(value)) return value;
1744
+ return {
1745
+ version: value,
1746
+ complete: true
1747
+ };
1748
+ }
1740
1749
  var TransportCursorManager = class {
1741
1750
  adapter;
1742
1751
  decodeSnapshot;
1743
1752
  decodeUpdateItems;
1744
1753
  saveRemoteCursor;
1754
+ incompleteCursors = /* @__PURE__ */ new WeakMap();
1745
1755
  constructor(options) {
1746
1756
  this.adapter = options.adapter;
1747
1757
  this.decodeSnapshot = options.decodeSnapshot;
1748
1758
  this.decodeUpdateItems = options.decodeUpdateItems;
1749
1759
  this.saveRemoteCursor = options.saveRemoteCursor;
1750
1760
  }
1751
- async applyBootstrapPayload(cursor, snapshot, updates, nextOffset) {
1761
+ isCursorComplete(cursor) {
1762
+ return !this.incompleteCursors.has(cursor);
1763
+ }
1764
+ async applyBootstrapPayload(cursor, snapshot, updates, nextOffset, upToDate = false) {
1752
1765
  let nextVersion = cursor.serverLowerBoundVersion;
1753
- if (snapshot.byteLength > 0) nextVersion = await this.adapter.applySnapshot(this.decodeSnapshot == null ? snapshot : await this.decodeSnapshot(snapshot));
1766
+ let complete = true;
1767
+ let unresolved = [];
1768
+ if (snapshot.byteLength > 0) {
1769
+ const outcome = normalizeApplyReturn(await this.adapter.applySnapshot(this.decodeSnapshot == null ? snapshot : await this.decodeSnapshot(snapshot)));
1770
+ nextVersion = outcome.version;
1771
+ complete = outcome.complete;
1772
+ unresolved = outcome.unresolved ?? [];
1773
+ }
1754
1774
  const clearUpdates = await this.decodeRemoteUpdateItems(updates);
1755
- if (clearUpdates.length > 0) nextVersion = await this.adapter.applyRemoteUpdates(clearUpdates, nextVersion);
1775
+ if (clearUpdates.length > 0) {
1776
+ const outcome = normalizeApplyReturn(await this.adapter.applyRemoteUpdates(clearUpdates, nextVersion));
1777
+ nextVersion = outcome.version;
1778
+ complete = outcome.complete;
1779
+ unresolved = outcome.unresolved ?? [];
1780
+ }
1756
1781
  return await this.finalizeCursor({
1757
1782
  ...cursor,
1758
1783
  nextOffset,
1759
1784
  serverLowerBoundVersion: nextVersion
1760
- }, "bootstrap");
1785
+ }, "bootstrap", complete, unresolved, upToDate);
1761
1786
  }
1762
- async applyBootstrapItems(cursor, items, nextOffset) {
1787
+ async applyBootstrapItems(cursor, items, nextOffset, upToDate = false) {
1763
1788
  const snapshot = items[0] ?? new Uint8Array();
1764
- return await this.applyBootstrapPayload(cursor, snapshot, items.slice(1), nextOffset);
1789
+ return await this.applyBootstrapPayload(cursor, snapshot, items.slice(1), nextOffset, upToDate);
1765
1790
  }
1766
1791
  async applyRemoteItems(cursor, items, nextOffset) {
1767
1792
  return await this.applyRemotePayload(cursor, [...items], nextOffset, "remote", void 0);
1768
1793
  }
1769
1794
  async applyCatchupResult(cursor, result, onRemoteVersionApplied) {
1770
- return await this.applyRemotePayload(cursor, result.updates, result.nextOffset, "remote", onRemoteVersionApplied);
1795
+ return await this.applyRemotePayload(cursor, result.updates, result.nextOffset, "remote", onRemoteVersionApplied, result.upToDate);
1771
1796
  }
1772
1797
  async applyLiveResult(cursor, requestOffset, result, onRemoteVersionApplied) {
1773
1798
  const previousVersion = cursor.serverLowerBoundVersion;
1774
1799
  let nextVersion = previousVersion;
1800
+ let complete = this.isCursorComplete(cursor);
1801
+ let unresolved = this.incompleteCursors.get(cursor) ?? [];
1775
1802
  const clearUpdates = await this.decodeRemoteUpdateItems(result.updates);
1776
1803
  if (clearUpdates.length > 0) {
1777
- nextVersion = await this.adapter.applyRemoteUpdates(clearUpdates, nextVersion);
1804
+ const outcome = normalizeApplyReturn(await this.adapter.applyRemoteUpdates(clearUpdates, nextVersion));
1805
+ nextVersion = outcome.version;
1806
+ complete = outcome.complete;
1807
+ unresolved = outcome.unresolved ?? [];
1778
1808
  await onRemoteVersionApplied?.(previousVersion, nextVersion);
1779
1809
  }
1780
1810
  return await this.finalizeCursor({
1781
1811
  ...cursor,
1782
1812
  nextOffset: resolveNextOffsetAfterConcurrentAdvance(requestOffset, cursor.nextOffset, result.nextOffset),
1783
1813
  serverLowerBoundVersion: nextVersion
1784
- }, "remote");
1814
+ }, "remote", complete, unresolved, result.upToDate === true);
1785
1815
  }
1786
- async applyRemotePayload(cursor, updates, nextOffset, source, onRemoteVersionApplied) {
1816
+ async applyRemotePayload(cursor, updates, nextOffset, source, onRemoteVersionApplied, upToDate = false) {
1787
1817
  const previousVersion = cursor.serverLowerBoundVersion;
1788
1818
  let nextVersion = previousVersion;
1819
+ let complete = this.isCursorComplete(cursor);
1820
+ let unresolved = this.incompleteCursors.get(cursor) ?? [];
1789
1821
  const clearUpdates = await this.decodeRemoteUpdateItems(updates);
1790
1822
  if (clearUpdates.length > 0) {
1791
- nextVersion = await this.adapter.applyRemoteUpdates(clearUpdates, nextVersion);
1823
+ const outcome = normalizeApplyReturn(await this.adapter.applyRemoteUpdates(clearUpdates, nextVersion));
1824
+ nextVersion = outcome.version;
1825
+ complete = outcome.complete;
1826
+ unresolved = outcome.unresolved ?? [];
1792
1827
  await onRemoteVersionApplied?.(previousVersion, nextVersion);
1793
1828
  }
1794
1829
  return await this.finalizeCursor({
1795
1830
  ...cursor,
1796
1831
  nextOffset,
1797
1832
  serverLowerBoundVersion: nextVersion
1798
- }, source);
1833
+ }, source, complete, unresolved, upToDate);
1799
1834
  }
1800
- async finalizeCursor(cursor, source) {
1835
+ async finalizeCursor(cursor, source, complete, unresolved, upToDate) {
1836
+ if (!complete) {
1837
+ if (upToDate) throw new CrdtApplyIncompleteError(unresolved);
1838
+ this.incompleteCursors.set(cursor, unresolved);
1839
+ return cursor;
1840
+ }
1801
1841
  return await this.saveRemoteCursor(cursor, source);
1802
1842
  }
1803
1843
  async decodeRemoteUpdateItems(items) {
@@ -1830,6 +1870,7 @@ var TransportSnapshotManager = class {
1830
1870
  logDebug;
1831
1871
  logError;
1832
1872
  isCurrentState;
1873
+ isCursorComplete;
1833
1874
  constructor(options) {
1834
1875
  this.adapter = options.adapter;
1835
1876
  this.config = options.config;
@@ -1840,6 +1881,7 @@ var TransportSnapshotManager = class {
1840
1881
  this.logDebug = options.logDebug;
1841
1882
  this.logError = options.logError;
1842
1883
  this.isCurrentState = options.isCurrentState;
1884
+ this.isCursorComplete = options.isCursorComplete;
1843
1885
  }
1844
1886
  dispose(state) {
1845
1887
  this.clearDebounceTimer(state.snapshot);
@@ -1889,6 +1931,10 @@ var TransportSnapshotManager = class {
1889
1931
  async maybeUpload(state) {
1890
1932
  const config = await this.resolveUploadConfig(state);
1891
1933
  if (config == null) return;
1934
+ if (!this.isCursorComplete(state.cursor)) {
1935
+ this.logDebug("skip snapshot upload: current cursor is incomplete", { offset: state.cursor.nextOffset });
1936
+ return;
1937
+ }
1892
1938
  const tailBytes = parseSnapshotByteOffset(state.cursor.nextOffset);
1893
1939
  if (tailBytes == null) {
1894
1940
  this.logDebug("skip snapshot upload: non-numeric offset token", { offset: state.cursor.nextOffset });
@@ -2003,6 +2049,7 @@ var StreamsCrdt = class StreamsCrdt {
2003
2049
  nextProducerSeq = 0;
2004
2050
  frozenLocalAppend;
2005
2051
  committedPendingCount;
2052
+ committedLocalStreamCursor;
2006
2053
  pendingWriteOnly = new Deque();
2007
2054
  writeOnlyCursor;
2008
2055
  unsubscribeWriteOnlyLocal;
@@ -2060,7 +2107,8 @@ var StreamsCrdt = class StreamsCrdt {
2060
2107
  enqueueExclusive: (task) => this.enqueueExclusive(task),
2061
2108
  logDebug: (message, detail) => this.logDebug(message, detail),
2062
2109
  logError: (message, error, detail) => this.logError(message, error, detail),
2063
- isCurrentState: (state) => this.joinState === state
2110
+ isCurrentState: (state) => this.joinState === state,
2111
+ isCursorComplete: (cursor) => this.cursorManager.isCursorComplete(cursor)
2064
2112
  });
2065
2113
  this.writeOnlyCursor = this.createInitialCursor();
2066
2114
  this.unsubscribeWriteOnlyLocal = this.adapter.subscribeLocalUpdates((batch) => {
@@ -2214,6 +2262,7 @@ var StreamsCrdt = class StreamsCrdt {
2214
2262
  this.nextProducerSeq = 0;
2215
2263
  this.frozenLocalAppend = void 0;
2216
2264
  this.committedPendingCount = void 0;
2265
+ this.committedLocalStreamCursor = void 0;
2217
2266
  return deleted;
2218
2267
  }) }
2219
2268
  };
@@ -2449,10 +2498,7 @@ var StreamsCrdt = class StreamsCrdt {
2449
2498
  async syncOnce() {
2450
2499
  let cursor = (await this.finalizeDirectLocalAppendIfNeeded(await this.resolveInitialRemoteState())).cursor;
2451
2500
  const localBatch = this.exportUpdates(cursor.serverLowerBoundVersion);
2452
- if (localBatch.batch != null) {
2453
- cursor = await this.appendLocalBatch(cursor, localBatch.batch);
2454
- cursor = (await this.catchup(cursor)).cursor;
2455
- }
2501
+ if (localBatch.batch != null) cursor = (await this.appendLocalBatchDurably(cursor, localBatch.batch)).cursor;
2456
2502
  return cursor;
2457
2503
  }
2458
2504
  async performInitialJoinSync(preferredLiveMode) {
@@ -2464,10 +2510,9 @@ var StreamsCrdt = class StreamsCrdt {
2464
2510
  const exportedLocal = this.exportUpdates(localExportRefVersion);
2465
2511
  localExportRefVersion = exportedLocal.nextRefVersion;
2466
2512
  if (exportedLocal.batch != null) {
2467
- cursor = await this.appendLocalBatch(cursor, exportedLocal.batch);
2468
- const caughtUp = await this.catchup(cursor);
2469
- cursor = caughtUp.cursor;
2470
- streamCursor = caughtUp.streamCursor;
2513
+ const committed = await this.appendLocalBatchDurably(cursor, exportedLocal.batch, void 0, "direct", streamCursor);
2514
+ cursor = committed.cursor;
2515
+ streamCursor = committed.streamCursor;
2471
2516
  }
2472
2517
  localExportRefVersion = cursor.serverLowerBoundVersion;
2473
2518
  return {
@@ -2501,7 +2546,7 @@ var StreamsCrdt = class StreamsCrdt {
2501
2546
  async bootstrapState(cursor, options = {}) {
2502
2547
  const cursorManager = options.cursorManager ?? this.cursorManager;
2503
2548
  const result = await this.client.bootstrap();
2504
- let saved = await cursorManager.applyBootstrapPayload(cursor, result.snapshot, result.updates, result.nextOffset);
2549
+ let saved = await cursorManager.applyBootstrapPayload(cursor, result.snapshot, result.updates, result.nextOffset, result.upToDate);
2505
2550
  let bootstrapCursor = result.streamCursor;
2506
2551
  if (!result.upToDate) {
2507
2552
  const caughtUp = await this.catchupOrGone(saved, {
@@ -2544,7 +2589,16 @@ var StreamsCrdt = class StreamsCrdt {
2544
2589
  }
2545
2590
  const result = response.value;
2546
2591
  if (isReadNoProgress(requestOffset, result)) throw new Error(`catch-up made no progress at offset ${requestOffset}; server returned empty updates and unchanged next offset with up_to_date=false`);
2547
- current = await cursorManager.applyCatchupResult(current, result, options.onRemoteVersionApplied);
2592
+ try {
2593
+ current = await cursorManager.applyCatchupResult(current, result, options.onRemoteVersionApplied);
2594
+ } catch (error) {
2595
+ if (error instanceof CrdtApplyIncompleteError && result.upToDate) return await this.bootstrapState({
2596
+ ...current,
2597
+ nextOffset: "-1",
2598
+ serverLowerBoundVersion: this.adapter.emptyVersion()
2599
+ }, { cursorManager });
2600
+ throw error;
2601
+ }
2548
2602
  currentStreamCursor = result.streamCursor;
2549
2603
  if (result.upToDate) return {
2550
2604
  cursor: current,
@@ -2552,20 +2606,64 @@ var StreamsCrdt = class StreamsCrdt {
2552
2606
  };
2553
2607
  }
2554
2608
  }
2555
- async appendLocalBatch(cursor, batch, pendingCount) {
2609
+ async recoverLiveIncompleteRemoteApply(state) {
2610
+ try {
2611
+ const bootstrapped = await this.bootstrapState({
2612
+ ...state.cursor,
2613
+ nextOffset: "-1",
2614
+ serverLowerBoundVersion: this.adapter.emptyVersion()
2615
+ });
2616
+ state.cursor = bootstrapped.cursor;
2617
+ state.streamCursor = bootstrapped.streamCursor;
2618
+ state.retryAttempt = 0;
2619
+ state.lastError = void 0;
2620
+ this.setReadSubStatus(state, "ok");
2621
+ if (state.pendingLocal.length > 0) this.flushPendingLocal(state);
2622
+ return true;
2623
+ } catch (error) {
2624
+ const classified = classifyFetchError(error);
2625
+ this.logError("live incomplete apply recovery failed", error, {
2626
+ streamUrl: this.streamUrl,
2627
+ code: classified.code
2628
+ });
2629
+ state.lastError = toTransportError(error);
2630
+ this.setReadSubStatus(state, classified.retriable ? "disconnected" : "error");
2631
+ return false;
2632
+ }
2633
+ }
2634
+ async appendLocalBatch(cursor, batch, pendingCount, streamCursor) {
2556
2635
  const source = pendingCount == null ? "direct" : "pending";
2557
- const committed = await this.appendLocalBatchDurably(cursor, batch, pendingCount, source);
2636
+ const committed = await this.appendLocalBatchDurably(cursor, batch, pendingCount, source, streamCursor);
2558
2637
  this.committedPendingCount = committed.pendingCount;
2638
+ this.committedLocalStreamCursor = committed.streamCursor;
2559
2639
  return committed.cursor;
2560
2640
  }
2561
- async appendLocalBatchDurably(cursor, batch, pendingCount, source = pendingCount == null ? "direct" : "pending") {
2641
+ async appendLocalBatchDurably(cursor, batch, pendingCount, source = pendingCount == null ? "direct" : "pending", streamCursor) {
2562
2642
  const frozen = await this.getOrCreateFrozenLocalAppend(cursor, batch, pendingCount, source);
2563
2643
  const appended = await this.appendFrozenLocalBatchRemote(frozen);
2564
- const saved = await this.saveRemoteCursor(appended.cursor, "local");
2644
+ const caughtUp = await this.catchupLocalAppend(frozen, streamCursor);
2565
2645
  this.commitProducerAck(appended.producerAck);
2566
2646
  return {
2567
- cursor: saved,
2568
- pendingCount: frozen.pendingCount
2647
+ cursor: caughtUp.cursor,
2648
+ pendingCount: frozen.pendingCount,
2649
+ streamCursor: caughtUp.streamCursor
2650
+ };
2651
+ }
2652
+ localAppendReadCursor(frozen) {
2653
+ return {
2654
+ ...frozen.baseCursor,
2655
+ serverLowerBoundVersion: this.adapter.mergeVersions(frozen.baseCursor.serverLowerBoundVersion, frozen.batch.version)
2656
+ };
2657
+ }
2658
+ async catchupLocalAppend(frozen, streamCursor) {
2659
+ const caughtUp = await this.catchupOrGone(this.localAppendReadCursor(frozen), {
2660
+ streamCursor,
2661
+ cursorManager: this.volatileCursorManager
2662
+ });
2663
+ if ("kind" in caughtUp) throw new Error("local append catch-up unexpectedly returned gone");
2664
+ return {
2665
+ cursor: await this.saveRemoteCursor(caughtUp.cursor, "local"),
2666
+ streamCursor: caughtUp.streamCursor
2569
2667
  };
2570
2668
  }
2571
2669
  async appendLocalBatchRemote(cursor, batch, pendingCount, source = pendingCount == null ? "direct" : "pending") {
@@ -2639,9 +2737,9 @@ var StreamsCrdt = class StreamsCrdt {
2639
2737
  const frozen = this.frozenLocalAppend;
2640
2738
  if (frozen == null || frozen.pendingCount != null) return state;
2641
2739
  const appended = await this.appendFrozenLocalBatchRemote(frozen);
2642
- const saved = await this.saveRemoteCursor(appended.cursor, "local");
2740
+ const caughtUp = await this.catchupLocalAppend(frozen, state.streamCursor);
2643
2741
  this.commitProducerAck(appended.producerAck);
2644
- return await this.catchup(saved, void 0, state.streamCursor);
2742
+ return caughtUp;
2645
2743
  }
2646
2744
  createCurrentProducerSession() {
2647
2745
  return {
@@ -2666,6 +2764,11 @@ var StreamsCrdt = class StreamsCrdt {
2666
2764
  this.committedPendingCount = void 0;
2667
2765
  return count;
2668
2766
  }
2767
+ consumeCommittedLocalStreamCursor() {
2768
+ const streamCursor = this.committedLocalStreamCursor;
2769
+ this.committedLocalStreamCursor = void 0;
2770
+ return streamCursor;
2771
+ }
2669
2772
  /**
2670
2773
  * Ensures the target stream exists, creating it if necessary.
2671
2774
  * Tolerates 409 Conflict (stream already created concurrently).
@@ -2802,6 +2905,7 @@ var StreamsCrdt = class StreamsCrdt {
2802
2905
  if (state.closed) return;
2803
2906
  state.retryAttempt = 0;
2804
2907
  this.setReadSubStatus(state, "ok");
2908
+ if (state.pendingLocal.length > 0) this.flushPendingLocal(state);
2805
2909
  await sleep(0);
2806
2910
  continue;
2807
2911
  }
@@ -2810,12 +2914,13 @@ var StreamsCrdt = class StreamsCrdt {
2810
2914
  let flushChain = Promise.resolve();
2811
2915
  resetInactivity();
2812
2916
  const sseStartedAtMs = Date.now();
2813
- const sseResult = await this.client.readFromOffsetSseItems(sseRequestOffset, state.streamCursor, (updates, nextOffset, _upToDate, streamCursor) => {
2917
+ const sseResult = await this.client.readFromOffsetSseItems(sseRequestOffset, state.streamCursor, (updates, nextOffset, upToDate, streamCursor) => {
2814
2918
  if (capturedController?.signal.aborted) return "stop";
2815
2919
  resetInactivity();
2816
2920
  pendingRemote.push({
2817
2921
  updates,
2818
2922
  nextOffset,
2923
+ upToDate,
2819
2924
  streamCursor
2820
2925
  });
2821
2926
  flushChain = flushChain.then(async () => {
@@ -2827,7 +2932,8 @@ var StreamsCrdt = class StreamsCrdt {
2827
2932
  if (state.closed) return;
2828
2933
  state.cursor = await this.cursorManager.applyLiveResult(state.cursor, sseRequestOffset, {
2829
2934
  nextOffset: lastEvent.nextOffset,
2830
- updates: allUpdates
2935
+ updates: allUpdates,
2936
+ upToDate: lastEvent.upToDate
2831
2937
  }, (previousVersion, nextVersion) => {
2832
2938
  this.snapshotManager.observeRemoteVersion(state, previousVersion, nextVersion);
2833
2939
  });
@@ -2837,6 +2943,7 @@ var StreamsCrdt = class StreamsCrdt {
2837
2943
  }
2838
2944
  });
2839
2945
  this.setReadSubStatus(state, "ok");
2946
+ if (state.pendingLocal.length > 0) this.flushPendingLocal(state);
2840
2947
  }).catch((flushError) => {
2841
2948
  this.logError("sse flush chain error", flushError, { streamUrl: this.streamUrl });
2842
2949
  capturedController?.abort(flushError);
@@ -2880,6 +2987,11 @@ var StreamsCrdt = class StreamsCrdt {
2880
2987
  } catch (error) {
2881
2988
  clearInactivity();
2882
2989
  if (state.closed) return;
2990
+ if (error instanceof CrdtApplyIncompleteError) {
2991
+ if (capturedController?.signal.aborted && state.requestController === capturedController) state.requestController = new AbortController();
2992
+ if (await this.recoverLiveIncompleteRemoteApply(state)) continue;
2993
+ return;
2994
+ }
2883
2995
  if (capturedController?.signal.aborted) {
2884
2996
  if (inactivityTriggered) {
2885
2997
  if (state.requestController === capturedController) state.requestController = new AbortController();
@@ -2906,6 +3018,11 @@ var StreamsCrdt = class StreamsCrdt {
2906
3018
  }
2907
3019
  const abortReason = capturedController.signal.reason;
2908
3020
  const flushError = abortReason instanceof Error ? abortReason : error;
3021
+ if (flushError instanceof CrdtApplyIncompleteError) {
3022
+ if (state.requestController === capturedController) state.requestController = new AbortController();
3023
+ if (await this.recoverLiveIncompleteRemoteApply(state)) continue;
3024
+ return;
3025
+ }
2909
3026
  if (flushError instanceof Error && !(flushError instanceof DOMException && flushError.name === "AbortError")) {
2910
3027
  const flushClassified = classifyFetchError(flushError);
2911
3028
  if (!flushClassified.retriable || isAuthOrProtocolError(flushError)) {
@@ -2992,6 +3109,7 @@ var StreamsCrdt = class StreamsCrdt {
2992
3109
  }
2993
3110
  async flushPendingLocal(state) {
2994
3111
  if (state.flushingLocal || state.closed) return;
3112
+ if (!this.cursorManager.isCursorComplete(state.cursor)) return;
2995
3113
  state.flushingLocal = true;
2996
3114
  try {
2997
3115
  while (!state.closed) {
@@ -2999,7 +3117,9 @@ var StreamsCrdt = class StreamsCrdt {
2999
3117
  if (drained == null) return;
3000
3118
  let attempts = 0;
3001
3119
  while (!state.closed) try {
3002
- state.cursor = await this.enqueueExclusive(async () => this.appendLocalBatch(state.cursor, drained.batch, drained.count));
3120
+ state.cursor = await this.enqueueExclusive(async () => this.appendLocalBatch(state.cursor, drained.batch, drained.count, state.streamCursor));
3121
+ const committedStreamCursor = this.consumeCommittedLocalStreamCursor();
3122
+ if (committedStreamCursor != null) state.streamCursor = committedStreamCursor;
3003
3123
  const committedCount = this.consumeCommittedPendingCount() ?? drained.count;
3004
3124
  for (let i = 0; i < committedCount; i += 1) state.pendingLocal.popFront();
3005
3125
  this.notifySyncWaiters(state, committedCount);
@@ -3158,4 +3278,4 @@ function createStreamUrl(input) {
3158
3278
  //#endregion
3159
3279
  export { InMemoryRemoteCursorStore as a, EphemeralStreamCrdt as c, StreamsCrdt as i, PayloadProtectionError as l, isValidBucketId as n, IndexedDbRemoteCursorStore as o, isValidRillId as r, createInitialRemoteCursor as s, createStreamUrl as t };
3160
3280
 
3161
- //# sourceMappingURL=stream-id-CF1egQDz.js.map
3281
+ //# sourceMappingURL=stream-id-BjmGvObf.js.map