@korajs/sync 1.0.0-beta.0 → 1.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ var SYNC_STATES = [
14
14
  ];
15
15
  var SYNC_STATUSES = [
16
16
  "connected",
17
+ "reconnecting",
17
18
  "syncing",
18
19
  "synced",
19
20
  "offline",
@@ -21,6 +22,20 @@ var SYNC_STATUSES = [
21
22
  "error",
22
23
  "schema-mismatch"
23
24
  ];
25
+ var MemoryRejectedOperationStorage = class {
26
+ rejected = /* @__PURE__ */ new Map();
27
+ async record(rejected) {
28
+ this.rejected.set(rejected.operationId, rejected);
29
+ }
30
+ async list() {
31
+ return [...this.rejected.values()];
32
+ }
33
+ async remove(operationIds) {
34
+ for (const id of operationIds) {
35
+ this.rejected.delete(id);
36
+ }
37
+ }
38
+ };
24
39
  var ScopeViolationError = class extends KoraError {
25
40
  constructor(operationId, collection, scope, message) {
26
41
  super(
@@ -218,10 +233,18 @@ function isSyncMessage(value) {
218
233
  return isAcknowledgmentMessage(value);
219
234
  case "error":
220
235
  return isErrorMessage(value);
236
+ case "operation-rejected":
237
+ return isOperationRejectedMessage(value);
221
238
  case "awareness-update":
222
239
  return isAwarenessUpdateMessage(value);
223
240
  case "yjs-doc-update":
224
241
  return isYjsDocUpdateMessage(value);
242
+ case "blob-chunk-request":
243
+ return isBlobChunkRequestMessage(value);
244
+ case "blob-chunk-response":
245
+ return isBlobChunkResponseMessage(value);
246
+ case "blob-chunk-push":
247
+ return isBlobChunkPushMessage(value);
225
248
  default:
226
249
  return false;
227
250
  }
@@ -251,6 +274,11 @@ function isErrorMessage(value) {
251
274
  const msg = value;
252
275
  return msg.type === "error" && typeof msg.messageId === "string" && typeof msg.code === "string" && typeof msg.message === "string" && typeof msg.retriable === "boolean";
253
276
  }
277
+ function isOperationRejectedMessage(value) {
278
+ if (typeof value !== "object" || value === null) return false;
279
+ const msg = value;
280
+ return msg.type === "operation-rejected" && typeof msg.messageId === "string" && typeof msg.operationId === "string" && typeof msg.collection === "string" && typeof msg.recordId === "string" && typeof msg.code === "string" && typeof msg.message === "string" && typeof msg.retriable === "boolean";
281
+ }
254
282
  function isAwarenessUpdateMessage(value) {
255
283
  if (typeof value !== "object" || value === null) return false;
256
284
  const msg = value;
@@ -261,6 +289,21 @@ function isYjsDocUpdateMessage(value) {
261
289
  const msg = value;
262
290
  return msg.type === "yjs-doc-update" && typeof msg.messageId === "string" && typeof msg.collection === "string" && typeof msg.recordId === "string" && typeof msg.field === "string" && typeof msg.update === "string";
263
291
  }
292
+ function isBlobChunkRequestMessage(value) {
293
+ if (typeof value !== "object" || value === null) return false;
294
+ const msg = value;
295
+ return msg.type === "blob-chunk-request" && typeof msg.messageId === "string" && typeof msg.requestId === "string" && typeof msg.hash === "string";
296
+ }
297
+ function isBlobChunkResponseMessage(value) {
298
+ if (typeof value !== "object" || value === null) return false;
299
+ const msg = value;
300
+ return msg.type === "blob-chunk-response" && typeof msg.messageId === "string" && typeof msg.requestId === "string" && (typeof msg.bytes === "string" || msg.bytes === null);
301
+ }
302
+ function isBlobChunkPushMessage(value) {
303
+ if (typeof value !== "object" || value === null) return false;
304
+ const msg = value;
305
+ return msg.type === "blob-chunk-push" && typeof msg.messageId === "string" && typeof msg.hash === "string" && typeof msg.bytes === "string";
306
+ }
264
307
 
265
308
  // src/protocol/serializer.ts
266
309
  import { SyncError } from "@korajs/core";
@@ -486,7 +529,8 @@ function toProtoEnvelope(message) {
486
529
  })),
487
530
  schemaVersion: message.schemaVersion,
488
531
  authToken: message.authToken,
489
- supportedWireFormats: message.supportedWireFormats
532
+ supportedWireFormats: message.supportedWireFormats,
533
+ ...message.lastDeliverySequence !== void 0 ? { lastDeliverySequence: message.lastDeliverySequence } : {}
490
534
  };
491
535
  case "handshake-response":
492
536
  return {
@@ -501,7 +545,9 @@ function toProtoEnvelope(message) {
501
545
  accepted: message.accepted,
502
546
  rejectReason: message.rejectReason,
503
547
  selectedWireFormat: message.selectedWireFormat,
504
- serverTime: message.serverTime
548
+ serverTime: message.serverTime,
549
+ blobStorageEnabled: message.blobStorageEnabled,
550
+ ...message.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: message.serverMaxDeliverySequence } : {}
505
551
  };
506
552
  case "operation-batch":
507
553
  return {
@@ -509,14 +555,17 @@ function toProtoEnvelope(message) {
509
555
  messageId: message.messageId,
510
556
  operations: message.operations.map(serializeProtoOperation),
511
557
  isFinal: message.isFinal,
512
- batchIndex: message.batchIndex
558
+ batchIndex: message.batchIndex,
559
+ ...message.baseDeliverySequence !== void 0 ? { baseDeliverySequence: message.baseDeliverySequence } : {},
560
+ ...message.maxDeliverySequence !== void 0 ? { maxDeliverySequence: message.maxDeliverySequence } : {}
513
561
  };
514
562
  case "acknowledgment":
515
563
  return {
516
564
  type: message.type,
517
565
  messageId: message.messageId,
518
566
  acknowledgedMessageId: message.acknowledgedMessageId,
519
- lastSequenceNumber: message.lastSequenceNumber
567
+ lastSequenceNumber: message.lastSequenceNumber,
568
+ ...message.deliverySequence !== void 0 ? { deliverySequence: message.deliverySequence } : {}
520
569
  };
521
570
  case "error":
522
571
  return {
@@ -526,12 +575,45 @@ function toProtoEnvelope(message) {
526
575
  errorMessage: message.message,
527
576
  retriable: message.retriable
528
577
  };
578
+ case "operation-rejected":
579
+ return {
580
+ type: message.type,
581
+ messageId: message.messageId,
582
+ operationId: message.operationId,
583
+ collection: message.collection,
584
+ recordId: message.recordId,
585
+ errorCode: message.code,
586
+ errorMessage: message.message,
587
+ retriable: message.retriable
588
+ };
529
589
  case "awareness-update":
530
590
  case "yjs-doc-update":
531
591
  return {
532
592
  type: message.type,
533
593
  messageId: message.messageId
534
594
  };
595
+ case "blob-chunk-request":
596
+ return {
597
+ type: message.type,
598
+ messageId: message.messageId,
599
+ requestId: message.requestId,
600
+ hash: message.hash
601
+ };
602
+ case "blob-chunk-response":
603
+ return {
604
+ type: message.type,
605
+ messageId: message.messageId,
606
+ requestId: message.requestId,
607
+ hasBytes: message.bytes !== null,
608
+ ...message.bytes !== null ? { chunkBytes: message.bytes } : {}
609
+ };
610
+ case "blob-chunk-push":
611
+ return {
612
+ type: message.type,
613
+ messageId: message.messageId,
614
+ hash: message.hash,
615
+ chunkBytes: message.bytes
616
+ };
535
617
  }
536
618
  }
537
619
  function fromProtoEnvelope(envelope) {
@@ -548,7 +630,8 @@ function fromProtoEnvelope(envelope) {
548
630
  authToken: envelope.authToken,
549
631
  supportedWireFormats: envelope.supportedWireFormats?.filter(
550
632
  (format) => format === "json" || format === "protobuf"
551
- )
633
+ ),
634
+ ...envelope.lastDeliverySequence !== void 0 ? { lastDeliverySequence: envelope.lastDeliverySequence } : {}
552
635
  };
553
636
  case "handshake-response":
554
637
  return {
@@ -564,7 +647,9 @@ function fromProtoEnvelope(envelope) {
564
647
  selectedWireFormat: envelope.selectedWireFormat === "json" || envelope.selectedWireFormat === "protobuf" ? envelope.selectedWireFormat : void 0,
565
648
  // Preserve the server's wall-clock time so clock-skew detection and
566
649
  // automatic timestamp rebase work over the protobuf wire, not just JSON.
567
- ...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {}
650
+ ...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {},
651
+ ...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {},
652
+ ...envelope.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: envelope.serverMaxDeliverySequence } : {}
568
653
  };
569
654
  case "operation-batch":
570
655
  return {
@@ -572,14 +657,17 @@ function fromProtoEnvelope(envelope) {
572
657
  messageId: envelope.messageId,
573
658
  operations: (envelope.operations ?? []).map(deserializeProtoOperation),
574
659
  isFinal: envelope.isFinal ?? false,
575
- batchIndex: envelope.batchIndex ?? 0
660
+ batchIndex: envelope.batchIndex ?? 0,
661
+ ...envelope.baseDeliverySequence !== void 0 ? { baseDeliverySequence: envelope.baseDeliverySequence } : {},
662
+ ...envelope.maxDeliverySequence !== void 0 ? { maxDeliverySequence: envelope.maxDeliverySequence } : {}
576
663
  };
577
664
  case "acknowledgment":
578
665
  return {
579
666
  type: "acknowledgment",
580
667
  messageId: envelope.messageId,
581
668
  acknowledgedMessageId: envelope.acknowledgedMessageId ?? "",
582
- lastSequenceNumber: envelope.lastSequenceNumber ?? 0
669
+ lastSequenceNumber: envelope.lastSequenceNumber ?? 0,
670
+ ...envelope.deliverySequence !== void 0 ? { deliverySequence: envelope.deliverySequence } : {}
583
671
  };
584
672
  case "error":
585
673
  return {
@@ -589,6 +677,38 @@ function fromProtoEnvelope(envelope) {
589
677
  message: envelope.errorMessage ?? "Unknown error",
590
678
  retriable: envelope.retriable ?? false
591
679
  };
680
+ case "operation-rejected":
681
+ return {
682
+ type: "operation-rejected",
683
+ messageId: envelope.messageId,
684
+ operationId: envelope.operationId ?? "",
685
+ collection: envelope.collection ?? "",
686
+ recordId: envelope.recordId ?? "",
687
+ code: envelope.errorCode ?? "REJECTED",
688
+ message: envelope.errorMessage ?? "",
689
+ retriable: envelope.retriable ?? false
690
+ };
691
+ case "blob-chunk-request":
692
+ return {
693
+ type: "blob-chunk-request",
694
+ messageId: envelope.messageId,
695
+ requestId: envelope.requestId ?? "",
696
+ hash: envelope.hash ?? ""
697
+ };
698
+ case "blob-chunk-response":
699
+ return {
700
+ type: "blob-chunk-response",
701
+ messageId: envelope.messageId,
702
+ requestId: envelope.requestId ?? "",
703
+ bytes: envelope.hasBytes ? envelope.chunkBytes ?? "" : null
704
+ };
705
+ case "blob-chunk-push":
706
+ return {
707
+ type: "blob-chunk-push",
708
+ messageId: envelope.messageId,
709
+ hash: envelope.hash ?? "",
710
+ bytes: envelope.chunkBytes ?? ""
711
+ };
592
712
  default:
593
713
  throw new SyncError("Failed to decode sync message: unknown protobuf type", {
594
714
  type: envelope.type
@@ -734,6 +854,29 @@ function encodeEnvelope(envelope) {
734
854
  }
735
855
  if (envelope.retriable !== void 0) writer.uint32(144).bool(envelope.retriable);
736
856
  if (envelope.serverTime !== void 0) writer.uint32(152).int64(envelope.serverTime);
857
+ if (envelope.requestId && envelope.requestId.length > 0)
858
+ writer.uint32(162).string(envelope.requestId);
859
+ if (envelope.hash && envelope.hash.length > 0) writer.uint32(170).string(envelope.hash);
860
+ if (envelope.chunkBytes && envelope.chunkBytes.length > 0)
861
+ writer.uint32(178).string(envelope.chunkBytes);
862
+ if (envelope.hasBytes !== void 0) writer.uint32(184).bool(envelope.hasBytes);
863
+ if (envelope.blobStorageEnabled !== void 0)
864
+ writer.uint32(200).bool(envelope.blobStorageEnabled);
865
+ if (envelope.operationId && envelope.operationId.length > 0)
866
+ writer.uint32(210).string(envelope.operationId);
867
+ if (envelope.collection && envelope.collection.length > 0)
868
+ writer.uint32(218).string(envelope.collection);
869
+ if (envelope.recordId && envelope.recordId.length > 0)
870
+ writer.uint32(226).string(envelope.recordId);
871
+ if (envelope.lastDeliverySequence !== void 0)
872
+ writer.uint32(232).int64(envelope.lastDeliverySequence);
873
+ if (envelope.baseDeliverySequence !== void 0)
874
+ writer.uint32(240).int64(envelope.baseDeliverySequence);
875
+ if (envelope.maxDeliverySequence !== void 0)
876
+ writer.uint32(248).int64(envelope.maxDeliverySequence);
877
+ if (envelope.deliverySequence !== void 0) writer.uint32(256).int64(envelope.deliverySequence);
878
+ if (envelope.serverMaxDeliverySequence !== void 0)
879
+ writer.uint32(264).int64(envelope.serverMaxDeliverySequence);
737
880
  return writer.finish();
738
881
  }
739
882
  function decodeEnvelope(bytes) {
@@ -805,6 +948,45 @@ function decodeEnvelope(bytes) {
805
948
  case 19:
806
949
  envelope.serverTime = longToNumber(reader.int64());
807
950
  break;
951
+ case 20:
952
+ envelope.requestId = reader.string();
953
+ break;
954
+ case 21:
955
+ envelope.hash = reader.string();
956
+ break;
957
+ case 22:
958
+ envelope.chunkBytes = reader.string();
959
+ break;
960
+ case 23:
961
+ envelope.hasBytes = reader.bool();
962
+ break;
963
+ case 25:
964
+ envelope.blobStorageEnabled = reader.bool();
965
+ break;
966
+ case 26:
967
+ envelope.operationId = reader.string();
968
+ break;
969
+ case 27:
970
+ envelope.collection = reader.string();
971
+ break;
972
+ case 28:
973
+ envelope.recordId = reader.string();
974
+ break;
975
+ case 29:
976
+ envelope.lastDeliverySequence = longToNumber(reader.int64());
977
+ break;
978
+ case 30:
979
+ envelope.baseDeliverySequence = longToNumber(reader.int64());
980
+ break;
981
+ case 31:
982
+ envelope.maxDeliverySequence = longToNumber(reader.int64());
983
+ break;
984
+ case 32:
985
+ envelope.deliverySequence = longToNumber(reader.int64());
986
+ break;
987
+ case 33:
988
+ envelope.serverMaxDeliverySequence = longToNumber(reader.int64());
989
+ break;
808
990
  default:
809
991
  reader.skipType(tag & 7);
810
992
  }
@@ -1304,6 +1486,7 @@ var ChaosTransport = class {
1304
1486
  reorderRate;
1305
1487
  maxLatency;
1306
1488
  random;
1489
+ dropPredicate;
1307
1490
  messageHandler = null;
1308
1491
  reorderBuffer = [];
1309
1492
  pending = [];
@@ -1314,6 +1497,7 @@ var ChaosTransport = class {
1314
1497
  this.reorderRate = config?.reorderRate ?? 0;
1315
1498
  this.maxLatency = config?.maxLatency ?? 0;
1316
1499
  this.random = config?.randomSource ?? Math.random;
1500
+ this.dropPredicate = config?.dropPredicate ?? null;
1317
1501
  }
1318
1502
  async connect(url, options) {
1319
1503
  this.inner.onMessage((msg) => this.handleIncoming(msg));
@@ -1332,6 +1516,9 @@ var ChaosTransport = class {
1332
1516
  return this.inner.disconnect();
1333
1517
  }
1334
1518
  send(message) {
1519
+ if (this.dropPredicate?.(message, "outgoing")) {
1520
+ return;
1521
+ }
1335
1522
  if (this.random() < this.dropRate) {
1336
1523
  return;
1337
1524
  }
@@ -1359,6 +1546,9 @@ var ChaosTransport = class {
1359
1546
  }
1360
1547
  handleIncoming(message) {
1361
1548
  if (!this.messageHandler) return;
1549
+ if (this.dropPredicate?.(message, "incoming")) {
1550
+ return;
1551
+ }
1362
1552
  if (this.random() < this.dropRate) {
1363
1553
  return;
1364
1554
  }
@@ -1612,6 +1802,95 @@ var AwarenessManager = class {
1612
1802
  }
1613
1803
  };
1614
1804
 
1805
+ // src/blob/blob-chunk-channel.ts
1806
+ import { generateUUIDv7 } from "@korajs/core";
1807
+ function encodeBlobChunkBytes(bytes) {
1808
+ let binary = "";
1809
+ for (let i = 0; i < bytes.length; i++) {
1810
+ binary += String.fromCharCode(bytes[i]);
1811
+ }
1812
+ return btoa(binary);
1813
+ }
1814
+ function decodeBlobChunkBytes(encoded) {
1815
+ const binary = atob(encoded);
1816
+ const out = new Uint8Array(binary.length);
1817
+ for (let i = 0; i < binary.length; i++) {
1818
+ out[i] = binary.charCodeAt(i);
1819
+ }
1820
+ return out;
1821
+ }
1822
+ var BlobChunkChannel = class {
1823
+ onSend;
1824
+ handlers = /* @__PURE__ */ new Set();
1825
+ constructor(options) {
1826
+ this.onSend = options?.onSend ?? null;
1827
+ }
1828
+ /**
1829
+ * Send a blob-chunk message to the peer. Response bytes are base64-encoded
1830
+ * onto the wire here; callers pass a `Uint8Array`.
1831
+ */
1832
+ send(message) {
1833
+ if (!this.onSend) {
1834
+ return;
1835
+ }
1836
+ if (message.type === "blob-chunk-request") {
1837
+ this.onSend({
1838
+ type: "blob-chunk-request",
1839
+ messageId: generateUUIDv7(),
1840
+ requestId: message.requestId,
1841
+ hash: message.hash
1842
+ });
1843
+ return;
1844
+ }
1845
+ if (message.type === "blob-chunk-push") {
1846
+ this.onSend({
1847
+ type: "blob-chunk-push",
1848
+ messageId: generateUUIDv7(),
1849
+ hash: message.hash,
1850
+ bytes: encodeBlobChunkBytes(message.bytes)
1851
+ });
1852
+ return;
1853
+ }
1854
+ this.onSend({
1855
+ type: "blob-chunk-response",
1856
+ messageId: generateUUIDv7(),
1857
+ requestId: message.requestId,
1858
+ bytes: message.bytes === null ? null : encodeBlobChunkBytes(message.bytes)
1859
+ });
1860
+ }
1861
+ /**
1862
+ * Register a handler for inbound blob-chunk messages. Multiple handlers are
1863
+ * allowed; each receives every delivered message.
1864
+ */
1865
+ onMessage(handler) {
1866
+ this.handlers.add(handler);
1867
+ return () => {
1868
+ this.handlers.delete(handler);
1869
+ };
1870
+ }
1871
+ /**
1872
+ * Deliver an inbound wire message to registered handlers, decoding response
1873
+ * bytes from base64 back to a `Uint8Array` first.
1874
+ */
1875
+ deliver(message) {
1876
+ if (this.handlers.size === 0) {
1877
+ return;
1878
+ }
1879
+ const decoded = message.type === "blob-chunk-request" ? { type: "blob-chunk-request", requestId: message.requestId, hash: message.hash } : message.type === "blob-chunk-push" ? {
1880
+ type: "blob-chunk-push",
1881
+ hash: message.hash,
1882
+ bytes: decodeBlobChunkBytes(message.bytes)
1883
+ } : {
1884
+ type: "blob-chunk-response",
1885
+ requestId: message.requestId,
1886
+ bytes: message.bytes === null ? null : decodeBlobChunkBytes(message.bytes)
1887
+ };
1888
+ for (const handler of this.handlers) {
1889
+ handler(decoded);
1890
+ }
1891
+ }
1892
+ };
1893
+
1615
1894
  // src/diagnostics/bandwidth-estimator.ts
1616
1895
  var BandwidthEstimator = class {
1617
1896
  maxSamples;
@@ -2055,7 +2334,7 @@ var SyncMetricsCollector = class {
2055
2334
  };
2056
2335
 
2057
2336
  // src/richtext/richtext-doc-channel.ts
2058
- import { generateUUIDv7 } from "@korajs/core";
2337
+ import { generateUUIDv7 as generateUUIDv72 } from "@korajs/core";
2059
2338
 
2060
2339
  // src/richtext/doc-channel-wire.ts
2061
2340
  function encodeYjsUpdate(update) {
@@ -2134,7 +2413,7 @@ var RichtextDocChannel = class {
2134
2413
  }
2135
2414
  this.onSend({
2136
2415
  type: "yjs-doc-update",
2137
- messageId: generateUUIDv7(),
2416
+ messageId: generateUUIDv72(),
2138
2417
  collection,
2139
2418
  recordId,
2140
2419
  field,
@@ -2222,6 +2501,32 @@ var OutboundQueue = class {
2222
2501
  const ids = ops.map((op) => op.id);
2223
2502
  await this.storage.dequeue(ids);
2224
2503
  }
2504
+ /**
2505
+ * Acknowledge only operations in an in-flight batch whose local sequence number is
2506
+ * covered by the server's ack. Any later operations return to the front of the
2507
+ * queue for retry. This is required when the server accepts a prefix but rejects a
2508
+ * later operation retriably (for example a stale-scope push after auth changed).
2509
+ */
2510
+ async acknowledgeThrough(batchId, lastSequenceNumber) {
2511
+ const ops = this.inFlight.get(batchId);
2512
+ if (!ops) return;
2513
+ this.inFlight.delete(batchId);
2514
+ const acknowledged = ops.filter((op) => op.sequenceNumber <= lastSequenceNumber);
2515
+ const retry = ops.filter((op) => op.sequenceNumber > lastSequenceNumber);
2516
+ if (retry.length > 0) {
2517
+ this.queue.unshift(...retry);
2518
+ if (this.queue.length > 1) {
2519
+ this.queue = topologicalSort(this.queue);
2520
+ }
2521
+ }
2522
+ if (acknowledged.length > 0) {
2523
+ const ids = acknowledged.map((op) => op.id);
2524
+ for (const id of ids) {
2525
+ this.seen.delete(id);
2526
+ }
2527
+ await this.storage.dequeue(ids);
2528
+ }
2529
+ }
2225
2530
  /**
2226
2531
  * Return a failed batch to the front of the queue for retry.
2227
2532
  * Prepends the operations to maintain priority.
@@ -2304,6 +2609,37 @@ var OutboundQueue = class {
2304
2609
  get isInitialized() {
2305
2610
  return this.initialized;
2306
2611
  }
2612
+ /**
2613
+ * Remove a single operation the server permanently rejected — from the queue,
2614
+ * from any in-flight batch, and from durable storage — so a later batch ack or
2615
+ * a reconnect `returnBatch` can never resend or resurrect it. Returns the
2616
+ * removed operation so the caller can record it in a durable rejected store,
2617
+ * or null if it was not present (already acknowledged and removed).
2618
+ */
2619
+ async reject(opId) {
2620
+ let removed = null;
2621
+ for (const [batchId, ops] of this.inFlight) {
2622
+ const idx = ops.findIndex((op) => op.id === opId);
2623
+ if (idx !== -1) {
2624
+ removed = ops[idx] ?? null;
2625
+ ops.splice(idx, 1);
2626
+ if (ops.length === 0) {
2627
+ this.inFlight.delete(batchId);
2628
+ }
2629
+ break;
2630
+ }
2631
+ }
2632
+ if (!removed) {
2633
+ const idx = this.queue.findIndex((op) => op.id === opId);
2634
+ if (idx !== -1) {
2635
+ removed = this.queue[idx] ?? null;
2636
+ this.queue.splice(idx, 1);
2637
+ }
2638
+ }
2639
+ this.seen.delete(opId);
2640
+ await this.storage.dequeue([opId]);
2641
+ return removed;
2642
+ }
2307
2643
  /**
2308
2644
  * Remove operations by id from queue and persistent storage.
2309
2645
  * Used when ops were already sent during handshake delta exchange.
@@ -2322,6 +2658,7 @@ var OutboundQueue = class {
2322
2658
  // src/engine/sync-engine.ts
2323
2659
  var DEFAULT_BATCH_SIZE = 100;
2324
2660
  var DEFAULT_SCHEMA_VERSION = 1;
2661
+ var DEFAULT_OUTBOUND_ACK_TIMEOUT_MS = 3e4;
2325
2662
  var VALID_TRANSITIONS = {
2326
2663
  disconnected: ["connecting"],
2327
2664
  connecting: ["handshaking", "error", "disconnected"],
@@ -2335,6 +2672,17 @@ var nextQuerySubsetId = 0;
2335
2672
  function generateMessageId() {
2336
2673
  return `msg-${Date.now()}-${nextMessageId++}`;
2337
2674
  }
2675
+ var MAX_DELIVERY_VIEW_WATERMARKS = 64;
2676
+ function stableStringify(value) {
2677
+ if (value === null || typeof value !== "object") {
2678
+ return JSON.stringify(value) ?? "null";
2679
+ }
2680
+ if (Array.isArray(value)) {
2681
+ return `[${value.map(stableStringify).join(",")}]`;
2682
+ }
2683
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
2684
+ return `{${entries.join(",")}}`;
2685
+ }
2338
2686
  var SyncEngine = class {
2339
2687
  state = "disconnected";
2340
2688
  transport;
@@ -2343,10 +2691,13 @@ var SyncEngine = class {
2343
2691
  serializer;
2344
2692
  emitter;
2345
2693
  outboundQueue;
2694
+ rejectedStorage;
2346
2695
  batchSize;
2696
+ outboundAckTimeoutMs;
2347
2697
  encryptor;
2348
2698
  awarenessManager;
2349
2699
  richtextDocChannel;
2700
+ blobChunkChannel;
2350
2701
  metricsCollector;
2351
2702
  syncState;
2352
2703
  remoteVector = /* @__PURE__ */ new Map();
@@ -2357,10 +2708,16 @@ var SyncEngine = class {
2357
2708
  lastSuccessfulPull = null;
2358
2709
  conflictCount = 0;
2359
2710
  currentBatch = null;
2711
+ currentBatchRequiresPartialAck = false;
2360
2712
  reconnecting = false;
2361
2713
  schemaBlocked = false;
2362
2714
  clockBlocked = false;
2363
2715
  clockSkewMs = null;
2716
+ blobStorageEnabled = false;
2717
+ outboundAckTimer = null;
2718
+ startPromise = null;
2719
+ stopPromise = null;
2720
+ reconnectPromise = null;
2364
2721
  // Track delta exchange state
2365
2722
  deltaBatchesReceived = 0;
2366
2723
  deltaReceiveComplete = false;
@@ -2381,6 +2738,27 @@ var SyncEngine = class {
2381
2738
  /** Resume cursor for paginated initial sync (persisted across reconnects) */
2382
2739
  resumeDeltaCursor = null;
2383
2740
  initialSyncTotalBatches = 0;
2741
+ /**
2742
+ * The delivery watermark for the CURRENT sync view: the highest server delivery
2743
+ * sequence up to which every operation in the current view (scope + active query
2744
+ * subscriptions) has been contiguously applied. Advanced only through the server's
2745
+ * gap-free delivery stream (never through live relay), so it is a durable lower bound
2746
+ * on what this client holds for this view. Reported at handshake to resume the stream.
2747
+ *
2748
+ * The watermark is keyed by view: switching views (a scope or subscription change)
2749
+ * saves the current view's watermark and loads the target view's, so returning to a
2750
+ * previously-synced view resumes exactly where it left off instead of re-syncing. This
2751
+ * is correct because a view's watermark is a lower bound specific to that view's
2752
+ * filter; a widened view is simply a different (initially unsynced) view.
2753
+ */
2754
+ deliveryWatermark = 0;
2755
+ /**
2756
+ * In-memory cache of watermark per view signature, preloaded from persistence at start
2757
+ * so a view switch resolves synchronously (no race with the reconnect it triggers).
2758
+ */
2759
+ deliverySignatureWatermarks = /* @__PURE__ */ new Map();
2760
+ deliveryGapRepeatCount = 0;
2761
+ lastDeliveryGapKey = null;
2384
2762
  constructor(options) {
2385
2763
  this.transport = options.transport;
2386
2764
  this.store = options.store;
@@ -2388,11 +2766,13 @@ var SyncEngine = class {
2388
2766
  this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
2389
2767
  this.emitter = options.emitter ?? null;
2390
2768
  this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
2769
+ this.outboundAckTimeoutMs = options.config.outboundAckTimeoutMs ?? DEFAULT_OUTBOUND_ACK_TIMEOUT_MS;
2391
2770
  this.encryptor = options.encryptor ?? null;
2392
2771
  this.syncState = options.syncState ?? null;
2393
2772
  this.activeScope = options.config.scopeMap;
2394
2773
  const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
2395
2774
  this.outboundQueue = new OutboundQueue(queueStorage);
2775
+ this.rejectedStorage = options.rejectedStorage ?? new MemoryRejectedOperationStorage();
2396
2776
  this.metricsCollector = new SyncMetricsCollector(options.metricsConfig);
2397
2777
  if (this.emitter) {
2398
2778
  this.metricsCollector.attachEmitter(this.emitter);
@@ -2409,6 +2789,14 @@ var SyncEngine = class {
2409
2789
  this.transport.send(message);
2410
2790
  }
2411
2791
  });
2792
+ this.blobChunkChannel = new BlobChunkChannel({
2793
+ onSend: (message) => {
2794
+ if (this.state !== "streaming") {
2795
+ return;
2796
+ }
2797
+ this.transport.send(message);
2798
+ }
2799
+ });
2412
2800
  this.awarenessManager.onSend((message) => {
2413
2801
  if (this.state !== "streaming") return;
2414
2802
  const wireMessage = {
@@ -2424,10 +2812,20 @@ var SyncEngine = class {
2424
2812
  * Start the sync engine: connect → handshake → delta exchange → streaming.
2425
2813
  */
2426
2814
  async start() {
2815
+ if (this.state === "streaming") return;
2816
+ if (this.startPromise) return this.startPromise;
2817
+ this.startPromise = this.startInternal().finally(() => {
2818
+ this.startPromise = null;
2819
+ });
2820
+ return this.startPromise;
2821
+ }
2822
+ async startInternal() {
2823
+ if (this.stopPromise) {
2824
+ await this.stopPromise;
2825
+ }
2826
+ if (this.state === "streaming") return;
2427
2827
  if (this.state !== "disconnected") {
2428
- throw new SyncError4("Cannot start sync engine: not in disconnected state", {
2429
- currentState: this.state
2430
- });
2828
+ await this.stop();
2431
2829
  }
2432
2830
  await this.outboundQueue.initialize();
2433
2831
  if (this.syncState) {
@@ -2435,6 +2833,16 @@ var SyncEngine = class {
2435
2833
  if (this.syncState.loadDeltaCursor) {
2436
2834
  this.resumeDeltaCursor = await this.syncState.loadDeltaCursor();
2437
2835
  }
2836
+ if (this.syncState.loadAllDeliveryWatermarks) {
2837
+ const all = await this.syncState.loadAllDeliveryWatermarks();
2838
+ for (const [signature, watermark] of Object.entries(all)) {
2839
+ this.deliverySignatureWatermarks.set(signature, watermark);
2840
+ }
2841
+ } else if (this.syncState.loadDeliveryWatermark) {
2842
+ this.deliverySignatureWatermarks.set("", await this.syncState.loadDeliveryWatermark(""));
2843
+ }
2844
+ this.deliveryWatermark = this.deliverySignatureWatermarks.get(this.deliverySignature()) ?? 0;
2845
+ this.evictColdViewWatermarks();
2438
2846
  }
2439
2847
  await this.reconcileOutboundFromOpLog();
2440
2848
  await this.refreshPendingCount();
@@ -2450,7 +2858,12 @@ var SyncEngine = class {
2450
2858
  this.transitionTo("connecting");
2451
2859
  try {
2452
2860
  const authToken = this.config.auth ? (await this.config.auth()).token : void 0;
2861
+ if (this.state !== "connecting") return;
2453
2862
  await this.transport.connect(this.config.url, { authToken });
2863
+ if (this.state !== "connecting") {
2864
+ await this.transport.disconnect();
2865
+ return;
2866
+ }
2454
2867
  this.transitionTo("handshaking");
2455
2868
  const localVector = this.store.getVersionVector();
2456
2869
  const activeQuerySubsets = this.getActiveQuerySubsets();
@@ -2464,7 +2877,11 @@ var SyncEngine = class {
2464
2877
  supportedWireFormats: ["json", "protobuf"],
2465
2878
  ...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
2466
2879
  ...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
2467
- ...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {}
2880
+ ...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {},
2881
+ // Resume the server's gap-free delivery stream from our watermark. A server
2882
+ // that understands it drives server->client sync from delivery sequences; an
2883
+ // older server ignores it and falls back to the version-vector delta.
2884
+ lastDeliverySequence: this.deliveryWatermark
2468
2885
  };
2469
2886
  this.transport.send(handshake);
2470
2887
  } catch (err) {
@@ -2476,11 +2893,20 @@ var SyncEngine = class {
2476
2893
  * Stop the sync engine. Disconnects the transport.
2477
2894
  */
2478
2895
  async stop() {
2896
+ if (this.stopPromise) return this.stopPromise;
2897
+ this.stopPromise = this.stopInternal().finally(() => {
2898
+ this.stopPromise = null;
2899
+ });
2900
+ return this.stopPromise;
2901
+ }
2902
+ async stopInternal() {
2479
2903
  if (this.state === "disconnected") return;
2904
+ this.clearOutboundAckTimer();
2480
2905
  this.awarenessManager.stopCleanupTimer();
2481
2906
  if (this.currentBatch) {
2482
2907
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
2483
2908
  this.currentBatch = null;
2909
+ this.currentBatchRequiresPartialAck = false;
2484
2910
  }
2485
2911
  try {
2486
2912
  await this.transport.disconnect();
@@ -2501,7 +2927,7 @@ var SyncEngine = class {
2501
2927
  * because they should remain local-only and not be sent to the server.
2502
2928
  */
2503
2929
  async pushOperation(op) {
2504
- if (!this.operationAllowedForSync(op)) {
2930
+ if (!await this.operationAllowedForSync(op)) {
2505
2931
  return;
2506
2932
  }
2507
2933
  if (this.syncState) {
@@ -2530,6 +2956,7 @@ var SyncEngine = class {
2530
2956
  getStatus() {
2531
2957
  const pendingOperations = this.syncState ? this.cachedUnsyncedCount : this.outboundQueue.totalPending;
2532
2958
  const base = {
2959
+ reconnecting: this.reconnecting,
2533
2960
  pendingOperations,
2534
2961
  lastSyncedAt: this.lastSyncedAt,
2535
2962
  lastSuccessfulPush: this.lastSuccessfulPush,
@@ -2549,7 +2976,7 @@ var SyncEngine = class {
2549
2976
  case "connecting":
2550
2977
  case "handshaking":
2551
2978
  case "syncing":
2552
- return { ...base, status: this.reconnecting ? "offline" : "syncing" };
2979
+ return { ...base, status: this.reconnecting ? "reconnecting" : "syncing" };
2553
2980
  case "streaming":
2554
2981
  return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
2555
2982
  case "error":
@@ -2634,6 +3061,24 @@ var SyncEngine = class {
2634
3061
  await this.start();
2635
3062
  }
2636
3063
  }
3064
+ /**
3065
+ * Atomically refresh the transport session. Overlapping callers share one
3066
+ * reconnect, in-flight outbound batches are returned to the queue by stop(), and the
3067
+ * next start handshakes with the latest auth token, sync scope, and query subsets.
3068
+ */
3069
+ async reconnect() {
3070
+ if (this.schemaBlocked) return;
3071
+ if (this.reconnectPromise) return this.reconnectPromise;
3072
+ this.reconnecting = false;
3073
+ this.reconnectPromise = this.reconnectInternal().finally(() => {
3074
+ this.reconnectPromise = null;
3075
+ });
3076
+ return this.reconnectPromise;
3077
+ }
3078
+ async reconnectInternal() {
3079
+ await this.stop();
3080
+ await this.start();
3081
+ }
2637
3082
  /**
2638
3083
  * Export a diagnostics snapshot for debugging and support tickets.
2639
3084
  * Contains connection state, timing info, and queue metrics.
@@ -2652,6 +3097,8 @@ var SyncEngine = class {
2652
3097
  pendingOperations: this.outboundQueue.totalPending,
2653
3098
  hasInFlightBatch: this.currentBatch !== null,
2654
3099
  reconnecting: this.reconnecting,
3100
+ deliveryWatermark: this.deliveryWatermark,
3101
+ deliveryGapRepeatCount: this.deliveryGapRepeatCount,
2655
3102
  timestamp: Date.now()
2656
3103
  };
2657
3104
  }
@@ -2680,8 +3127,84 @@ var SyncEngine = class {
2680
3127
  * @param scopeMap - New per-collection scope filters, or undefined to remove scope
2681
3128
  */
2682
3129
  updateScope(scopeMap) {
3130
+ const previousSignature = this.deliverySignature();
2683
3131
  this.activeScope = scopeMap;
2684
3132
  this.config.scopeMap = scopeMap;
3133
+ this.switchDeliveryView(previousSignature);
3134
+ }
3135
+ /**
3136
+ * A stable identifier for the current sync view: the auth/tenant scope plus the set of
3137
+ * active query subscriptions, canonicalized so the same view always produces the same
3138
+ * string. The delivery watermark is keyed by this, so each distinct view resumes from
3139
+ * its own last position.
3140
+ */
3141
+ deliverySignature() {
3142
+ const subsets = this.getActiveQuerySubsets();
3143
+ if (!this.activeScope && subsets.length === 0) {
3144
+ return "";
3145
+ }
3146
+ const normalizedSubsets = subsets.map((s) => `${s.collection}:${stableStringify(s.where)}`).sort();
3147
+ return stableStringify({ scope: this.activeScope ?? null, subsets: normalizedSubsets });
3148
+ }
3149
+ /**
3150
+ * Switch the active delivery watermark to the current view after a scope or subscription
3151
+ * change. The prior view's watermark is saved so returning to it resumes; the new view's
3152
+ * watermark is restored from the in-memory cache (0 for a never-synced view, which then
3153
+ * does a one-time resync under that view). No data is lost: a view's watermark is a lower
3154
+ * bound for that specific view's filter.
3155
+ */
3156
+ switchDeliveryView(previousSignature) {
3157
+ const nextSignature = this.deliverySignature();
3158
+ if (nextSignature === previousSignature) {
3159
+ return;
3160
+ }
3161
+ this.setViewWatermark(previousSignature, this.deliveryWatermark);
3162
+ void this.persistDeliveryWatermark(this.deliveryWatermark, previousSignature);
3163
+ this.deliveryWatermark = this.deliverySignatureWatermarks.get(nextSignature) ?? 0;
3164
+ }
3165
+ /**
3166
+ * Record a view's watermark in the in-memory cache as the most-recently-used entry (the
3167
+ * Map preserves insertion order, so re-inserting moves it to the tail), then evict cold
3168
+ * views past the retention cap. This is the single place the cache is written, so LRU
3169
+ * order and the size bound are always maintained together.
3170
+ */
3171
+ setViewWatermark(signature, watermark) {
3172
+ this.deliverySignatureWatermarks.delete(signature);
3173
+ this.deliverySignatureWatermarks.set(signature, watermark);
3174
+ this.evictColdViewWatermarks();
3175
+ }
3176
+ /**
3177
+ * Trim the per-view watermark cache to `MAX_DELIVERY_VIEW_WATERMARKS`, evicting the
3178
+ * least-recently-used views. The default view ('') and the live view are never evicted
3179
+ * (evicting the live view would force an immediate re-scan of what we are actively
3180
+ * syncing). Each eviction also removes the persisted row, bounding storage. Safe by
3181
+ * construction: an evicted view simply back-fills from 0 (deduplicated) when next seen.
3182
+ */
3183
+ evictColdViewWatermarks() {
3184
+ if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
3185
+ return;
3186
+ }
3187
+ const liveSignature = this.deliverySignature();
3188
+ for (const signature of this.deliverySignatureWatermarks.keys()) {
3189
+ if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
3190
+ break;
3191
+ }
3192
+ if (signature === "" || signature === liveSignature) {
3193
+ continue;
3194
+ }
3195
+ this.deliverySignatureWatermarks.delete(signature);
3196
+ void this.deleteDeliveryWatermark(signature);
3197
+ }
3198
+ }
3199
+ /**
3200
+ * Remove a view's persisted watermark row (best-effort; a persistence layer that omits
3201
+ * the delete simply keeps the row, which is harmless).
3202
+ */
3203
+ async deleteDeliveryWatermark(signature) {
3204
+ if (!this.syncState?.deleteDeliveryWatermark) {
3205
+ return;
3206
+ }
3207
+ await this.syncState.deleteDeliveryWatermark(signature);
2685
3208
  }
2686
3209
  /**
2687
3210
  * Get the currently active scope map. Returns undefined if no scope is configured.
@@ -2695,10 +3218,14 @@ var SyncEngine = class {
2695
3218
  */
2696
3219
  registerQuerySubset(subset) {
2697
3220
  const id = `query-${nextQuerySubsetId++}`;
3221
+ const previousSignature = this.deliverySignature();
2698
3222
  this.querySubsets.set(id, subset);
3223
+ this.switchDeliveryView(previousSignature);
2699
3224
  this.scheduleQuerySubsetReconnect();
2700
3225
  return () => {
3226
+ const sigBeforeRemove = this.deliverySignature();
2701
3227
  this.querySubsets.delete(id);
3228
+ this.switchDeliveryView(sigBeforeRemove);
2702
3229
  this.scheduleQuerySubsetReconnect();
2703
3230
  };
2704
3231
  }
@@ -2722,6 +3249,31 @@ var SyncEngine = class {
2722
3249
  getRichtextDocChannel() {
2723
3250
  return this.richtextDocChannel;
2724
3251
  }
3252
+ /**
3253
+ * Side channel for out-of-band blob chunk transfer over the sync connection.
3254
+ * The app layer bridges this to a `ChunkMessagePort` to pull/serve blob bytes.
3255
+ */
3256
+ getBlobChunkChannel() {
3257
+ return this.blobChunkChannel;
3258
+ }
3259
+ /**
3260
+ * Whether the connected server persists blob bytes centrally (learned from the
3261
+ * handshake response). When true, the app uploads the bytes behind `blob`
3262
+ * fields so they stay available after the authoring device goes offline.
3263
+ */
3264
+ isBlobStorageEnabled() {
3265
+ return this.blobStorageEnabled;
3266
+ }
3267
+ /**
3268
+ * Upload a blob chunk (or manifest) to the server for central persistence.
3269
+ * A no-op unless connected and the server advertised blob storage.
3270
+ */
3271
+ uploadBlobChunk(hash, bytes) {
3272
+ if (this.state !== "streaming" || !this.blobStorageEnabled) {
3273
+ return;
3274
+ }
3275
+ this.blobChunkChannel.send({ type: "blob-chunk-push", hash, bytes });
3276
+ }
2725
3277
  // --- Private methods ---
2726
3278
  messageChain = Promise.resolve();
2727
3279
  enqueueMessage(message) {
@@ -2736,17 +3288,25 @@ var SyncEngine = class {
2736
3288
  await this.handleOperationBatch(message);
2737
3289
  break;
2738
3290
  case "acknowledgment":
2739
- this.handleAcknowledgment(message);
3291
+ await this.handleAcknowledgment(message);
2740
3292
  break;
2741
3293
  case "error":
2742
3294
  this.handleError(message);
2743
3295
  break;
3296
+ case "operation-rejected":
3297
+ await this.handleOperationRejected(message);
3298
+ break;
2744
3299
  case "awareness-update":
2745
3300
  this.handleAwarenessUpdate(message);
2746
3301
  break;
2747
3302
  case "yjs-doc-update":
2748
3303
  this.richtextDocChannel.deliver(message);
2749
3304
  break;
3305
+ case "blob-chunk-request":
3306
+ case "blob-chunk-response":
3307
+ case "blob-chunk-push":
3308
+ this.blobChunkChannel.deliver(message);
3309
+ break;
2750
3310
  }
2751
3311
  }
2752
3312
  handleMessageFailure(error) {
@@ -2779,6 +3339,7 @@ var SyncEngine = class {
2779
3339
  }
2780
3340
  async handleHandshakeResponse(msg) {
2781
3341
  if (this.state !== "handshaking") return;
3342
+ this.blobStorageEnabled = msg.blobStorageEnabled === true;
2782
3343
  if (typeof msg.serverTime === "number") {
2783
3344
  this.evaluateClockSkew(msg.serverTime);
2784
3345
  if (this.clockBlocked) {
@@ -2817,6 +3378,11 @@ var SyncEngine = class {
2817
3378
  }
2818
3379
  this.remoteVector = wireToVersionVector(msg.versionVector);
2819
3380
  void this.persistLastAckedServerVector(this.remoteVector);
3381
+ if (typeof msg.serverMaxDeliverySequence === "number" && this.deliveryWatermark > msg.serverMaxDeliverySequence) {
3382
+ this.deliveryWatermark = 0;
3383
+ this.setViewWatermark(this.deliverySignature(), 0);
3384
+ await this.persistDeliveryWatermark(0);
3385
+ }
2820
3386
  if (msg.selectedWireFormat) {
2821
3387
  this.setSerializerWireFormat(msg.selectedWireFormat);
2822
3388
  }
@@ -2882,7 +3448,7 @@ var SyncEngine = class {
2882
3448
  async sendDelta() {
2883
3449
  const localVector = this.store.getVersionVector();
2884
3450
  const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
2885
- const missingOps = allMissingOps.filter((op) => this.operationAllowedForSync(op));
3451
+ const missingOps = await this.filterAllowedForSync(allMissingOps);
2886
3452
  this.deltaSentOpIds = missingOps.map((op) => op.id);
2887
3453
  if (missingOps.length === 0) {
2888
3454
  const messageId = generateMessageId();
@@ -2957,11 +3523,34 @@ var SyncEngine = class {
2957
3523
  return missing;
2958
3524
  }
2959
3525
  async handleOperationBatch(msg) {
3526
+ const isDeliveryBatch = msg.maxDeliverySequence !== void 0;
3527
+ if (isDeliveryBatch) {
3528
+ const base = msg.baseDeliverySequence ?? 0;
3529
+ if (base > this.deliveryWatermark) {
3530
+ const gapKey = `${this.deliveryWatermark}:${base}`;
3531
+ this.deliveryGapRepeatCount = this.lastDeliveryGapKey === gapKey ? this.deliveryGapRepeatCount + 1 : 1;
3532
+ this.lastDeliveryGapKey = gapKey;
3533
+ this.emitter?.emit({
3534
+ type: "sync:delivery-gap",
3535
+ expectedBase: this.deliveryWatermark,
3536
+ receivedBase: base,
3537
+ currentWatermark: this.deliveryWatermark,
3538
+ messageId: msg.messageId,
3539
+ repeatCount: this.deliveryGapRepeatCount
3540
+ });
3541
+ return;
3542
+ }
3543
+ if (base < this.deliveryWatermark) {
3544
+ this.sendDeliveryAck(msg.messageId, this.deliveryWatermark);
3545
+ return;
3546
+ }
3547
+ }
2960
3548
  const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
2961
3549
  const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
2962
- const inScopeOps = operations.filter((op) => this.operationAllowedForSync(op));
3550
+ const inScopeOps = await this.filterAllowedForSync(operations);
2963
3551
  const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
2964
3552
  const transforms = this.config.operationTransforms ?? [];
3553
+ let fullyApplied = true;
2965
3554
  for (const op of inScopeOps) {
2966
3555
  const transformed = transforms.length > 0 ? applyOperationTransforms(op, targetSchemaVersion, transforms) : op;
2967
3556
  if (transformed === null) {
@@ -2973,6 +3562,7 @@ var SyncEngine = class {
2973
3562
  this.emitApplyFailure(transformed, result);
2974
3563
  }
2975
3564
  } catch (error) {
3565
+ fullyApplied = false;
2976
3566
  if (error instanceof ClockDriftError) {
2977
3567
  this.emitApplyFailure(transformed, "rejected", {
2978
3568
  code: APPLY_FAILURE_CODES.CLOCK_DRIFT,
@@ -2983,11 +3573,8 @@ var SyncEngine = class {
2983
3573
  }
2984
3574
  const message = error instanceof Error ? error.message : "Apply failed";
2985
3575
  const code = error instanceof SyncError4 ? error.code : error instanceof KoraError2 ? error.code : error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : APPLY_FAILURE_CODES.APPLY_FAILED;
2986
- this.emitApplyFailure(transformed, "rejected", {
2987
- code,
2988
- message,
2989
- retriable: code !== APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY
2990
- });
3576
+ const retriable = code !== APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY;
3577
+ this.emitApplyFailure(transformed, "rejected", { code, message, retriable });
2991
3578
  }
2992
3579
  }
2993
3580
  if (inScopeOps.length > 0) {
@@ -2998,18 +3585,29 @@ var SyncEngine = class {
2998
3585
  batchSize: inScopeOps.length
2999
3586
  });
3000
3587
  }
3001
- const lastOp = operations[operations.length - 1];
3002
- const ack = {
3003
- type: "acknowledgment",
3004
- messageId: generateMessageId(),
3005
- acknowledgedMessageId: msg.messageId,
3006
- lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3007
- };
3008
- this.transport.send(ack);
3009
- this.emitter?.emit({
3010
- type: "sync:acknowledged",
3011
- sequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3012
- });
3588
+ const deliveryFullyApplied = isDeliveryBatch && fullyApplied;
3589
+ if (!isDeliveryBatch || deliveryFullyApplied) {
3590
+ const lastOp = operations[operations.length - 1];
3591
+ const ack = {
3592
+ type: "acknowledgment",
3593
+ messageId: generateMessageId(),
3594
+ acknowledgedMessageId: msg.messageId,
3595
+ lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0,
3596
+ ...deliveryFullyApplied && msg.maxDeliverySequence !== void 0 ? { deliverySequence: msg.maxDeliverySequence } : {}
3597
+ };
3598
+ this.transport.send(ack);
3599
+ this.emitter?.emit({
3600
+ type: "sync:acknowledged",
3601
+ sequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3602
+ });
3603
+ }
3604
+ if (isDeliveryBatch && fullyApplied && msg.maxDeliverySequence !== void 0) {
3605
+ this.deliveryGapRepeatCount = 0;
3606
+ this.lastDeliveryGapKey = null;
3607
+ this.deliveryWatermark = msg.maxDeliverySequence;
3608
+ this.setViewWatermark(this.deliverySignature(), this.deliveryWatermark);
3609
+ await this.persistDeliveryWatermark(this.deliveryWatermark);
3610
+ }
3013
3611
  if (this.state === "syncing") {
3014
3612
  this.deltaBatchesReceived++;
3015
3613
  const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
@@ -3017,15 +3615,17 @@ var SyncEngine = class {
3017
3615
  this.initialSyncTotalBatches = msg.totalBatches;
3018
3616
  }
3019
3617
  this.metricsCollector.updateInitialSyncProgress(this.deltaBatchesReceived, totalBatches);
3020
- const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
3021
- inScopeOps.length > 0 ? inScopeOps : operations,
3022
- msg.batchIndex
3023
- );
3024
- if (cursorFromBatch) {
3025
- this.resumeDeltaCursor = cursorFromBatch;
3026
- await this.persistDeltaCursor(cursorFromBatch);
3618
+ if (!isDeliveryBatch) {
3619
+ const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
3620
+ inScopeOps.length > 0 ? inScopeOps : operations,
3621
+ msg.batchIndex
3622
+ );
3623
+ if (cursorFromBatch) {
3624
+ this.resumeDeltaCursor = cursorFromBatch;
3625
+ await this.persistDeltaCursor(cursorFromBatch);
3626
+ }
3027
3627
  }
3028
- if (msg.isFinal) {
3628
+ if (msg.isFinal && (!isDeliveryBatch || fullyApplied)) {
3029
3629
  this.deltaReceiveComplete = true;
3030
3630
  this.resumeDeltaCursor = null;
3031
3631
  await this.persistDeltaCursor(null);
@@ -3034,26 +3634,51 @@ var SyncEngine = class {
3034
3634
  }
3035
3635
  }
3036
3636
  }
3037
- handleAcknowledgment(msg) {
3637
+ /** Acknowledge a delivery batch (used for duplicates) without re-applying it. */
3638
+ sendDeliveryAck(acknowledgedMessageId, deliverySequence) {
3639
+ const ack = {
3640
+ type: "acknowledgment",
3641
+ messageId: generateMessageId(),
3642
+ acknowledgedMessageId,
3643
+ lastSequenceNumber: 0,
3644
+ deliverySequence
3645
+ };
3646
+ this.transport.send(ack);
3647
+ }
3648
+ async handleAcknowledgment(msg) {
3649
+ this.clearOutboundAckTimer();
3038
3650
  if (this.state === "syncing" && this.config.strictHandshake) {
3039
3651
  this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
3040
3652
  this.markDeltaSendCompleteIfReady();
3041
3653
  }
3042
3654
  if (this.currentBatch) {
3043
- this.outboundQueue.acknowledge(this.currentBatch.batchId);
3655
+ if (this.currentBatchRequiresPartialAck) {
3656
+ await this.outboundQueue.acknowledgeThrough(
3657
+ this.currentBatch.batchId,
3658
+ msg.lastSequenceNumber
3659
+ );
3660
+ } else {
3661
+ await this.outboundQueue.acknowledge(this.currentBatch.batchId);
3662
+ }
3044
3663
  this.currentBatch = null;
3664
+ this.currentBatchRequiresPartialAck = false;
3045
3665
  const now = Date.now();
3046
3666
  this.lastSyncedAt = now;
3047
3667
  this.lastSuccessfulPush = now;
3048
3668
  }
3049
- void this.advanceLastAckedForLocalNode(msg.lastSequenceNumber).then(
3050
- () => this.refreshPendingCount()
3051
- );
3669
+ await this.advanceLastAckedForLocalNode(msg.lastSequenceNumber);
3670
+ await this.refreshPendingCount();
3052
3671
  if (this.state === "streaming" && this.outboundQueue.hasOperations) {
3053
3672
  this.flushQueue();
3054
3673
  }
3055
3674
  }
3056
3675
  handleError(msg) {
3676
+ this.clearOutboundAckTimer();
3677
+ if (this.currentBatch) {
3678
+ this.outboundQueue.returnBatch(this.currentBatch.batchId);
3679
+ this.currentBatch = null;
3680
+ this.currentBatchRequiresPartialAck = false;
3681
+ }
3057
3682
  this.transitionTo("error");
3058
3683
  if (msg.code === "AUTH_FAILED") {
3059
3684
  this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
@@ -3072,6 +3697,64 @@ var SyncEngine = class {
3072
3697
  this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
3073
3698
  this.transitionTo("disconnected");
3074
3699
  }
3700
+ /**
3701
+ * Handle a per-operation rejection: divert the op out of the pending outbound
3702
+ * queue (so it is never retried or resurrected on reconnect), record it in the
3703
+ * durable rejected store (so it is kept and explainable), and emit an event so
3704
+ * the app can reconcile. Unlike {@link handleError}, this is a normal per-op
3705
+ * signal, so the connection stays up.
3706
+ */
3707
+ async handleOperationRejected(msg) {
3708
+ if (msg.retriable) {
3709
+ this.currentBatchRequiresPartialAck = true;
3710
+ await this.refreshPendingCount();
3711
+ this.emitter?.emit({
3712
+ type: "sync:operation-rejected",
3713
+ operationId: msg.operationId,
3714
+ collection: msg.collection,
3715
+ recordId: msg.recordId,
3716
+ code: msg.code,
3717
+ message: msg.message,
3718
+ retriable: true
3719
+ });
3720
+ return;
3721
+ }
3722
+ await this.outboundQueue.reject(msg.operationId);
3723
+ await this.rejectedStorage.record({
3724
+ operationId: msg.operationId,
3725
+ collection: msg.collection,
3726
+ recordId: msg.recordId,
3727
+ code: msg.code,
3728
+ message: msg.message,
3729
+ retriable: msg.retriable,
3730
+ rejectedAt: Date.now()
3731
+ });
3732
+ await this.refreshPendingCount();
3733
+ this.emitter?.emit({
3734
+ type: "sync:operation-rejected",
3735
+ operationId: msg.operationId,
3736
+ collection: msg.collection,
3737
+ recordId: msg.recordId,
3738
+ code: msg.code,
3739
+ message: msg.message,
3740
+ retriable: msg.retriable
3741
+ });
3742
+ }
3743
+ /**
3744
+ * Every operation the server rejected that has not yet been reconciled. The app
3745
+ * uses this (alongside the `sync:operation-rejected` event) to surface failed
3746
+ * submissions and decide whether to roll back or resubmit.
3747
+ */
3748
+ getRejectedOperations() {
3749
+ return this.rejectedStorage.list();
3750
+ }
3751
+ /**
3752
+ * Forget rejected operations by id once the app has reconciled them (rolled the
3753
+ * optimistic write back or resubmitted a corrected op).
3754
+ */
3755
+ clearRejectedOperations(operationIds) {
3756
+ return this.rejectedStorage.remove(operationIds);
3757
+ }
3075
3758
  async checkDeltaComplete() {
3076
3759
  if (!this.deltaSendComplete || !this.deltaReceiveComplete) {
3077
3760
  return;
@@ -3170,7 +3853,7 @@ var SyncEngine = class {
3170
3853
  return;
3171
3854
  }
3172
3855
  const ops = await this.store.getOperationRange(localNodeId, 1, localSeq);
3173
- const inScope = ops.filter((op) => this.operationAllowedForSync(op));
3856
+ const inScope = await this.filterAllowedForSync(ops);
3174
3857
  for (const op of inScope) {
3175
3858
  await this.outboundQueue.enqueue(op);
3176
3859
  }
@@ -3181,6 +3864,7 @@ var SyncEngine = class {
3181
3864
  const batch = this.outboundQueue.takeBatch(this.batchSize);
3182
3865
  if (!batch) return;
3183
3866
  this.currentBatch = batch;
3867
+ this.currentBatchRequiresPartialAck = false;
3184
3868
  if (this.encryptor) {
3185
3869
  this.encryptor.encryptBatch(batch.operations).then(
3186
3870
  (encrypted) => {
@@ -3193,6 +3877,7 @@ var SyncEngine = class {
3193
3877
  batchIndex: 0
3194
3878
  };
3195
3879
  this.transport.send(batchMsg);
3880
+ this.startOutboundAckTimer(batch.batchId);
3196
3881
  this.emitter?.emit({
3197
3882
  type: "sync:sent",
3198
3883
  operations: batch.operations,
@@ -3202,6 +3887,7 @@ var SyncEngine = class {
3202
3887
  (err) => {
3203
3888
  this.outboundQueue.returnBatch(batch.batchId);
3204
3889
  this.currentBatch = null;
3890
+ this.currentBatchRequiresPartialAck = false;
3205
3891
  this.emitter?.emit({
3206
3892
  type: "sync:disconnected",
3207
3893
  reason: err instanceof Error ? err.message : "Encryption failed"
@@ -3218,6 +3904,7 @@ var SyncEngine = class {
3218
3904
  batchIndex: 0
3219
3905
  };
3220
3906
  this.transport.send(batchMsg);
3907
+ this.startOutboundAckTimer(batch.batchId);
3221
3908
  this.emitter?.emit({
3222
3909
  type: "sync:sent",
3223
3910
  operations: batch.operations,
@@ -3225,6 +3912,34 @@ var SyncEngine = class {
3225
3912
  });
3226
3913
  }
3227
3914
  }
3915
+ startOutboundAckTimer(batchId) {
3916
+ this.clearOutboundAckTimer();
3917
+ if (this.outboundAckTimeoutMs <= 0) return;
3918
+ this.outboundAckTimer = setTimeout(() => {
3919
+ void this.handleOutboundAckTimeout(batchId);
3920
+ }, this.outboundAckTimeoutMs);
3921
+ }
3922
+ clearOutboundAckTimer() {
3923
+ if (!this.outboundAckTimer) return;
3924
+ clearTimeout(this.outboundAckTimer);
3925
+ this.outboundAckTimer = null;
3926
+ }
3927
+ async handleOutboundAckTimeout(batchId) {
3928
+ if (!this.currentBatch || this.currentBatch.batchId !== batchId) return;
3929
+ this.outboundQueue.returnBatch(batchId);
3930
+ this.currentBatch = null;
3931
+ this.currentBatchRequiresPartialAck = false;
3932
+ this.clearOutboundAckTimer();
3933
+ await this.refreshPendingCount();
3934
+ if (this.state === "disconnected") return;
3935
+ const reason = `Timed out waiting for acknowledgment of outbound batch ${batchId}`;
3936
+ try {
3937
+ await this.transport.disconnect();
3938
+ } finally {
3939
+ this.ensureDisconnected();
3940
+ this.emitter?.emit({ type: "sync:disconnected", reason });
3941
+ }
3942
+ }
3228
3943
  handleAwarenessUpdate(msg) {
3229
3944
  const awarenessMessage = {
3230
3945
  type: "awareness",
@@ -3234,9 +3949,11 @@ var SyncEngine = class {
3234
3949
  this.awarenessManager.handleRemoteMessage(awarenessMessage);
3235
3950
  }
3236
3951
  handleTransportClose(reason) {
3952
+ this.clearOutboundAckTimer();
3237
3953
  if (this.currentBatch) {
3238
3954
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
3239
3955
  this.currentBatch = null;
3956
+ this.currentBatchRequiresPartialAck = false;
3240
3957
  }
3241
3958
  if (this.schemaBlocked) {
3242
3959
  return;
@@ -3268,11 +3985,40 @@ var SyncEngine = class {
3268
3985
  this.serializer.setWireFormat(format);
3269
3986
  }
3270
3987
  }
3271
- operationAllowedForSync(op) {
3272
- if (!operationMatchesScope(op, this.activeScope)) {
3988
+ async operationAllowedForSync(op) {
3989
+ if (this.matchesScopeAndSubsets(op)) {
3990
+ return true;
3991
+ }
3992
+ const fullRecord = await this.readRecordForBackfill(op);
3993
+ if (!fullRecord) {
3273
3994
  return false;
3274
3995
  }
3275
- return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets());
3996
+ return this.matchesScopeAndSubsets(op, fullRecord);
3997
+ }
3998
+ matchesScopeAndSubsets(op, fullRecord) {
3999
+ if (!operationMatchesScope(op, this.activeScope, fullRecord)) {
4000
+ return false;
4001
+ }
4002
+ return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets(), fullRecord);
4003
+ }
4004
+ async readRecordForBackfill(op) {
4005
+ if (!this.store.readRecordFields) {
4006
+ return null;
4007
+ }
4008
+ try {
4009
+ return await this.store.readRecordFields(op.collection, op.recordId);
4010
+ } catch {
4011
+ return null;
4012
+ }
4013
+ }
4014
+ async filterAllowedForSync(ops) {
4015
+ const allowed = [];
4016
+ for (const op of ops) {
4017
+ if (await this.operationAllowedForSync(op)) {
4018
+ allowed.push(op);
4019
+ }
4020
+ }
4021
+ return allowed;
3276
4022
  }
3277
4023
  async persistDeltaCursor(cursor) {
3278
4024
  if (!this.syncState?.saveDeltaCursor) {
@@ -3280,6 +4026,12 @@ var SyncEngine = class {
3280
4026
  }
3281
4027
  await this.syncState.saveDeltaCursor(cursor);
3282
4028
  }
4029
+ async persistDeliveryWatermark(watermark, signature = this.deliverySignature()) {
4030
+ if (!this.syncState?.saveDeliveryWatermark) {
4031
+ return;
4032
+ }
4033
+ await this.syncState.saveDeliveryWatermark(signature, watermark);
4034
+ }
3283
4035
  scheduleQuerySubsetReconnect() {
3284
4036
  if (this.querySubsetReconnectTimer) {
3285
4037
  clearTimeout(this.querySubsetReconnectTimer);
@@ -3292,8 +4044,7 @@ var SyncEngine = class {
3292
4044
  }, 500);
3293
4045
  }
3294
4046
  async reconnectForQuerySubsets() {
3295
- await this.stop();
3296
- await this.start();
4047
+ await this.reconnect();
3297
4048
  }
3298
4049
  };
3299
4050
  function awarenessStatesToWire(states) {
@@ -3974,6 +4725,7 @@ function fromBase642(str) {
3974
4725
  // src/reactivity/sync-status-controller.ts
3975
4726
  var OFFLINE_SYNC_STATUS = Object.freeze({
3976
4727
  status: "offline",
4728
+ reconnecting: false,
3977
4729
  pendingOperations: 0,
3978
4730
  lastSyncedAt: null,
3979
4731
  lastSuccessfulPush: null,
@@ -3991,6 +4743,7 @@ var SYNC_STATUS_EVENT_TYPES = [
3991
4743
  "sync:received",
3992
4744
  "sync:acknowledged",
3993
4745
  "sync:apply-failed",
4746
+ "sync:delivery-gap",
3994
4747
  "sync:diagnostics",
3995
4748
  "sync:initial-sync-progress"
3996
4749
  ];
@@ -4126,6 +4879,7 @@ function subscribeRemoteAwarenessStates(awareness, listener) {
4126
4879
  }
4127
4880
  export {
4128
4881
  AwarenessManager,
4882
+ BlobChunkChannel,
4129
4883
  ChaosTransport,
4130
4884
  ConnectionMonitor,
4131
4885
  DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD,
@@ -4135,6 +4889,7 @@ export {
4135
4889
  InvalidScopeError,
4136
4890
  JsonMessageSerializer,
4137
4891
  KeyDerivationError,
4892
+ MemoryRejectedOperationStorage,
4138
4893
  NegotiatedMessageSerializer,
4139
4894
  OFFLINE_SYNC_STATUS,
4140
4895
  OutboundQueue,
@@ -4150,11 +4905,13 @@ export {
4150
4905
  WebSocketTransport,
4151
4906
  createDeltaCursorFromBatch,
4152
4907
  createSyncStatusController,
4908
+ decodeBlobChunkBytes,
4153
4909
  decodeDeltaCursor,
4154
4910
  decodeYjsUpdate,
4155
4911
  dedupeQuerySubsets,
4156
4912
  deriveKey,
4157
4913
  deriveVersionedKey,
4914
+ encodeBlobChunkBytes,
4158
4915
  encodeDeltaCursor,
4159
4916
  encodeYjsUpdate,
4160
4917
  filterOperationsByScope,
@@ -4162,12 +4919,16 @@ export {
4162
4919
  getRemoteAwarenessStates,
4163
4920
  isAcknowledgmentMessage,
4164
4921
  isAwarenessUpdateMessage,
4922
+ isBlobChunkPushMessage,
4923
+ isBlobChunkRequestMessage,
4924
+ isBlobChunkResponseMessage,
4165
4925
  isClientSchemaVersionSupported,
4166
4926
  isEncryptedPayload,
4167
4927
  isErrorMessage,
4168
4928
  isHandshakeMessage,
4169
4929
  isHandshakeResponseMessage,
4170
4930
  isOperationBatchMessage,
4931
+ isOperationRejectedMessage,
4171
4932
  isSchemaMismatchReject,
4172
4933
  isSyncMessage,
4173
4934
  isYjsDocUpdateMessage,