@korajs/sync 1.0.0-beta.1 → 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.cjs CHANGED
@@ -41,6 +41,7 @@ __export(index_exports, {
41
41
  InvalidScopeError: () => InvalidScopeError,
42
42
  JsonMessageSerializer: () => JsonMessageSerializer,
43
43
  KeyDerivationError: () => KeyDerivationError,
44
+ MemoryRejectedOperationStorage: () => MemoryRejectedOperationStorage,
44
45
  NegotiatedMessageSerializer: () => NegotiatedMessageSerializer,
45
46
  OFFLINE_SYNC_STATUS: () => OFFLINE_SYNC_STATUS,
46
47
  OutboundQueue: () => OutboundQueue,
@@ -79,6 +80,7 @@ __export(index_exports, {
79
80
  isHandshakeMessage: () => isHandshakeMessage,
80
81
  isHandshakeResponseMessage: () => isHandshakeResponseMessage,
81
82
  isOperationBatchMessage: () => isOperationBatchMessage,
83
+ isOperationRejectedMessage: () => isOperationRejectedMessage,
82
84
  isSchemaMismatchReject: () => isSchemaMismatchReject,
83
85
  isSyncMessage: () => isSyncMessage,
84
86
  isYjsDocUpdateMessage: () => isYjsDocUpdateMessage,
@@ -104,6 +106,7 @@ var SYNC_STATES = [
104
106
  ];
105
107
  var SYNC_STATUSES = [
106
108
  "connected",
109
+ "reconnecting",
107
110
  "syncing",
108
111
  "synced",
109
112
  "offline",
@@ -111,6 +114,20 @@ var SYNC_STATUSES = [
111
114
  "error",
112
115
  "schema-mismatch"
113
116
  ];
117
+ var MemoryRejectedOperationStorage = class {
118
+ rejected = /* @__PURE__ */ new Map();
119
+ async record(rejected) {
120
+ this.rejected.set(rejected.operationId, rejected);
121
+ }
122
+ async list() {
123
+ return [...this.rejected.values()];
124
+ }
125
+ async remove(operationIds) {
126
+ for (const id of operationIds) {
127
+ this.rejected.delete(id);
128
+ }
129
+ }
130
+ };
114
131
  var ScopeViolationError = class extends import_core.KoraError {
115
132
  constructor(operationId, collection, scope, message) {
116
133
  super(
@@ -308,6 +325,8 @@ function isSyncMessage(value) {
308
325
  return isAcknowledgmentMessage(value);
309
326
  case "error":
310
327
  return isErrorMessage(value);
328
+ case "operation-rejected":
329
+ return isOperationRejectedMessage(value);
311
330
  case "awareness-update":
312
331
  return isAwarenessUpdateMessage(value);
313
332
  case "yjs-doc-update":
@@ -347,6 +366,11 @@ function isErrorMessage(value) {
347
366
  const msg = value;
348
367
  return msg.type === "error" && typeof msg.messageId === "string" && typeof msg.code === "string" && typeof msg.message === "string" && typeof msg.retriable === "boolean";
349
368
  }
369
+ function isOperationRejectedMessage(value) {
370
+ if (typeof value !== "object" || value === null) return false;
371
+ const msg = value;
372
+ 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";
373
+ }
350
374
  function isAwarenessUpdateMessage(value) {
351
375
  if (typeof value !== "object" || value === null) return false;
352
376
  const msg = value;
@@ -597,7 +621,8 @@ function toProtoEnvelope(message) {
597
621
  })),
598
622
  schemaVersion: message.schemaVersion,
599
623
  authToken: message.authToken,
600
- supportedWireFormats: message.supportedWireFormats
624
+ supportedWireFormats: message.supportedWireFormats,
625
+ ...message.lastDeliverySequence !== void 0 ? { lastDeliverySequence: message.lastDeliverySequence } : {}
601
626
  };
602
627
  case "handshake-response":
603
628
  return {
@@ -613,7 +638,8 @@ function toProtoEnvelope(message) {
613
638
  rejectReason: message.rejectReason,
614
639
  selectedWireFormat: message.selectedWireFormat,
615
640
  serverTime: message.serverTime,
616
- blobStorageEnabled: message.blobStorageEnabled
641
+ blobStorageEnabled: message.blobStorageEnabled,
642
+ ...message.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: message.serverMaxDeliverySequence } : {}
617
643
  };
618
644
  case "operation-batch":
619
645
  return {
@@ -621,14 +647,17 @@ function toProtoEnvelope(message) {
621
647
  messageId: message.messageId,
622
648
  operations: message.operations.map(serializeProtoOperation),
623
649
  isFinal: message.isFinal,
624
- batchIndex: message.batchIndex
650
+ batchIndex: message.batchIndex,
651
+ ...message.baseDeliverySequence !== void 0 ? { baseDeliverySequence: message.baseDeliverySequence } : {},
652
+ ...message.maxDeliverySequence !== void 0 ? { maxDeliverySequence: message.maxDeliverySequence } : {}
625
653
  };
626
654
  case "acknowledgment":
627
655
  return {
628
656
  type: message.type,
629
657
  messageId: message.messageId,
630
658
  acknowledgedMessageId: message.acknowledgedMessageId,
631
- lastSequenceNumber: message.lastSequenceNumber
659
+ lastSequenceNumber: message.lastSequenceNumber,
660
+ ...message.deliverySequence !== void 0 ? { deliverySequence: message.deliverySequence } : {}
632
661
  };
633
662
  case "error":
634
663
  return {
@@ -638,6 +667,17 @@ function toProtoEnvelope(message) {
638
667
  errorMessage: message.message,
639
668
  retriable: message.retriable
640
669
  };
670
+ case "operation-rejected":
671
+ return {
672
+ type: message.type,
673
+ messageId: message.messageId,
674
+ operationId: message.operationId,
675
+ collection: message.collection,
676
+ recordId: message.recordId,
677
+ errorCode: message.code,
678
+ errorMessage: message.message,
679
+ retriable: message.retriable
680
+ };
641
681
  case "awareness-update":
642
682
  case "yjs-doc-update":
643
683
  return {
@@ -682,7 +722,8 @@ function fromProtoEnvelope(envelope) {
682
722
  authToken: envelope.authToken,
683
723
  supportedWireFormats: envelope.supportedWireFormats?.filter(
684
724
  (format) => format === "json" || format === "protobuf"
685
- )
725
+ ),
726
+ ...envelope.lastDeliverySequence !== void 0 ? { lastDeliverySequence: envelope.lastDeliverySequence } : {}
686
727
  };
687
728
  case "handshake-response":
688
729
  return {
@@ -699,7 +740,8 @@ function fromProtoEnvelope(envelope) {
699
740
  // Preserve the server's wall-clock time so clock-skew detection and
700
741
  // automatic timestamp rebase work over the protobuf wire, not just JSON.
701
742
  ...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {},
702
- ...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {}
743
+ ...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {},
744
+ ...envelope.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: envelope.serverMaxDeliverySequence } : {}
703
745
  };
704
746
  case "operation-batch":
705
747
  return {
@@ -707,14 +749,17 @@ function fromProtoEnvelope(envelope) {
707
749
  messageId: envelope.messageId,
708
750
  operations: (envelope.operations ?? []).map(deserializeProtoOperation),
709
751
  isFinal: envelope.isFinal ?? false,
710
- batchIndex: envelope.batchIndex ?? 0
752
+ batchIndex: envelope.batchIndex ?? 0,
753
+ ...envelope.baseDeliverySequence !== void 0 ? { baseDeliverySequence: envelope.baseDeliverySequence } : {},
754
+ ...envelope.maxDeliverySequence !== void 0 ? { maxDeliverySequence: envelope.maxDeliverySequence } : {}
711
755
  };
712
756
  case "acknowledgment":
713
757
  return {
714
758
  type: "acknowledgment",
715
759
  messageId: envelope.messageId,
716
760
  acknowledgedMessageId: envelope.acknowledgedMessageId ?? "",
717
- lastSequenceNumber: envelope.lastSequenceNumber ?? 0
761
+ lastSequenceNumber: envelope.lastSequenceNumber ?? 0,
762
+ ...envelope.deliverySequence !== void 0 ? { deliverySequence: envelope.deliverySequence } : {}
718
763
  };
719
764
  case "error":
720
765
  return {
@@ -724,6 +769,17 @@ function fromProtoEnvelope(envelope) {
724
769
  message: envelope.errorMessage ?? "Unknown error",
725
770
  retriable: envelope.retriable ?? false
726
771
  };
772
+ case "operation-rejected":
773
+ return {
774
+ type: "operation-rejected",
775
+ messageId: envelope.messageId,
776
+ operationId: envelope.operationId ?? "",
777
+ collection: envelope.collection ?? "",
778
+ recordId: envelope.recordId ?? "",
779
+ code: envelope.errorCode ?? "REJECTED",
780
+ message: envelope.errorMessage ?? "",
781
+ retriable: envelope.retriable ?? false
782
+ };
727
783
  case "blob-chunk-request":
728
784
  return {
729
785
  type: "blob-chunk-request",
@@ -898,6 +954,21 @@ function encodeEnvelope(envelope) {
898
954
  if (envelope.hasBytes !== void 0) writer.uint32(184).bool(envelope.hasBytes);
899
955
  if (envelope.blobStorageEnabled !== void 0)
900
956
  writer.uint32(200).bool(envelope.blobStorageEnabled);
957
+ if (envelope.operationId && envelope.operationId.length > 0)
958
+ writer.uint32(210).string(envelope.operationId);
959
+ if (envelope.collection && envelope.collection.length > 0)
960
+ writer.uint32(218).string(envelope.collection);
961
+ if (envelope.recordId && envelope.recordId.length > 0)
962
+ writer.uint32(226).string(envelope.recordId);
963
+ if (envelope.lastDeliverySequence !== void 0)
964
+ writer.uint32(232).int64(envelope.lastDeliverySequence);
965
+ if (envelope.baseDeliverySequence !== void 0)
966
+ writer.uint32(240).int64(envelope.baseDeliverySequence);
967
+ if (envelope.maxDeliverySequence !== void 0)
968
+ writer.uint32(248).int64(envelope.maxDeliverySequence);
969
+ if (envelope.deliverySequence !== void 0) writer.uint32(256).int64(envelope.deliverySequence);
970
+ if (envelope.serverMaxDeliverySequence !== void 0)
971
+ writer.uint32(264).int64(envelope.serverMaxDeliverySequence);
901
972
  return writer.finish();
902
973
  }
903
974
  function decodeEnvelope(bytes) {
@@ -984,6 +1055,30 @@ function decodeEnvelope(bytes) {
984
1055
  case 25:
985
1056
  envelope.blobStorageEnabled = reader.bool();
986
1057
  break;
1058
+ case 26:
1059
+ envelope.operationId = reader.string();
1060
+ break;
1061
+ case 27:
1062
+ envelope.collection = reader.string();
1063
+ break;
1064
+ case 28:
1065
+ envelope.recordId = reader.string();
1066
+ break;
1067
+ case 29:
1068
+ envelope.lastDeliverySequence = longToNumber(reader.int64());
1069
+ break;
1070
+ case 30:
1071
+ envelope.baseDeliverySequence = longToNumber(reader.int64());
1072
+ break;
1073
+ case 31:
1074
+ envelope.maxDeliverySequence = longToNumber(reader.int64());
1075
+ break;
1076
+ case 32:
1077
+ envelope.deliverySequence = longToNumber(reader.int64());
1078
+ break;
1079
+ case 33:
1080
+ envelope.serverMaxDeliverySequence = longToNumber(reader.int64());
1081
+ break;
987
1082
  default:
988
1083
  reader.skipType(tag & 7);
989
1084
  }
@@ -1483,6 +1578,7 @@ var ChaosTransport = class {
1483
1578
  reorderRate;
1484
1579
  maxLatency;
1485
1580
  random;
1581
+ dropPredicate;
1486
1582
  messageHandler = null;
1487
1583
  reorderBuffer = [];
1488
1584
  pending = [];
@@ -1493,6 +1589,7 @@ var ChaosTransport = class {
1493
1589
  this.reorderRate = config?.reorderRate ?? 0;
1494
1590
  this.maxLatency = config?.maxLatency ?? 0;
1495
1591
  this.random = config?.randomSource ?? Math.random;
1592
+ this.dropPredicate = config?.dropPredicate ?? null;
1496
1593
  }
1497
1594
  async connect(url, options) {
1498
1595
  this.inner.onMessage((msg) => this.handleIncoming(msg));
@@ -1511,6 +1608,9 @@ var ChaosTransport = class {
1511
1608
  return this.inner.disconnect();
1512
1609
  }
1513
1610
  send(message) {
1611
+ if (this.dropPredicate?.(message, "outgoing")) {
1612
+ return;
1613
+ }
1514
1614
  if (this.random() < this.dropRate) {
1515
1615
  return;
1516
1616
  }
@@ -1538,6 +1638,9 @@ var ChaosTransport = class {
1538
1638
  }
1539
1639
  handleIncoming(message) {
1540
1640
  if (!this.messageHandler) return;
1641
+ if (this.dropPredicate?.(message, "incoming")) {
1642
+ return;
1643
+ }
1541
1644
  if (this.random() < this.dropRate) {
1542
1645
  return;
1543
1646
  }
@@ -2502,6 +2605,32 @@ var OutboundQueue = class {
2502
2605
  const ids = ops.map((op) => op.id);
2503
2606
  await this.storage.dequeue(ids);
2504
2607
  }
2608
+ /**
2609
+ * Acknowledge only operations in an in-flight batch whose local sequence number is
2610
+ * covered by the server's ack. Any later operations return to the front of the
2611
+ * queue for retry. This is required when the server accepts a prefix but rejects a
2612
+ * later operation retriably (for example a stale-scope push after auth changed).
2613
+ */
2614
+ async acknowledgeThrough(batchId, lastSequenceNumber) {
2615
+ const ops = this.inFlight.get(batchId);
2616
+ if (!ops) return;
2617
+ this.inFlight.delete(batchId);
2618
+ const acknowledged = ops.filter((op) => op.sequenceNumber <= lastSequenceNumber);
2619
+ const retry = ops.filter((op) => op.sequenceNumber > lastSequenceNumber);
2620
+ if (retry.length > 0) {
2621
+ this.queue.unshift(...retry);
2622
+ if (this.queue.length > 1) {
2623
+ this.queue = (0, import_internal.topologicalSort)(this.queue);
2624
+ }
2625
+ }
2626
+ if (acknowledged.length > 0) {
2627
+ const ids = acknowledged.map((op) => op.id);
2628
+ for (const id of ids) {
2629
+ this.seen.delete(id);
2630
+ }
2631
+ await this.storage.dequeue(ids);
2632
+ }
2633
+ }
2505
2634
  /**
2506
2635
  * Return a failed batch to the front of the queue for retry.
2507
2636
  * Prepends the operations to maintain priority.
@@ -2584,6 +2713,37 @@ var OutboundQueue = class {
2584
2713
  get isInitialized() {
2585
2714
  return this.initialized;
2586
2715
  }
2716
+ /**
2717
+ * Remove a single operation the server permanently rejected — from the queue,
2718
+ * from any in-flight batch, and from durable storage — so a later batch ack or
2719
+ * a reconnect `returnBatch` can never resend or resurrect it. Returns the
2720
+ * removed operation so the caller can record it in a durable rejected store,
2721
+ * or null if it was not present (already acknowledged and removed).
2722
+ */
2723
+ async reject(opId) {
2724
+ let removed = null;
2725
+ for (const [batchId, ops] of this.inFlight) {
2726
+ const idx = ops.findIndex((op) => op.id === opId);
2727
+ if (idx !== -1) {
2728
+ removed = ops[idx] ?? null;
2729
+ ops.splice(idx, 1);
2730
+ if (ops.length === 0) {
2731
+ this.inFlight.delete(batchId);
2732
+ }
2733
+ break;
2734
+ }
2735
+ }
2736
+ if (!removed) {
2737
+ const idx = this.queue.findIndex((op) => op.id === opId);
2738
+ if (idx !== -1) {
2739
+ removed = this.queue[idx] ?? null;
2740
+ this.queue.splice(idx, 1);
2741
+ }
2742
+ }
2743
+ this.seen.delete(opId);
2744
+ await this.storage.dequeue([opId]);
2745
+ return removed;
2746
+ }
2587
2747
  /**
2588
2748
  * Remove operations by id from queue and persistent storage.
2589
2749
  * Used when ops were already sent during handshake delta exchange.
@@ -2602,6 +2762,7 @@ var OutboundQueue = class {
2602
2762
  // src/engine/sync-engine.ts
2603
2763
  var DEFAULT_BATCH_SIZE = 100;
2604
2764
  var DEFAULT_SCHEMA_VERSION = 1;
2765
+ var DEFAULT_OUTBOUND_ACK_TIMEOUT_MS = 3e4;
2605
2766
  var VALID_TRANSITIONS = {
2606
2767
  disconnected: ["connecting"],
2607
2768
  connecting: ["handshaking", "error", "disconnected"],
@@ -2615,6 +2776,17 @@ var nextQuerySubsetId = 0;
2615
2776
  function generateMessageId() {
2616
2777
  return `msg-${Date.now()}-${nextMessageId++}`;
2617
2778
  }
2779
+ var MAX_DELIVERY_VIEW_WATERMARKS = 64;
2780
+ function stableStringify(value) {
2781
+ if (value === null || typeof value !== "object") {
2782
+ return JSON.stringify(value) ?? "null";
2783
+ }
2784
+ if (Array.isArray(value)) {
2785
+ return `[${value.map(stableStringify).join(",")}]`;
2786
+ }
2787
+ 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)}`);
2788
+ return `{${entries.join(",")}}`;
2789
+ }
2618
2790
  var SyncEngine = class {
2619
2791
  state = "disconnected";
2620
2792
  transport;
@@ -2623,7 +2795,9 @@ var SyncEngine = class {
2623
2795
  serializer;
2624
2796
  emitter;
2625
2797
  outboundQueue;
2798
+ rejectedStorage;
2626
2799
  batchSize;
2800
+ outboundAckTimeoutMs;
2627
2801
  encryptor;
2628
2802
  awarenessManager;
2629
2803
  richtextDocChannel;
@@ -2638,11 +2812,16 @@ var SyncEngine = class {
2638
2812
  lastSuccessfulPull = null;
2639
2813
  conflictCount = 0;
2640
2814
  currentBatch = null;
2815
+ currentBatchRequiresPartialAck = false;
2641
2816
  reconnecting = false;
2642
2817
  schemaBlocked = false;
2643
2818
  clockBlocked = false;
2644
2819
  clockSkewMs = null;
2645
2820
  blobStorageEnabled = false;
2821
+ outboundAckTimer = null;
2822
+ startPromise = null;
2823
+ stopPromise = null;
2824
+ reconnectPromise = null;
2646
2825
  // Track delta exchange state
2647
2826
  deltaBatchesReceived = 0;
2648
2827
  deltaReceiveComplete = false;
@@ -2663,6 +2842,27 @@ var SyncEngine = class {
2663
2842
  /** Resume cursor for paginated initial sync (persisted across reconnects) */
2664
2843
  resumeDeltaCursor = null;
2665
2844
  initialSyncTotalBatches = 0;
2845
+ /**
2846
+ * The delivery watermark for the CURRENT sync view: the highest server delivery
2847
+ * sequence up to which every operation in the current view (scope + active query
2848
+ * subscriptions) has been contiguously applied. Advanced only through the server's
2849
+ * gap-free delivery stream (never through live relay), so it is a durable lower bound
2850
+ * on what this client holds for this view. Reported at handshake to resume the stream.
2851
+ *
2852
+ * The watermark is keyed by view: switching views (a scope or subscription change)
2853
+ * saves the current view's watermark and loads the target view's, so returning to a
2854
+ * previously-synced view resumes exactly where it left off instead of re-syncing. This
2855
+ * is correct because a view's watermark is a lower bound specific to that view's
2856
+ * filter; a widened view is simply a different (initially unsynced) view.
2857
+ */
2858
+ deliveryWatermark = 0;
2859
+ /**
2860
+ * In-memory cache of watermark per view signature, preloaded from persistence at start
2861
+ * so a view switch resolves synchronously (no race with the reconnect it triggers).
2862
+ */
2863
+ deliverySignatureWatermarks = /* @__PURE__ */ new Map();
2864
+ deliveryGapRepeatCount = 0;
2865
+ lastDeliveryGapKey = null;
2666
2866
  constructor(options) {
2667
2867
  this.transport = options.transport;
2668
2868
  this.store = options.store;
@@ -2670,11 +2870,13 @@ var SyncEngine = class {
2670
2870
  this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
2671
2871
  this.emitter = options.emitter ?? null;
2672
2872
  this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
2873
+ this.outboundAckTimeoutMs = options.config.outboundAckTimeoutMs ?? DEFAULT_OUTBOUND_ACK_TIMEOUT_MS;
2673
2874
  this.encryptor = options.encryptor ?? null;
2674
2875
  this.syncState = options.syncState ?? null;
2675
2876
  this.activeScope = options.config.scopeMap;
2676
2877
  const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
2677
2878
  this.outboundQueue = new OutboundQueue(queueStorage);
2879
+ this.rejectedStorage = options.rejectedStorage ?? new MemoryRejectedOperationStorage();
2678
2880
  this.metricsCollector = new SyncMetricsCollector(options.metricsConfig);
2679
2881
  if (this.emitter) {
2680
2882
  this.metricsCollector.attachEmitter(this.emitter);
@@ -2714,10 +2916,20 @@ var SyncEngine = class {
2714
2916
  * Start the sync engine: connect → handshake → delta exchange → streaming.
2715
2917
  */
2716
2918
  async start() {
2919
+ if (this.state === "streaming") return;
2920
+ if (this.startPromise) return this.startPromise;
2921
+ this.startPromise = this.startInternal().finally(() => {
2922
+ this.startPromise = null;
2923
+ });
2924
+ return this.startPromise;
2925
+ }
2926
+ async startInternal() {
2927
+ if (this.stopPromise) {
2928
+ await this.stopPromise;
2929
+ }
2930
+ if (this.state === "streaming") return;
2717
2931
  if (this.state !== "disconnected") {
2718
- throw new import_core7.SyncError("Cannot start sync engine: not in disconnected state", {
2719
- currentState: this.state
2720
- });
2932
+ await this.stop();
2721
2933
  }
2722
2934
  await this.outboundQueue.initialize();
2723
2935
  if (this.syncState) {
@@ -2725,6 +2937,16 @@ var SyncEngine = class {
2725
2937
  if (this.syncState.loadDeltaCursor) {
2726
2938
  this.resumeDeltaCursor = await this.syncState.loadDeltaCursor();
2727
2939
  }
2940
+ if (this.syncState.loadAllDeliveryWatermarks) {
2941
+ const all = await this.syncState.loadAllDeliveryWatermarks();
2942
+ for (const [signature, watermark] of Object.entries(all)) {
2943
+ this.deliverySignatureWatermarks.set(signature, watermark);
2944
+ }
2945
+ } else if (this.syncState.loadDeliveryWatermark) {
2946
+ this.deliverySignatureWatermarks.set("", await this.syncState.loadDeliveryWatermark(""));
2947
+ }
2948
+ this.deliveryWatermark = this.deliverySignatureWatermarks.get(this.deliverySignature()) ?? 0;
2949
+ this.evictColdViewWatermarks();
2728
2950
  }
2729
2951
  await this.reconcileOutboundFromOpLog();
2730
2952
  await this.refreshPendingCount();
@@ -2740,7 +2962,12 @@ var SyncEngine = class {
2740
2962
  this.transitionTo("connecting");
2741
2963
  try {
2742
2964
  const authToken = this.config.auth ? (await this.config.auth()).token : void 0;
2965
+ if (this.state !== "connecting") return;
2743
2966
  await this.transport.connect(this.config.url, { authToken });
2967
+ if (this.state !== "connecting") {
2968
+ await this.transport.disconnect();
2969
+ return;
2970
+ }
2744
2971
  this.transitionTo("handshaking");
2745
2972
  const localVector = this.store.getVersionVector();
2746
2973
  const activeQuerySubsets = this.getActiveQuerySubsets();
@@ -2754,7 +2981,11 @@ var SyncEngine = class {
2754
2981
  supportedWireFormats: ["json", "protobuf"],
2755
2982
  ...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
2756
2983
  ...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
2757
- ...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {}
2984
+ ...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {},
2985
+ // Resume the server's gap-free delivery stream from our watermark. A server
2986
+ // that understands it drives server->client sync from delivery sequences; an
2987
+ // older server ignores it and falls back to the version-vector delta.
2988
+ lastDeliverySequence: this.deliveryWatermark
2758
2989
  };
2759
2990
  this.transport.send(handshake);
2760
2991
  } catch (err) {
@@ -2766,11 +2997,20 @@ var SyncEngine = class {
2766
2997
  * Stop the sync engine. Disconnects the transport.
2767
2998
  */
2768
2999
  async stop() {
3000
+ if (this.stopPromise) return this.stopPromise;
3001
+ this.stopPromise = this.stopInternal().finally(() => {
3002
+ this.stopPromise = null;
3003
+ });
3004
+ return this.stopPromise;
3005
+ }
3006
+ async stopInternal() {
2769
3007
  if (this.state === "disconnected") return;
3008
+ this.clearOutboundAckTimer();
2770
3009
  this.awarenessManager.stopCleanupTimer();
2771
3010
  if (this.currentBatch) {
2772
3011
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
2773
3012
  this.currentBatch = null;
3013
+ this.currentBatchRequiresPartialAck = false;
2774
3014
  }
2775
3015
  try {
2776
3016
  await this.transport.disconnect();
@@ -2791,7 +3031,7 @@ var SyncEngine = class {
2791
3031
  * because they should remain local-only and not be sent to the server.
2792
3032
  */
2793
3033
  async pushOperation(op) {
2794
- if (!this.operationAllowedForSync(op)) {
3034
+ if (!await this.operationAllowedForSync(op)) {
2795
3035
  return;
2796
3036
  }
2797
3037
  if (this.syncState) {
@@ -2820,6 +3060,7 @@ var SyncEngine = class {
2820
3060
  getStatus() {
2821
3061
  const pendingOperations = this.syncState ? this.cachedUnsyncedCount : this.outboundQueue.totalPending;
2822
3062
  const base = {
3063
+ reconnecting: this.reconnecting,
2823
3064
  pendingOperations,
2824
3065
  lastSyncedAt: this.lastSyncedAt,
2825
3066
  lastSuccessfulPush: this.lastSuccessfulPush,
@@ -2839,7 +3080,7 @@ var SyncEngine = class {
2839
3080
  case "connecting":
2840
3081
  case "handshaking":
2841
3082
  case "syncing":
2842
- return { ...base, status: this.reconnecting ? "offline" : "syncing" };
3083
+ return { ...base, status: this.reconnecting ? "reconnecting" : "syncing" };
2843
3084
  case "streaming":
2844
3085
  return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
2845
3086
  case "error":
@@ -2924,6 +3165,24 @@ var SyncEngine = class {
2924
3165
  await this.start();
2925
3166
  }
2926
3167
  }
3168
+ /**
3169
+ * Atomically refresh the transport session. Overlapping callers share one
3170
+ * reconnect, in-flight outbound batches are returned to the queue by stop(), and the
3171
+ * next start handshakes with the latest auth token, sync scope, and query subsets.
3172
+ */
3173
+ async reconnect() {
3174
+ if (this.schemaBlocked) return;
3175
+ if (this.reconnectPromise) return this.reconnectPromise;
3176
+ this.reconnecting = false;
3177
+ this.reconnectPromise = this.reconnectInternal().finally(() => {
3178
+ this.reconnectPromise = null;
3179
+ });
3180
+ return this.reconnectPromise;
3181
+ }
3182
+ async reconnectInternal() {
3183
+ await this.stop();
3184
+ await this.start();
3185
+ }
2927
3186
  /**
2928
3187
  * Export a diagnostics snapshot for debugging and support tickets.
2929
3188
  * Contains connection state, timing info, and queue metrics.
@@ -2942,6 +3201,8 @@ var SyncEngine = class {
2942
3201
  pendingOperations: this.outboundQueue.totalPending,
2943
3202
  hasInFlightBatch: this.currentBatch !== null,
2944
3203
  reconnecting: this.reconnecting,
3204
+ deliveryWatermark: this.deliveryWatermark,
3205
+ deliveryGapRepeatCount: this.deliveryGapRepeatCount,
2945
3206
  timestamp: Date.now()
2946
3207
  };
2947
3208
  }
@@ -2970,8 +3231,84 @@ var SyncEngine = class {
2970
3231
  * @param scopeMap - New per-collection scope filters, or undefined to remove scope
2971
3232
  */
2972
3233
  updateScope(scopeMap) {
3234
+ const previousSignature = this.deliverySignature();
2973
3235
  this.activeScope = scopeMap;
2974
3236
  this.config.scopeMap = scopeMap;
3237
+ this.switchDeliveryView(previousSignature);
3238
+ }
3239
+ /**
3240
+ * A stable identifier for the current sync view: the auth/tenant scope plus the set of
3241
+ * active query subscriptions, canonicalized so the same view always produces the same
3242
+ * string. The delivery watermark is keyed by this, so each distinct view resumes from
3243
+ * its own last position.
3244
+ */
3245
+ deliverySignature() {
3246
+ const subsets = this.getActiveQuerySubsets();
3247
+ if (!this.activeScope && subsets.length === 0) {
3248
+ return "";
3249
+ }
3250
+ const normalizedSubsets = subsets.map((s) => `${s.collection}:${stableStringify(s.where)}`).sort();
3251
+ return stableStringify({ scope: this.activeScope ?? null, subsets: normalizedSubsets });
3252
+ }
3253
+ /**
3254
+ * Switch the active delivery watermark to the current view after a scope or subscription
3255
+ * change. The prior view's watermark is saved so returning to it resumes; the new view's
3256
+ * watermark is restored from the in-memory cache (0 for a never-synced view, which then
3257
+ * does a one-time resync under that view). No data is lost: a view's watermark is a lower
3258
+ * bound for that specific view's filter.
3259
+ */
3260
+ switchDeliveryView(previousSignature) {
3261
+ const nextSignature = this.deliverySignature();
3262
+ if (nextSignature === previousSignature) {
3263
+ return;
3264
+ }
3265
+ this.setViewWatermark(previousSignature, this.deliveryWatermark);
3266
+ void this.persistDeliveryWatermark(this.deliveryWatermark, previousSignature);
3267
+ this.deliveryWatermark = this.deliverySignatureWatermarks.get(nextSignature) ?? 0;
3268
+ }
3269
+ /**
3270
+ * Record a view's watermark in the in-memory cache as the most-recently-used entry (the
3271
+ * Map preserves insertion order, so re-inserting moves it to the tail), then evict cold
3272
+ * views past the retention cap. This is the single place the cache is written, so LRU
3273
+ * order and the size bound are always maintained together.
3274
+ */
3275
+ setViewWatermark(signature, watermark) {
3276
+ this.deliverySignatureWatermarks.delete(signature);
3277
+ this.deliverySignatureWatermarks.set(signature, watermark);
3278
+ this.evictColdViewWatermarks();
3279
+ }
3280
+ /**
3281
+ * Trim the per-view watermark cache to `MAX_DELIVERY_VIEW_WATERMARKS`, evicting the
3282
+ * least-recently-used views. The default view ('') and the live view are never evicted
3283
+ * (evicting the live view would force an immediate re-scan of what we are actively
3284
+ * syncing). Each eviction also removes the persisted row, bounding storage. Safe by
3285
+ * construction: an evicted view simply back-fills from 0 (deduplicated) when next seen.
3286
+ */
3287
+ evictColdViewWatermarks() {
3288
+ if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
3289
+ return;
3290
+ }
3291
+ const liveSignature = this.deliverySignature();
3292
+ for (const signature of this.deliverySignatureWatermarks.keys()) {
3293
+ if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
3294
+ break;
3295
+ }
3296
+ if (signature === "" || signature === liveSignature) {
3297
+ continue;
3298
+ }
3299
+ this.deliverySignatureWatermarks.delete(signature);
3300
+ void this.deleteDeliveryWatermark(signature);
3301
+ }
3302
+ }
3303
+ /**
3304
+ * Remove a view's persisted watermark row (best-effort; a persistence layer that omits
3305
+ * the delete simply keeps the row, which is harmless).
3306
+ */
3307
+ async deleteDeliveryWatermark(signature) {
3308
+ if (!this.syncState?.deleteDeliveryWatermark) {
3309
+ return;
3310
+ }
3311
+ await this.syncState.deleteDeliveryWatermark(signature);
2975
3312
  }
2976
3313
  /**
2977
3314
  * Get the currently active scope map. Returns undefined if no scope is configured.
@@ -2985,10 +3322,14 @@ var SyncEngine = class {
2985
3322
  */
2986
3323
  registerQuerySubset(subset) {
2987
3324
  const id = `query-${nextQuerySubsetId++}`;
3325
+ const previousSignature = this.deliverySignature();
2988
3326
  this.querySubsets.set(id, subset);
3327
+ this.switchDeliveryView(previousSignature);
2989
3328
  this.scheduleQuerySubsetReconnect();
2990
3329
  return () => {
3330
+ const sigBeforeRemove = this.deliverySignature();
2991
3331
  this.querySubsets.delete(id);
3332
+ this.switchDeliveryView(sigBeforeRemove);
2992
3333
  this.scheduleQuerySubsetReconnect();
2993
3334
  };
2994
3335
  }
@@ -3051,11 +3392,14 @@ var SyncEngine = class {
3051
3392
  await this.handleOperationBatch(message);
3052
3393
  break;
3053
3394
  case "acknowledgment":
3054
- this.handleAcknowledgment(message);
3395
+ await this.handleAcknowledgment(message);
3055
3396
  break;
3056
3397
  case "error":
3057
3398
  this.handleError(message);
3058
3399
  break;
3400
+ case "operation-rejected":
3401
+ await this.handleOperationRejected(message);
3402
+ break;
3059
3403
  case "awareness-update":
3060
3404
  this.handleAwarenessUpdate(message);
3061
3405
  break;
@@ -3138,6 +3482,11 @@ var SyncEngine = class {
3138
3482
  }
3139
3483
  this.remoteVector = wireToVersionVector(msg.versionVector);
3140
3484
  void this.persistLastAckedServerVector(this.remoteVector);
3485
+ if (typeof msg.serverMaxDeliverySequence === "number" && this.deliveryWatermark > msg.serverMaxDeliverySequence) {
3486
+ this.deliveryWatermark = 0;
3487
+ this.setViewWatermark(this.deliverySignature(), 0);
3488
+ await this.persistDeliveryWatermark(0);
3489
+ }
3141
3490
  if (msg.selectedWireFormat) {
3142
3491
  this.setSerializerWireFormat(msg.selectedWireFormat);
3143
3492
  }
@@ -3203,7 +3552,7 @@ var SyncEngine = class {
3203
3552
  async sendDelta() {
3204
3553
  const localVector = this.store.getVersionVector();
3205
3554
  const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
3206
- const missingOps = allMissingOps.filter((op) => this.operationAllowedForSync(op));
3555
+ const missingOps = await this.filterAllowedForSync(allMissingOps);
3207
3556
  this.deltaSentOpIds = missingOps.map((op) => op.id);
3208
3557
  if (missingOps.length === 0) {
3209
3558
  const messageId = generateMessageId();
@@ -3278,11 +3627,34 @@ var SyncEngine = class {
3278
3627
  return missing;
3279
3628
  }
3280
3629
  async handleOperationBatch(msg) {
3630
+ const isDeliveryBatch = msg.maxDeliverySequence !== void 0;
3631
+ if (isDeliveryBatch) {
3632
+ const base = msg.baseDeliverySequence ?? 0;
3633
+ if (base > this.deliveryWatermark) {
3634
+ const gapKey = `${this.deliveryWatermark}:${base}`;
3635
+ this.deliveryGapRepeatCount = this.lastDeliveryGapKey === gapKey ? this.deliveryGapRepeatCount + 1 : 1;
3636
+ this.lastDeliveryGapKey = gapKey;
3637
+ this.emitter?.emit({
3638
+ type: "sync:delivery-gap",
3639
+ expectedBase: this.deliveryWatermark,
3640
+ receivedBase: base,
3641
+ currentWatermark: this.deliveryWatermark,
3642
+ messageId: msg.messageId,
3643
+ repeatCount: this.deliveryGapRepeatCount
3644
+ });
3645
+ return;
3646
+ }
3647
+ if (base < this.deliveryWatermark) {
3648
+ this.sendDeliveryAck(msg.messageId, this.deliveryWatermark);
3649
+ return;
3650
+ }
3651
+ }
3281
3652
  const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
3282
3653
  const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
3283
- const inScopeOps = operations.filter((op) => this.operationAllowedForSync(op));
3654
+ const inScopeOps = await this.filterAllowedForSync(operations);
3284
3655
  const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
3285
3656
  const transforms = this.config.operationTransforms ?? [];
3657
+ let fullyApplied = true;
3286
3658
  for (const op of inScopeOps) {
3287
3659
  const transformed = transforms.length > 0 ? (0, import_core7.applyOperationTransforms)(op, targetSchemaVersion, transforms) : op;
3288
3660
  if (transformed === null) {
@@ -3294,6 +3666,7 @@ var SyncEngine = class {
3294
3666
  this.emitApplyFailure(transformed, result);
3295
3667
  }
3296
3668
  } catch (error) {
3669
+ fullyApplied = false;
3297
3670
  if (error instanceof import_core7.ClockDriftError) {
3298
3671
  this.emitApplyFailure(transformed, "rejected", {
3299
3672
  code: import_core7.APPLY_FAILURE_CODES.CLOCK_DRIFT,
@@ -3304,11 +3677,8 @@ var SyncEngine = class {
3304
3677
  }
3305
3678
  const message = error instanceof Error ? error.message : "Apply failed";
3306
3679
  const code = error instanceof import_core7.SyncError ? error.code : error instanceof import_core7.KoraError ? error.code : error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : import_core7.APPLY_FAILURE_CODES.APPLY_FAILED;
3307
- this.emitApplyFailure(transformed, "rejected", {
3308
- code,
3309
- message,
3310
- retriable: code !== import_core7.APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY
3311
- });
3680
+ const retriable = code !== import_core7.APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY;
3681
+ this.emitApplyFailure(transformed, "rejected", { code, message, retriable });
3312
3682
  }
3313
3683
  }
3314
3684
  if (inScopeOps.length > 0) {
@@ -3319,18 +3689,29 @@ var SyncEngine = class {
3319
3689
  batchSize: inScopeOps.length
3320
3690
  });
3321
3691
  }
3322
- const lastOp = operations[operations.length - 1];
3323
- const ack = {
3324
- type: "acknowledgment",
3325
- messageId: generateMessageId(),
3326
- acknowledgedMessageId: msg.messageId,
3327
- lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3328
- };
3329
- this.transport.send(ack);
3330
- this.emitter?.emit({
3331
- type: "sync:acknowledged",
3332
- sequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3333
- });
3692
+ const deliveryFullyApplied = isDeliveryBatch && fullyApplied;
3693
+ if (!isDeliveryBatch || deliveryFullyApplied) {
3694
+ const lastOp = operations[operations.length - 1];
3695
+ const ack = {
3696
+ type: "acknowledgment",
3697
+ messageId: generateMessageId(),
3698
+ acknowledgedMessageId: msg.messageId,
3699
+ lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0,
3700
+ ...deliveryFullyApplied && msg.maxDeliverySequence !== void 0 ? { deliverySequence: msg.maxDeliverySequence } : {}
3701
+ };
3702
+ this.transport.send(ack);
3703
+ this.emitter?.emit({
3704
+ type: "sync:acknowledged",
3705
+ sequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3706
+ });
3707
+ }
3708
+ if (isDeliveryBatch && fullyApplied && msg.maxDeliverySequence !== void 0) {
3709
+ this.deliveryGapRepeatCount = 0;
3710
+ this.lastDeliveryGapKey = null;
3711
+ this.deliveryWatermark = msg.maxDeliverySequence;
3712
+ this.setViewWatermark(this.deliverySignature(), this.deliveryWatermark);
3713
+ await this.persistDeliveryWatermark(this.deliveryWatermark);
3714
+ }
3334
3715
  if (this.state === "syncing") {
3335
3716
  this.deltaBatchesReceived++;
3336
3717
  const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
@@ -3338,15 +3719,17 @@ var SyncEngine = class {
3338
3719
  this.initialSyncTotalBatches = msg.totalBatches;
3339
3720
  }
3340
3721
  this.metricsCollector.updateInitialSyncProgress(this.deltaBatchesReceived, totalBatches);
3341
- const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
3342
- inScopeOps.length > 0 ? inScopeOps : operations,
3343
- msg.batchIndex
3344
- );
3345
- if (cursorFromBatch) {
3346
- this.resumeDeltaCursor = cursorFromBatch;
3347
- await this.persistDeltaCursor(cursorFromBatch);
3722
+ if (!isDeliveryBatch) {
3723
+ const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
3724
+ inScopeOps.length > 0 ? inScopeOps : operations,
3725
+ msg.batchIndex
3726
+ );
3727
+ if (cursorFromBatch) {
3728
+ this.resumeDeltaCursor = cursorFromBatch;
3729
+ await this.persistDeltaCursor(cursorFromBatch);
3730
+ }
3348
3731
  }
3349
- if (msg.isFinal) {
3732
+ if (msg.isFinal && (!isDeliveryBatch || fullyApplied)) {
3350
3733
  this.deltaReceiveComplete = true;
3351
3734
  this.resumeDeltaCursor = null;
3352
3735
  await this.persistDeltaCursor(null);
@@ -3355,26 +3738,51 @@ var SyncEngine = class {
3355
3738
  }
3356
3739
  }
3357
3740
  }
3358
- handleAcknowledgment(msg) {
3741
+ /** Acknowledge a delivery batch (used for duplicates) without re-applying it. */
3742
+ sendDeliveryAck(acknowledgedMessageId, deliverySequence) {
3743
+ const ack = {
3744
+ type: "acknowledgment",
3745
+ messageId: generateMessageId(),
3746
+ acknowledgedMessageId,
3747
+ lastSequenceNumber: 0,
3748
+ deliverySequence
3749
+ };
3750
+ this.transport.send(ack);
3751
+ }
3752
+ async handleAcknowledgment(msg) {
3753
+ this.clearOutboundAckTimer();
3359
3754
  if (this.state === "syncing" && this.config.strictHandshake) {
3360
3755
  this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
3361
3756
  this.markDeltaSendCompleteIfReady();
3362
3757
  }
3363
3758
  if (this.currentBatch) {
3364
- this.outboundQueue.acknowledge(this.currentBatch.batchId);
3759
+ if (this.currentBatchRequiresPartialAck) {
3760
+ await this.outboundQueue.acknowledgeThrough(
3761
+ this.currentBatch.batchId,
3762
+ msg.lastSequenceNumber
3763
+ );
3764
+ } else {
3765
+ await this.outboundQueue.acknowledge(this.currentBatch.batchId);
3766
+ }
3365
3767
  this.currentBatch = null;
3768
+ this.currentBatchRequiresPartialAck = false;
3366
3769
  const now = Date.now();
3367
3770
  this.lastSyncedAt = now;
3368
3771
  this.lastSuccessfulPush = now;
3369
3772
  }
3370
- void this.advanceLastAckedForLocalNode(msg.lastSequenceNumber).then(
3371
- () => this.refreshPendingCount()
3372
- );
3773
+ await this.advanceLastAckedForLocalNode(msg.lastSequenceNumber);
3774
+ await this.refreshPendingCount();
3373
3775
  if (this.state === "streaming" && this.outboundQueue.hasOperations) {
3374
3776
  this.flushQueue();
3375
3777
  }
3376
3778
  }
3377
3779
  handleError(msg) {
3780
+ this.clearOutboundAckTimer();
3781
+ if (this.currentBatch) {
3782
+ this.outboundQueue.returnBatch(this.currentBatch.batchId);
3783
+ this.currentBatch = null;
3784
+ this.currentBatchRequiresPartialAck = false;
3785
+ }
3378
3786
  this.transitionTo("error");
3379
3787
  if (msg.code === "AUTH_FAILED") {
3380
3788
  this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
@@ -3393,6 +3801,64 @@ var SyncEngine = class {
3393
3801
  this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
3394
3802
  this.transitionTo("disconnected");
3395
3803
  }
3804
+ /**
3805
+ * Handle a per-operation rejection: divert the op out of the pending outbound
3806
+ * queue (so it is never retried or resurrected on reconnect), record it in the
3807
+ * durable rejected store (so it is kept and explainable), and emit an event so
3808
+ * the app can reconcile. Unlike {@link handleError}, this is a normal per-op
3809
+ * signal, so the connection stays up.
3810
+ */
3811
+ async handleOperationRejected(msg) {
3812
+ if (msg.retriable) {
3813
+ this.currentBatchRequiresPartialAck = true;
3814
+ await this.refreshPendingCount();
3815
+ this.emitter?.emit({
3816
+ type: "sync:operation-rejected",
3817
+ operationId: msg.operationId,
3818
+ collection: msg.collection,
3819
+ recordId: msg.recordId,
3820
+ code: msg.code,
3821
+ message: msg.message,
3822
+ retriable: true
3823
+ });
3824
+ return;
3825
+ }
3826
+ await this.outboundQueue.reject(msg.operationId);
3827
+ await this.rejectedStorage.record({
3828
+ operationId: msg.operationId,
3829
+ collection: msg.collection,
3830
+ recordId: msg.recordId,
3831
+ code: msg.code,
3832
+ message: msg.message,
3833
+ retriable: msg.retriable,
3834
+ rejectedAt: Date.now()
3835
+ });
3836
+ await this.refreshPendingCount();
3837
+ this.emitter?.emit({
3838
+ type: "sync:operation-rejected",
3839
+ operationId: msg.operationId,
3840
+ collection: msg.collection,
3841
+ recordId: msg.recordId,
3842
+ code: msg.code,
3843
+ message: msg.message,
3844
+ retriable: msg.retriable
3845
+ });
3846
+ }
3847
+ /**
3848
+ * Every operation the server rejected that has not yet been reconciled. The app
3849
+ * uses this (alongside the `sync:operation-rejected` event) to surface failed
3850
+ * submissions and decide whether to roll back or resubmit.
3851
+ */
3852
+ getRejectedOperations() {
3853
+ return this.rejectedStorage.list();
3854
+ }
3855
+ /**
3856
+ * Forget rejected operations by id once the app has reconciled them (rolled the
3857
+ * optimistic write back or resubmitted a corrected op).
3858
+ */
3859
+ clearRejectedOperations(operationIds) {
3860
+ return this.rejectedStorage.remove(operationIds);
3861
+ }
3396
3862
  async checkDeltaComplete() {
3397
3863
  if (!this.deltaSendComplete || !this.deltaReceiveComplete) {
3398
3864
  return;
@@ -3491,7 +3957,7 @@ var SyncEngine = class {
3491
3957
  return;
3492
3958
  }
3493
3959
  const ops = await this.store.getOperationRange(localNodeId, 1, localSeq);
3494
- const inScope = ops.filter((op) => this.operationAllowedForSync(op));
3960
+ const inScope = await this.filterAllowedForSync(ops);
3495
3961
  for (const op of inScope) {
3496
3962
  await this.outboundQueue.enqueue(op);
3497
3963
  }
@@ -3502,6 +3968,7 @@ var SyncEngine = class {
3502
3968
  const batch = this.outboundQueue.takeBatch(this.batchSize);
3503
3969
  if (!batch) return;
3504
3970
  this.currentBatch = batch;
3971
+ this.currentBatchRequiresPartialAck = false;
3505
3972
  if (this.encryptor) {
3506
3973
  this.encryptor.encryptBatch(batch.operations).then(
3507
3974
  (encrypted) => {
@@ -3514,6 +3981,7 @@ var SyncEngine = class {
3514
3981
  batchIndex: 0
3515
3982
  };
3516
3983
  this.transport.send(batchMsg);
3984
+ this.startOutboundAckTimer(batch.batchId);
3517
3985
  this.emitter?.emit({
3518
3986
  type: "sync:sent",
3519
3987
  operations: batch.operations,
@@ -3523,6 +3991,7 @@ var SyncEngine = class {
3523
3991
  (err) => {
3524
3992
  this.outboundQueue.returnBatch(batch.batchId);
3525
3993
  this.currentBatch = null;
3994
+ this.currentBatchRequiresPartialAck = false;
3526
3995
  this.emitter?.emit({
3527
3996
  type: "sync:disconnected",
3528
3997
  reason: err instanceof Error ? err.message : "Encryption failed"
@@ -3539,6 +4008,7 @@ var SyncEngine = class {
3539
4008
  batchIndex: 0
3540
4009
  };
3541
4010
  this.transport.send(batchMsg);
4011
+ this.startOutboundAckTimer(batch.batchId);
3542
4012
  this.emitter?.emit({
3543
4013
  type: "sync:sent",
3544
4014
  operations: batch.operations,
@@ -3546,6 +4016,34 @@ var SyncEngine = class {
3546
4016
  });
3547
4017
  }
3548
4018
  }
4019
+ startOutboundAckTimer(batchId) {
4020
+ this.clearOutboundAckTimer();
4021
+ if (this.outboundAckTimeoutMs <= 0) return;
4022
+ this.outboundAckTimer = setTimeout(() => {
4023
+ void this.handleOutboundAckTimeout(batchId);
4024
+ }, this.outboundAckTimeoutMs);
4025
+ }
4026
+ clearOutboundAckTimer() {
4027
+ if (!this.outboundAckTimer) return;
4028
+ clearTimeout(this.outboundAckTimer);
4029
+ this.outboundAckTimer = null;
4030
+ }
4031
+ async handleOutboundAckTimeout(batchId) {
4032
+ if (!this.currentBatch || this.currentBatch.batchId !== batchId) return;
4033
+ this.outboundQueue.returnBatch(batchId);
4034
+ this.currentBatch = null;
4035
+ this.currentBatchRequiresPartialAck = false;
4036
+ this.clearOutboundAckTimer();
4037
+ await this.refreshPendingCount();
4038
+ if (this.state === "disconnected") return;
4039
+ const reason = `Timed out waiting for acknowledgment of outbound batch ${batchId}`;
4040
+ try {
4041
+ await this.transport.disconnect();
4042
+ } finally {
4043
+ this.ensureDisconnected();
4044
+ this.emitter?.emit({ type: "sync:disconnected", reason });
4045
+ }
4046
+ }
3549
4047
  handleAwarenessUpdate(msg) {
3550
4048
  const awarenessMessage = {
3551
4049
  type: "awareness",
@@ -3555,9 +4053,11 @@ var SyncEngine = class {
3555
4053
  this.awarenessManager.handleRemoteMessage(awarenessMessage);
3556
4054
  }
3557
4055
  handleTransportClose(reason) {
4056
+ this.clearOutboundAckTimer();
3558
4057
  if (this.currentBatch) {
3559
4058
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
3560
4059
  this.currentBatch = null;
4060
+ this.currentBatchRequiresPartialAck = false;
3561
4061
  }
3562
4062
  if (this.schemaBlocked) {
3563
4063
  return;
@@ -3589,11 +4089,40 @@ var SyncEngine = class {
3589
4089
  this.serializer.setWireFormat(format);
3590
4090
  }
3591
4091
  }
3592
- operationAllowedForSync(op) {
3593
- if (!operationMatchesScope(op, this.activeScope)) {
4092
+ async operationAllowedForSync(op) {
4093
+ if (this.matchesScopeAndSubsets(op)) {
4094
+ return true;
4095
+ }
4096
+ const fullRecord = await this.readRecordForBackfill(op);
4097
+ if (!fullRecord) {
4098
+ return false;
4099
+ }
4100
+ return this.matchesScopeAndSubsets(op, fullRecord);
4101
+ }
4102
+ matchesScopeAndSubsets(op, fullRecord) {
4103
+ if (!operationMatchesScope(op, this.activeScope, fullRecord)) {
3594
4104
  return false;
3595
4105
  }
3596
- return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets());
4106
+ return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets(), fullRecord);
4107
+ }
4108
+ async readRecordForBackfill(op) {
4109
+ if (!this.store.readRecordFields) {
4110
+ return null;
4111
+ }
4112
+ try {
4113
+ return await this.store.readRecordFields(op.collection, op.recordId);
4114
+ } catch {
4115
+ return null;
4116
+ }
4117
+ }
4118
+ async filterAllowedForSync(ops) {
4119
+ const allowed = [];
4120
+ for (const op of ops) {
4121
+ if (await this.operationAllowedForSync(op)) {
4122
+ allowed.push(op);
4123
+ }
4124
+ }
4125
+ return allowed;
3597
4126
  }
3598
4127
  async persistDeltaCursor(cursor) {
3599
4128
  if (!this.syncState?.saveDeltaCursor) {
@@ -3601,6 +4130,12 @@ var SyncEngine = class {
3601
4130
  }
3602
4131
  await this.syncState.saveDeltaCursor(cursor);
3603
4132
  }
4133
+ async persistDeliveryWatermark(watermark, signature = this.deliverySignature()) {
4134
+ if (!this.syncState?.saveDeliveryWatermark) {
4135
+ return;
4136
+ }
4137
+ await this.syncState.saveDeliveryWatermark(signature, watermark);
4138
+ }
3604
4139
  scheduleQuerySubsetReconnect() {
3605
4140
  if (this.querySubsetReconnectTimer) {
3606
4141
  clearTimeout(this.querySubsetReconnectTimer);
@@ -3613,8 +4148,7 @@ var SyncEngine = class {
3613
4148
  }, 500);
3614
4149
  }
3615
4150
  async reconnectForQuerySubsets() {
3616
- await this.stop();
3617
- await this.start();
4151
+ await this.reconnect();
3618
4152
  }
3619
4153
  };
3620
4154
  function awarenessStatesToWire(states) {
@@ -4295,6 +4829,7 @@ function fromBase642(str) {
4295
4829
  // src/reactivity/sync-status-controller.ts
4296
4830
  var OFFLINE_SYNC_STATUS = Object.freeze({
4297
4831
  status: "offline",
4832
+ reconnecting: false,
4298
4833
  pendingOperations: 0,
4299
4834
  lastSyncedAt: null,
4300
4835
  lastSuccessfulPush: null,
@@ -4312,6 +4847,7 @@ var SYNC_STATUS_EVENT_TYPES = [
4312
4847
  "sync:received",
4313
4848
  "sync:acknowledged",
4314
4849
  "sync:apply-failed",
4850
+ "sync:delivery-gap",
4315
4851
  "sync:diagnostics",
4316
4852
  "sync:initial-sync-progress"
4317
4853
  ];
@@ -4458,6 +4994,7 @@ function subscribeRemoteAwarenessStates(awareness, listener) {
4458
4994
  InvalidScopeError,
4459
4995
  JsonMessageSerializer,
4460
4996
  KeyDerivationError,
4997
+ MemoryRejectedOperationStorage,
4461
4998
  NegotiatedMessageSerializer,
4462
4999
  OFFLINE_SYNC_STATUS,
4463
5000
  OutboundQueue,
@@ -4496,6 +5033,7 @@ function subscribeRemoteAwarenessStates(awareness, listener) {
4496
5033
  isHandshakeMessage,
4497
5034
  isHandshakeResponseMessage,
4498
5035
  isOperationBatchMessage,
5036
+ isOperationRejectedMessage,
4499
5037
  isSchemaMismatchReject,
4500
5038
  isSyncMessage,
4501
5039
  isYjsDocUpdateMessage,