@loro-dev/streams-crdt 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -184,7 +184,8 @@ const DEFAULT_RECONNECT_CONFIG = {
184
184
  jitterFraction: .2,
185
185
  maxAttempts: Infinity,
186
186
  connectTimeoutMs: 1e4,
187
- pollTimeoutMs: 3e4
187
+ pollTimeoutMs: 3e4,
188
+ liveInactivityTimeoutMs: 45e3
188
189
  };
189
190
  /**
190
191
  * Apply jitter of +/- fraction to a base delay.
@@ -677,13 +678,29 @@ var StreamsTransportClient = class {
677
678
  async readItemsSse(initialOffset, cursor, onControl, onOpen, signal) {
678
679
  const opened = await this.openItemsSseSession(initialOffset, cursor, onOpen, signal);
679
680
  if (opened.kind !== "ok") return opened;
680
- const result = await opened.session.continue(onControl);
681
- if (result.paused) await opened.session.cancel();
682
- return {
683
- kind: "ok",
684
- latestOffset: result.latestOffset,
685
- latestCursor: result.latestCursor
686
- };
681
+ let abortHandler;
682
+ const abortRace = signal != null && !signal.aborted ? new Promise((_, reject) => {
683
+ abortHandler = () => {
684
+ opened.session.cancel();
685
+ reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
686
+ };
687
+ signal.addEventListener("abort", abortHandler, { once: true });
688
+ }) : null;
689
+ try {
690
+ if (signal?.aborted) {
691
+ await opened.session.cancel();
692
+ throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
693
+ }
694
+ const result = abortRace != null ? await Promise.race([opened.session.continue(onControl), abortRace]) : await opened.session.continue(onControl);
695
+ if (result.paused) await opened.session.cancel();
696
+ return {
697
+ kind: "ok",
698
+ latestOffset: result.latestOffset,
699
+ latestCursor: result.latestCursor
700
+ };
701
+ } finally {
702
+ if (abortHandler != null && signal != null) signal.removeEventListener("abort", abortHandler);
703
+ }
687
704
  }
688
705
  async openItemsSseSession(initialOffset, cursor, onOpen, signal) {
689
706
  const result = await this.client.openSseSession({
@@ -1074,6 +1091,8 @@ function createJoinState(cursor, liveMode = "sse") {
1074
1091
  refCount: 0,
1075
1092
  closed: false,
1076
1093
  status: "connecting",
1094
+ readSubStatus: "connecting",
1095
+ writeSubStatus: "ok",
1077
1096
  statusListeners: /* @__PURE__ */ new Set(),
1078
1097
  pendingLocal: new Deque(),
1079
1098
  flushingLocal: false,
@@ -1082,7 +1101,8 @@ function createJoinState(cursor, liveMode = "sse") {
1082
1101
  cursor,
1083
1102
  liveMode,
1084
1103
  snapshot: createTransportSnapshotState(),
1085
- started: Promise.resolve()
1104
+ started: Promise.resolve(),
1105
+ syncWaiters: []
1086
1106
  };
1087
1107
  }
1088
1108
  async function disposeJoinState(state, options) {
@@ -1094,6 +1114,8 @@ async function disposeJoinState(state, options) {
1094
1114
  state.unsubscribeLocal = void 0;
1095
1115
  requestController?.abort();
1096
1116
  unsubscribeLocal?.();
1117
+ const disposedError = /* @__PURE__ */ new Error("subscription closed");
1118
+ for (const waiter of state.syncWaiters.splice(0)) waiter.reject(disposedError);
1097
1119
  options.onDisposed?.();
1098
1120
  }
1099
1121
  //#endregion
@@ -1156,8 +1178,8 @@ var StreamsCrdt = class StreamsCrdt {
1156
1178
  adapter: this.adapter,
1157
1179
  config: snapshotUploadConfig,
1158
1180
  encodeSnapshot: snapshotCodec?.compress,
1159
- readSnapshotOffsetValue: () => this.readSnapshotOffsetValue(),
1160
- putSnapshotAt: (snapshotOffset, snapshot) => this.putSnapshotAt(snapshotOffset, snapshot),
1181
+ readSnapshotOffsetValue: () => this.client.readSnapshotOffsetValue(),
1182
+ putSnapshotAt: (snapshotOffset, snapshot) => this.client.putSnapshotAt(snapshotOffset, snapshot),
1161
1183
  enqueueExclusive: (task) => this.enqueueExclusive(task),
1162
1184
  logDebug: (message, detail) => this.logDebug(message, detail),
1163
1185
  logError: (message, error, detail) => this.logError(message, error, detail),
@@ -1175,22 +1197,31 @@ var StreamsCrdt = class StreamsCrdt {
1175
1197
  * Resets the retry counter and immediately attempts to reconnect.
1176
1198
  *
1177
1199
  * Use this after device wake or network recovery to skip any pending
1178
- * backoff delay. No-op when the room is already joined or no join is active.
1200
+ * backoff delay. No-op when the room is already fully healthy (both
1201
+ * read and write sub-statuses are "ok") or no join is active.
1179
1202
  *
1180
- * If the room reached `"disconnected"` status (only possible with a finite
1181
- * `maxAttempts` config), this restarts the read loop.
1203
+ * If the room reached `"disconnected"` or `"error"` status, this
1204
+ * restarts the affected loop(s).
1182
1205
  */
1183
1206
  rejoin() {
1184
1207
  const state = this.joinState;
1185
- if (state == null || state.closed || state.status === "joined") return;
1186
- const wasDisconnected = state.status === "disconnected";
1208
+ if (state == null || state.closed) return;
1209
+ if (state.readSubStatus === "ok" && state.writeSubStatus === "ok") return;
1187
1210
  state.retryAttempt = 0;
1188
1211
  state.retrySleepController.abort();
1189
1212
  state.retrySleepController = new AbortController();
1190
- if (wasDisconnected) {
1191
- this.setJoinStatus(state, "reconnecting");
1213
+ const previousController = state.requestController;
1214
+ state.requestController = new AbortController();
1215
+ previousController?.abort();
1216
+ if (state.readSubStatus === "disconnected" || state.readSubStatus === "error") {
1217
+ this.setReadSubStatus(state, "reconnecting");
1192
1218
  this.runReadLoop(state);
1193
1219
  }
1220
+ if (state.writeSubStatus === "error") {
1221
+ state.writeSubStatus = state.pendingLocal.length > 0 ? "reconnecting" : "ok";
1222
+ this.recomputeJoinStatus(state);
1223
+ if (state.pendingLocal.length > 0) this.flushPendingLocal(state);
1224
+ }
1194
1225
  }
1195
1226
  /**
1196
1227
  * Reads the latest remote snapshot offset, if the server exposes one.
@@ -1199,7 +1230,7 @@ var StreamsCrdt = class StreamsCrdt {
1199
1230
  try {
1200
1231
  return {
1201
1232
  ok: true,
1202
- value: await this.readSnapshotOffsetValue()
1233
+ value: await this.client.readSnapshotOffsetValue()
1203
1234
  };
1204
1235
  } catch (error) {
1205
1236
  return {
@@ -1215,7 +1246,7 @@ var StreamsCrdt = class StreamsCrdt {
1215
1246
  try {
1216
1247
  return {
1217
1248
  ok: true,
1218
- value: { created: await this.createStreamRequest() }
1249
+ value: { created: await this.client.createStreamRequest() }
1219
1250
  };
1220
1251
  } catch (error) {
1221
1252
  return {
@@ -1233,7 +1264,7 @@ var StreamsCrdt = class StreamsCrdt {
1233
1264
  return {
1234
1265
  ok: true,
1235
1266
  value: { deleted: await this.enqueueExclusive(async () => {
1236
- const deleted = await this.deleteStreamRequest();
1267
+ const deleted = await this.client.deleteStreamRequest();
1237
1268
  await this.remoteCursorStore.delete?.(this.streamUrl);
1238
1269
  this.producerEpoch = 0;
1239
1270
  this.nextProducerSeq = 0;
@@ -1330,6 +1361,19 @@ var StreamsCrdt = class StreamsCrdt {
1330
1361
  return () => {
1331
1362
  state.statusListeners.delete(listener);
1332
1363
  };
1364
+ },
1365
+ waitUntilSynced: async () => {
1366
+ await this.adapter.flushLocalExports?.();
1367
+ if (state.closed) throw new Error("subscription closed");
1368
+ const remaining = state.pendingLocal.length;
1369
+ if (remaining === 0) return;
1370
+ return new Promise((resolve, reject) => {
1371
+ state.syncWaiters.push({
1372
+ remaining,
1373
+ resolve,
1374
+ reject
1375
+ });
1376
+ });
1333
1377
  }
1334
1378
  };
1335
1379
  }
@@ -1361,13 +1405,16 @@ var StreamsCrdt = class StreamsCrdt {
1361
1405
  if (state.status === "error" || state.status === "disconnected") throw new Error("join local sync failed before subscription became ready");
1362
1406
  }
1363
1407
  if (state.closed) return;
1364
- this.setJoinStatus(state, "joined");
1408
+ this.setReadSubStatus(state, "ok");
1409
+ this.setWriteSubStatus(state, "ok");
1365
1410
  this.runReadLoop(state);
1366
1411
  } catch (error) {
1367
1412
  if (state.closed) return;
1368
1413
  this.snapshotManager.dispose(state);
1369
1414
  this.logError("join first sync failed", error, { streamUrl: this.streamUrl });
1370
- this.setJoinStatus(state, "error");
1415
+ state.readSubStatus = "error";
1416
+ state.writeSubStatus = "error";
1417
+ this.recomputeJoinStatus(state);
1371
1418
  throw error;
1372
1419
  }
1373
1420
  }
@@ -1419,13 +1466,13 @@ var StreamsCrdt = class StreamsCrdt {
1419
1466
  return caughtUp;
1420
1467
  } catch (error) {
1421
1468
  if (!isStreamNotFoundError(error) || !this.createStreamIfMissing) throw error;
1422
- await this.createStreamRequest();
1469
+ await this.ensureStreamExists();
1423
1470
  return await this.bootstrapState(this.createInitialCursor());
1424
1471
  }
1425
1472
  }
1426
1473
  async bootstrapState(cursor, options = {}) {
1427
1474
  const cursorManager = options.cursorManager ?? this.cursorManager;
1428
- const result = await this.bootstrap(this.streamUrl);
1475
+ const result = await this.client.bootstrap();
1429
1476
  let saved = await cursorManager.applyBootstrapPayload(cursor, result.snapshot, result.updates, result.nextOffset);
1430
1477
  let bootstrapCursor = result.streamCursor;
1431
1478
  if (!result.upToDate) {
@@ -1458,7 +1505,7 @@ var StreamsCrdt = class StreamsCrdt {
1458
1505
  const cursorManager = options.cursorManager ?? this.cursorManager;
1459
1506
  while (true) {
1460
1507
  const requestOffset = current.nextOffset;
1461
- const response = await this.readFromOffset(this.streamUrl, requestOffset, currentStreamCursor, options.live);
1508
+ const response = await this.client.readFromOffset(requestOffset, currentStreamCursor, options.live);
1462
1509
  if (response.kind === "gone") {
1463
1510
  if (goneStrategy === "return-gone") return StreamsCrdt.GONE_CATCHUP;
1464
1511
  return await this.bootstrapState({
@@ -1484,7 +1531,16 @@ var StreamsCrdt = class StreamsCrdt {
1484
1531
  nextProducerSeq: this.nextProducerSeq
1485
1532
  };
1486
1533
  for (let attempt = 0; attempt < 3; attempt += 1) {
1487
- const result = await this.appendUpdates(this.streamUrl, batch.updates, producerSession);
1534
+ let result;
1535
+ try {
1536
+ result = await this.client.appendUpdates(batch.updates, producerSession);
1537
+ } catch (error) {
1538
+ if (isStreamNotFoundError(error) && this.createStreamIfMissing) {
1539
+ await this.ensureStreamExists();
1540
+ continue;
1541
+ }
1542
+ throw error;
1543
+ }
1488
1544
  if (result.kind === "ok") {
1489
1545
  this.producerEpoch = result.ackEpoch;
1490
1546
  this.nextProducerSeq = result.ackSeq + 1;
@@ -1510,6 +1566,17 @@ var StreamsCrdt = class StreamsCrdt {
1510
1566
  }
1511
1567
  throw new Error("append retry budget exhausted");
1512
1568
  }
1569
+ /**
1570
+ * Ensures the target stream exists, creating it if necessary.
1571
+ * Tolerates 409 Conflict (stream already created concurrently).
1572
+ */
1573
+ async ensureStreamExists() {
1574
+ try {
1575
+ await this.client.createStreamRequest();
1576
+ } catch (error) {
1577
+ if (!(error instanceof HttpError && error.status === 409)) throw error;
1578
+ }
1579
+ }
1513
1580
  exportUpdates(fromVersion) {
1514
1581
  const exported = this.adapter.exportUpdates(fromVersion);
1515
1582
  if (exported == null) return { nextRefVersion: fromVersion };
@@ -1538,6 +1605,17 @@ var StreamsCrdt = class StreamsCrdt {
1538
1605
  if (isolated == null) return null;
1539
1606
  return this.createCursorManager(isolated, async (cursor) => cursor);
1540
1607
  }
1608
+ /**
1609
+ * Infers remote state via bootstrap with the given cursor manager,
1610
+ * exports and appends any local delta, then re-bootstraps to converge
1611
+ * persisted cursor and local CRDT state on server truth.
1612
+ */
1613
+ async inferRemoteAndAppendLocal(bootstrapCursor, cursorManager) {
1614
+ const inferredRemote = await this.bootstrapState(bootstrapCursor, { cursorManager });
1615
+ const localBatch = this.exportUpdates(inferredRemote.cursor.serverLowerBoundVersion);
1616
+ if (localBatch.batch != null) await this.appendLocalBatch(inferredRemote.cursor, localBatch.batch, false);
1617
+ return await this.bootstrapState(bootstrapCursor);
1618
+ }
1541
1619
  async recoverFromGoneWithLocalBootstrap(cursor) {
1542
1620
  const isolatedCursorManager = await this.createIsolatedCursorManager();
1543
1621
  const bootstrapCursor = {
@@ -1545,12 +1623,7 @@ var StreamsCrdt = class StreamsCrdt {
1545
1623
  nextOffset: "-1",
1546
1624
  serverLowerBoundVersion: this.adapter.emptyVersion()
1547
1625
  };
1548
- if (isolatedCursorManager != null) {
1549
- const inferredRemote = await this.bootstrapState(bootstrapCursor, { cursorManager: isolatedCursorManager });
1550
- const localBatch = this.exportUpdates(inferredRemote.cursor.serverLowerBoundVersion);
1551
- if (localBatch.batch != null) await this.appendLocalBatch(inferredRemote.cursor, localBatch.batch, false);
1552
- return await this.bootstrapState(bootstrapCursor);
1553
- }
1626
+ if (isolatedCursorManager != null) return await this.inferRemoteAndAppendLocal(bootstrapCursor, isolatedCursorManager);
1554
1627
  const preservedLocalSnapshot = this.adapter.exportSnapshot();
1555
1628
  let restoredLocalSnapshot = false;
1556
1629
  try {
@@ -1567,121 +1640,166 @@ var StreamsCrdt = class StreamsCrdt {
1567
1640
  }
1568
1641
  async runReadLoop(state) {
1569
1642
  state.requestController ??= new AbortController();
1570
- while (!state.closed) try {
1571
- if (state.liveMode === "long-poll") {
1572
- const requestOffset = state.cursor.nextOffset;
1573
- const response = await this.readFromOffset(this.streamUrl, requestOffset, state.streamCursor, "long-poll", state.requestController?.signal);
1574
- if (response.kind === "gone") {
1575
- const bootstrapped = await this.bootstrapState({
1576
- ...state.cursor,
1577
- nextOffset: "-1",
1578
- serverLowerBoundVersion: this.adapter.emptyVersion()
1643
+ while (!state.closed) {
1644
+ const capturedController = state.requestController;
1645
+ let inactivityTimer = null;
1646
+ let inactivityTriggered = false;
1647
+ const clearInactivity = () => {
1648
+ if (inactivityTimer != null) {
1649
+ clearTimeout(inactivityTimer);
1650
+ inactivityTimer = null;
1651
+ }
1652
+ };
1653
+ const resetInactivity = () => {
1654
+ clearInactivity();
1655
+ const timeoutMs = this.reconnectConfig.liveInactivityTimeoutMs;
1656
+ if (timeoutMs != null && timeoutMs > 0 && !state.closed) inactivityTimer = setTimeout(() => {
1657
+ inactivityTriggered = true;
1658
+ capturedController?.abort();
1659
+ }, timeoutMs);
1660
+ };
1661
+ try {
1662
+ if (state.liveMode === "long-poll") {
1663
+ const requestOffset = state.cursor.nextOffset;
1664
+ const response = await this.client.readFromOffset(requestOffset, state.streamCursor, "long-poll", capturedController?.signal);
1665
+ if (response.kind === "gone") {
1666
+ const bootstrapped = await this.bootstrapState({
1667
+ ...state.cursor,
1668
+ nextOffset: "-1",
1669
+ serverLowerBoundVersion: this.adapter.emptyVersion()
1670
+ });
1671
+ state.cursor = bootstrapped.cursor;
1672
+ state.streamCursor = bootstrapped.streamCursor;
1673
+ state.retryAttempt = 0;
1674
+ continue;
1675
+ }
1676
+ const result = response.value;
1677
+ 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`);
1678
+ await this.enqueueExclusive(async () => {
1679
+ if (state.closed) return;
1680
+ state.cursor = await this.cursorManager.applyLiveResult(state.cursor, requestOffset, result, (previousVersion, nextVersion) => {
1681
+ this.snapshotManager.observeRemoteVersion(state, previousVersion, nextVersion);
1682
+ });
1683
+ if (result.streamCursor != null) state.streamCursor = result.streamCursor;
1579
1684
  });
1580
- state.cursor = bootstrapped.cursor;
1581
- state.streamCursor = bootstrapped.streamCursor;
1685
+ if (state.closed) return;
1582
1686
  state.retryAttempt = 0;
1687
+ this.setReadSubStatus(state, "ok");
1688
+ await sleep(0);
1583
1689
  continue;
1584
1690
  }
1585
- const result = response.value;
1586
- 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`);
1587
- await this.enqueueExclusive(async () => {
1588
- if (state.closed) return;
1589
- state.cursor = await this.cursorManager.applyLiveResult(state.cursor, requestOffset, result, (previousVersion, nextVersion) => {
1590
- this.snapshotManager.observeRemoteVersion(state, previousVersion, nextVersion);
1691
+ const sseRequestOffset = state.cursor.nextOffset;
1692
+ const pendingRemote = [];
1693
+ let flushChain = Promise.resolve();
1694
+ resetInactivity();
1695
+ const sseResult = await this.client.readFromOffsetSseItems(sseRequestOffset, state.streamCursor, (updates, nextOffset, _upToDate, streamCursor) => {
1696
+ resetInactivity();
1697
+ pendingRemote.push({
1698
+ updates,
1699
+ nextOffset,
1700
+ streamCursor
1591
1701
  });
1592
- if (result.streamCursor != null) state.streamCursor = result.streamCursor;
1593
- });
1594
- if (state.closed) return;
1595
- state.retryAttempt = 0;
1596
- this.setJoinStatus(state, "joined");
1597
- await sleep(0);
1598
- continue;
1599
- }
1600
- const sseRequestOffset = state.cursor.nextOffset;
1601
- const pendingRemote = [];
1602
- let flushChain = Promise.resolve();
1603
- const sseResult = await this.readFromOffsetSseItems(this.streamUrl, sseRequestOffset, state.streamCursor, (updates, nextOffset, _upToDate, streamCursor) => {
1604
- pendingRemote.push({
1605
- updates,
1606
- nextOffset,
1607
- streamCursor
1608
- });
1609
- flushChain = flushChain.then(async () => {
1610
- const events = pendingRemote.splice(0);
1611
- if (events.length === 0 || state.closed) return;
1612
- const allUpdates = events.flatMap((e) => e.updates);
1613
- const lastEvent = events[events.length - 1];
1614
- await this.enqueueExclusive(async () => {
1615
- if (state.closed) return;
1616
- state.cursor = await this.cursorManager.applyLiveResult(state.cursor, sseRequestOffset, {
1617
- nextOffset: lastEvent.nextOffset,
1618
- updates: allUpdates
1619
- }, (previousVersion, nextVersion) => {
1620
- this.snapshotManager.observeRemoteVersion(state, previousVersion, nextVersion);
1702
+ flushChain = flushChain.then(async () => {
1703
+ const events = pendingRemote.splice(0);
1704
+ if (events.length === 0 || state.closed) return;
1705
+ const allUpdates = events.flatMap((e) => e.updates);
1706
+ const lastEvent = events[events.length - 1];
1707
+ await this.enqueueExclusive(async () => {
1708
+ if (state.closed) return;
1709
+ state.cursor = await this.cursorManager.applyLiveResult(state.cursor, sseRequestOffset, {
1710
+ nextOffset: lastEvent.nextOffset,
1711
+ updates: allUpdates
1712
+ }, (previousVersion, nextVersion) => {
1713
+ this.snapshotManager.observeRemoteVersion(state, previousVersion, nextVersion);
1714
+ });
1715
+ for (let i = events.length - 1; i >= 0; i--) if (events[i].streamCursor != null) {
1716
+ state.streamCursor = events[i].streamCursor;
1717
+ break;
1718
+ }
1621
1719
  });
1622
- for (let i = events.length - 1; i >= 0; i--) if (events[i].streamCursor != null) {
1623
- state.streamCursor = events[i].streamCursor;
1624
- break;
1625
- }
1720
+ this.setReadSubStatus(state, "ok");
1721
+ });
1722
+ }, () => {
1723
+ resetInactivity();
1724
+ this.setReadSubStatus(state, "ok");
1725
+ }, capturedController?.signal);
1726
+ clearInactivity();
1727
+ await flushChain;
1728
+ if (state.closed) return;
1729
+ if (sseResult.kind === "ok") {
1730
+ state.retryAttempt = 0;
1731
+ state.streamCursor = sseResult.latestCursor;
1732
+ await sleep(0);
1733
+ continue;
1734
+ }
1735
+ if (sseResult.kind === "unsupported") {
1736
+ state.liveMode = "long-poll";
1737
+ await sleep(0);
1738
+ continue;
1739
+ }
1740
+ if (sseResult.kind === "not-found") {
1741
+ this.logError("live read failed", new HttpError("stream not found", 404), {
1742
+ streamUrl: this.streamUrl,
1743
+ code: "stream_not_found"
1626
1744
  });
1627
- this.setJoinStatus(state, "joined");
1745
+ this.setReadSubStatus(state, "error");
1746
+ return;
1747
+ }
1748
+ const bootstrapped = await this.bootstrapState({
1749
+ ...state.cursor,
1750
+ nextOffset: "-1",
1751
+ serverLowerBoundVersion: this.adapter.emptyVersion()
1628
1752
  });
1629
- }, () => {
1630
- this.setJoinStatus(state, "joined");
1631
- }, state.requestController?.signal);
1632
- await flushChain;
1633
- if (state.closed) return;
1634
- if (sseResult.kind === "ok") {
1753
+ state.cursor = bootstrapped.cursor;
1754
+ state.streamCursor = bootstrapped.streamCursor;
1635
1755
  state.retryAttempt = 0;
1636
- state.streamCursor = sseResult.latestCursor;
1637
- await sleep(0);
1638
- continue;
1639
- }
1640
- if (sseResult.kind === "unsupported") {
1641
- state.liveMode = "long-poll";
1642
- await sleep(0);
1643
- continue;
1644
- }
1645
- if (sseResult.kind === "not-found") {
1646
- this.logError("live read failed", new HttpError("stream not found", 404), {
1647
- streamUrl: this.streamUrl,
1648
- code: "stream_not_found"
1649
- });
1650
- this.setJoinStatus(state, "error");
1651
- return;
1652
- }
1653
- const bootstrapped = await this.bootstrapState({
1654
- ...state.cursor,
1655
- nextOffset: "-1",
1656
- serverLowerBoundVersion: this.adapter.emptyVersion()
1657
- });
1658
- state.cursor = bootstrapped.cursor;
1659
- state.streamCursor = bootstrapped.streamCursor;
1660
- state.retryAttempt = 0;
1661
- } catch (error) {
1662
- if (state.closed) return;
1663
- const classified = classifyFetchError(error);
1664
- if (!classified.retriable || isAuthOrProtocolError(error)) {
1665
- this.logError("live read failed", error, {
1666
- streamUrl: this.streamUrl,
1667
- code: classified.code
1668
- });
1669
- this.setJoinStatus(state, "error");
1670
- return;
1671
- }
1672
- this.setJoinStatus(state, "reconnecting");
1673
- const maxAttempts = this.reconnectConfig.maxAttempts;
1674
- if (state.retryAttempt >= maxAttempts) {
1675
- this.logError("live read reconnect exhausted", error, {
1676
- streamUrl: this.streamUrl,
1677
- attempts: state.retryAttempt
1678
- });
1679
- this.setJoinStatus(state, "disconnected");
1680
- return;
1756
+ } catch (error) {
1757
+ clearInactivity();
1758
+ if (state.closed) return;
1759
+ if (capturedController?.signal.aborted) {
1760
+ if (inactivityTriggered) {
1761
+ if (state.requestController === capturedController) state.requestController = new AbortController();
1762
+ this.logDebug("sse inactivity timeout", { streamUrl: this.streamUrl });
1763
+ this.setReadSubStatus(state, "reconnecting");
1764
+ const maxAttempts = this.reconnectConfig.maxAttempts;
1765
+ if (state.retryAttempt >= maxAttempts) {
1766
+ this.logError("live read reconnect exhausted (inactivity)", error, {
1767
+ streamUrl: this.streamUrl,
1768
+ attempts: state.retryAttempt
1769
+ });
1770
+ this.setReadSubStatus(state, "disconnected");
1771
+ return;
1772
+ }
1773
+ state.retryAttempt += 1;
1774
+ continue;
1775
+ }
1776
+ state.retryAttempt = 0;
1777
+ this.setReadSubStatus(state, "reconnecting");
1778
+ continue;
1779
+ }
1780
+ const classified = classifyFetchError(error);
1781
+ if (!classified.retriable || isAuthOrProtocolError(error)) {
1782
+ this.logError("live read failed", error, {
1783
+ streamUrl: this.streamUrl,
1784
+ code: classified.code
1785
+ });
1786
+ this.setReadSubStatus(state, "error");
1787
+ return;
1788
+ }
1789
+ this.setReadSubStatus(state, "reconnecting");
1790
+ const maxAttempts = this.reconnectConfig.maxAttempts;
1791
+ if (state.retryAttempt >= maxAttempts) {
1792
+ this.logError("live read reconnect exhausted", error, {
1793
+ streamUrl: this.streamUrl,
1794
+ attempts: state.retryAttempt
1795
+ });
1796
+ this.setReadSubStatus(state, "disconnected");
1797
+ return;
1798
+ }
1799
+ const delay = computeRetryDelay(state.retryAttempt, this.reconnectConfig);
1800
+ state.retryAttempt += 1;
1801
+ await sleep(delay, state.retrySleepController.signal);
1681
1802
  }
1682
- const delay = computeRetryDelay(state.retryAttempt, this.reconnectConfig);
1683
- state.retryAttempt += 1;
1684
- await sleep(delay, state.retrySleepController.signal);
1685
1803
  }
1686
1804
  }
1687
1805
  /** Maximum encoded size (bytes) for a single batched append. */
@@ -1732,7 +1850,8 @@ var StreamsCrdt = class StreamsCrdt {
1732
1850
  while (!state.closed) try {
1733
1851
  state.cursor = await this.enqueueExclusive(async () => this.appendLocalBatch(state.cursor, drained.batch));
1734
1852
  for (let i = 0; i < drained.count; i += 1) state.pendingLocal.popFront();
1735
- this.setJoinStatus(state, "joined");
1853
+ this.notifySyncWaiters(state, drained.count);
1854
+ this.setWriteSubStatus(state, "ok");
1736
1855
  this.snapshotManager.schedule(state);
1737
1856
  break;
1738
1857
  } catch (error) {
@@ -1742,15 +1861,17 @@ var StreamsCrdt = class StreamsCrdt {
1742
1861
  streamUrl: this.streamUrl,
1743
1862
  code: classified.code
1744
1863
  });
1745
- this.setJoinStatus(state, "error");
1864
+ this.rejectSyncWaiters(state, /* @__PURE__ */ new Error(`local append failed: ${classified.code}`));
1865
+ this.setWriteSubStatus(state, "error");
1746
1866
  return;
1747
1867
  }
1748
1868
  const maxAttempts = this.reconnectConfig.maxAttempts;
1749
1869
  if (attempts >= maxAttempts) {
1750
- this.setJoinStatus(state, "error");
1870
+ this.rejectSyncWaiters(state, /* @__PURE__ */ new Error("local append retry budget exhausted"));
1871
+ this.setWriteSubStatus(state, "error");
1751
1872
  return;
1752
1873
  }
1753
- this.setJoinStatus(state, "reconnecting");
1874
+ this.setWriteSubStatus(state, "reconnecting");
1754
1875
  const delay = computeRetryDelay(attempts, this.reconnectConfig);
1755
1876
  attempts += 1;
1756
1877
  await sleep(delay, state.retrySleepController.signal);
@@ -1760,6 +1881,25 @@ var StreamsCrdt = class StreamsCrdt {
1760
1881
  state.flushingLocal = false;
1761
1882
  }
1762
1883
  }
1884
+ /**
1885
+ * Decrements all sync waiters by the number of flushed batches and
1886
+ * resolves any that have reached zero.
1887
+ */
1888
+ notifySyncWaiters(state, flushedCount) {
1889
+ let i = 0;
1890
+ while (i < state.syncWaiters.length) {
1891
+ const waiter = state.syncWaiters[i];
1892
+ waiter.remaining -= flushedCount;
1893
+ if (waiter.remaining <= 0) {
1894
+ state.syncWaiters.splice(i, 1);
1895
+ waiter.resolve();
1896
+ } else i += 1;
1897
+ }
1898
+ }
1899
+ /** Rejects and removes all sync waiters. */
1900
+ rejectSyncWaiters(state, error) {
1901
+ for (const waiter of state.syncWaiters.splice(0)) waiter.reject(error);
1902
+ }
1763
1903
  async saveRemoteCursor(cursor, source) {
1764
1904
  return await persistRemoteCursor({
1765
1905
  remoteCursorStore: this.remoteCursorStore,
@@ -1768,34 +1908,29 @@ var StreamsCrdt = class StreamsCrdt {
1768
1908
  beforeRemoteCursorSave: this.beforeRemoteCursorSave
1769
1909
  });
1770
1910
  }
1771
- setJoinStatus(state, status) {
1911
+ setReadSubStatus(state, sub) {
1912
+ if (state.readSubStatus === sub) return;
1913
+ state.readSubStatus = sub;
1914
+ this.recomputeJoinStatus(state);
1915
+ }
1916
+ setWriteSubStatus(state, sub) {
1917
+ if (state.writeSubStatus === sub) return;
1918
+ state.writeSubStatus = sub;
1919
+ this.recomputeJoinStatus(state);
1920
+ }
1921
+ recomputeJoinStatus(state) {
1922
+ const status = this.computeOverallStatus(state);
1772
1923
  if (state.status === status) return;
1773
1924
  state.status = status;
1774
1925
  for (const listener of state.statusListeners) listener(status);
1775
1926
  }
1776
- async createStreamRequest() {
1777
- return await this.client.createStreamRequest();
1778
- }
1779
- async deleteStreamRequest() {
1780
- return await this.client.deleteStreamRequest();
1781
- }
1782
- async readFromOffsetSseItems(_streamUrl, offset, cursor, onControl, onOpen, signal) {
1783
- return await this.client.readFromOffsetSseItems(offset, cursor, onControl, onOpen, signal);
1784
- }
1785
- async readFromOffset(_streamUrl, offset, cursor, live, signal) {
1786
- return await this.client.readFromOffset(offset, cursor, live, signal);
1787
- }
1788
- async bootstrap(_streamUrl) {
1789
- return await this.client.bootstrap();
1790
- }
1791
- async readSnapshotOffsetValue() {
1792
- return await this.client.readSnapshotOffsetValue();
1793
- }
1794
- async putSnapshotAt(snapshotOffset, snapshot) {
1795
- await this.client.putSnapshotAt(snapshotOffset, snapshot);
1796
- }
1797
- async appendUpdates(_streamUrl, updates, producerSession) {
1798
- return await this.client.appendUpdates(updates, producerSession);
1927
+ computeOverallStatus(state) {
1928
+ const { readSubStatus, writeSubStatus } = state;
1929
+ if (readSubStatus === "error" || writeSubStatus === "error") return "error";
1930
+ if (readSubStatus === "disconnected") return "disconnected";
1931
+ if (readSubStatus === "connecting") return "connecting";
1932
+ if (readSubStatus === "reconnecting" || writeSubStatus === "reconnecting") return "reconnecting";
1933
+ return "joined";
1799
1934
  }
1800
1935
  enqueueExclusive(task) {
1801
1936
  const run = this.opQueue.then(task, task);
@@ -1855,4 +1990,4 @@ function createStreamUrl(input) {
1855
1990
  //#endregion
1856
1991
  export { InMemoryRemoteCursorStore as a, StreamsCrdt as i, isValidBucketId as n, IndexedDbRemoteCursorStore as o, isValidRillId as r, createInitialRemoteCursor as s, createStreamUrl as t };
1857
1992
 
1858
- //# sourceMappingURL=stream-id-nPEdFo7c.js.map
1993
+ //# sourceMappingURL=stream-id-0dTFsiaO.js.map