@korajs/sync 1.0.0-beta.4 → 1.0.0-beta.6

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
@@ -620,7 +620,8 @@ function toProtoEnvelope(message) {
620
620
  })),
621
621
  schemaVersion: message.schemaVersion,
622
622
  authToken: message.authToken,
623
- supportedWireFormats: message.supportedWireFormats
623
+ supportedWireFormats: message.supportedWireFormats,
624
+ ...message.lastDeliverySequence !== void 0 ? { lastDeliverySequence: message.lastDeliverySequence } : {}
624
625
  };
625
626
  case "handshake-response":
626
627
  return {
@@ -636,7 +637,8 @@ function toProtoEnvelope(message) {
636
637
  rejectReason: message.rejectReason,
637
638
  selectedWireFormat: message.selectedWireFormat,
638
639
  serverTime: message.serverTime,
639
- blobStorageEnabled: message.blobStorageEnabled
640
+ blobStorageEnabled: message.blobStorageEnabled,
641
+ ...message.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: message.serverMaxDeliverySequence } : {}
640
642
  };
641
643
  case "operation-batch":
642
644
  return {
@@ -644,14 +646,17 @@ function toProtoEnvelope(message) {
644
646
  messageId: message.messageId,
645
647
  operations: message.operations.map(serializeProtoOperation),
646
648
  isFinal: message.isFinal,
647
- batchIndex: message.batchIndex
649
+ batchIndex: message.batchIndex,
650
+ ...message.baseDeliverySequence !== void 0 ? { baseDeliverySequence: message.baseDeliverySequence } : {},
651
+ ...message.maxDeliverySequence !== void 0 ? { maxDeliverySequence: message.maxDeliverySequence } : {}
648
652
  };
649
653
  case "acknowledgment":
650
654
  return {
651
655
  type: message.type,
652
656
  messageId: message.messageId,
653
657
  acknowledgedMessageId: message.acknowledgedMessageId,
654
- lastSequenceNumber: message.lastSequenceNumber
658
+ lastSequenceNumber: message.lastSequenceNumber,
659
+ ...message.deliverySequence !== void 0 ? { deliverySequence: message.deliverySequence } : {}
655
660
  };
656
661
  case "error":
657
662
  return {
@@ -716,7 +721,8 @@ function fromProtoEnvelope(envelope) {
716
721
  authToken: envelope.authToken,
717
722
  supportedWireFormats: envelope.supportedWireFormats?.filter(
718
723
  (format) => format === "json" || format === "protobuf"
719
- )
724
+ ),
725
+ ...envelope.lastDeliverySequence !== void 0 ? { lastDeliverySequence: envelope.lastDeliverySequence } : {}
720
726
  };
721
727
  case "handshake-response":
722
728
  return {
@@ -733,7 +739,8 @@ function fromProtoEnvelope(envelope) {
733
739
  // Preserve the server's wall-clock time so clock-skew detection and
734
740
  // automatic timestamp rebase work over the protobuf wire, not just JSON.
735
741
  ...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {},
736
- ...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {}
742
+ ...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {},
743
+ ...envelope.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: envelope.serverMaxDeliverySequence } : {}
737
744
  };
738
745
  case "operation-batch":
739
746
  return {
@@ -741,14 +748,17 @@ function fromProtoEnvelope(envelope) {
741
748
  messageId: envelope.messageId,
742
749
  operations: (envelope.operations ?? []).map(deserializeProtoOperation),
743
750
  isFinal: envelope.isFinal ?? false,
744
- batchIndex: envelope.batchIndex ?? 0
751
+ batchIndex: envelope.batchIndex ?? 0,
752
+ ...envelope.baseDeliverySequence !== void 0 ? { baseDeliverySequence: envelope.baseDeliverySequence } : {},
753
+ ...envelope.maxDeliverySequence !== void 0 ? { maxDeliverySequence: envelope.maxDeliverySequence } : {}
745
754
  };
746
755
  case "acknowledgment":
747
756
  return {
748
757
  type: "acknowledgment",
749
758
  messageId: envelope.messageId,
750
759
  acknowledgedMessageId: envelope.acknowledgedMessageId ?? "",
751
- lastSequenceNumber: envelope.lastSequenceNumber ?? 0
760
+ lastSequenceNumber: envelope.lastSequenceNumber ?? 0,
761
+ ...envelope.deliverySequence !== void 0 ? { deliverySequence: envelope.deliverySequence } : {}
752
762
  };
753
763
  case "error":
754
764
  return {
@@ -949,6 +959,15 @@ function encodeEnvelope(envelope) {
949
959
  writer.uint32(218).string(envelope.collection);
950
960
  if (envelope.recordId && envelope.recordId.length > 0)
951
961
  writer.uint32(226).string(envelope.recordId);
962
+ if (envelope.lastDeliverySequence !== void 0)
963
+ writer.uint32(232).int64(envelope.lastDeliverySequence);
964
+ if (envelope.baseDeliverySequence !== void 0)
965
+ writer.uint32(240).int64(envelope.baseDeliverySequence);
966
+ if (envelope.maxDeliverySequence !== void 0)
967
+ writer.uint32(248).int64(envelope.maxDeliverySequence);
968
+ if (envelope.deliverySequence !== void 0) writer.uint32(256).int64(envelope.deliverySequence);
969
+ if (envelope.serverMaxDeliverySequence !== void 0)
970
+ writer.uint32(264).int64(envelope.serverMaxDeliverySequence);
952
971
  return writer.finish();
953
972
  }
954
973
  function decodeEnvelope(bytes) {
@@ -1044,6 +1063,21 @@ function decodeEnvelope(bytes) {
1044
1063
  case 28:
1045
1064
  envelope.recordId = reader.string();
1046
1065
  break;
1066
+ case 29:
1067
+ envelope.lastDeliverySequence = longToNumber(reader.int64());
1068
+ break;
1069
+ case 30:
1070
+ envelope.baseDeliverySequence = longToNumber(reader.int64());
1071
+ break;
1072
+ case 31:
1073
+ envelope.maxDeliverySequence = longToNumber(reader.int64());
1074
+ break;
1075
+ case 32:
1076
+ envelope.deliverySequence = longToNumber(reader.int64());
1077
+ break;
1078
+ case 33:
1079
+ envelope.serverMaxDeliverySequence = longToNumber(reader.int64());
1080
+ break;
1047
1081
  default:
1048
1082
  reader.skipType(tag & 7);
1049
1083
  }
@@ -1543,6 +1577,7 @@ var ChaosTransport = class {
1543
1577
  reorderRate;
1544
1578
  maxLatency;
1545
1579
  random;
1580
+ dropPredicate;
1546
1581
  messageHandler = null;
1547
1582
  reorderBuffer = [];
1548
1583
  pending = [];
@@ -1553,6 +1588,7 @@ var ChaosTransport = class {
1553
1588
  this.reorderRate = config?.reorderRate ?? 0;
1554
1589
  this.maxLatency = config?.maxLatency ?? 0;
1555
1590
  this.random = config?.randomSource ?? Math.random;
1591
+ this.dropPredicate = config?.dropPredicate ?? null;
1556
1592
  }
1557
1593
  async connect(url, options) {
1558
1594
  this.inner.onMessage((msg) => this.handleIncoming(msg));
@@ -1571,6 +1607,9 @@ var ChaosTransport = class {
1571
1607
  return this.inner.disconnect();
1572
1608
  }
1573
1609
  send(message) {
1610
+ if (this.dropPredicate?.(message, "outgoing")) {
1611
+ return;
1612
+ }
1574
1613
  if (this.random() < this.dropRate) {
1575
1614
  return;
1576
1615
  }
@@ -1598,6 +1637,9 @@ var ChaosTransport = class {
1598
1637
  }
1599
1638
  handleIncoming(message) {
1600
1639
  if (!this.messageHandler) return;
1640
+ if (this.dropPredicate?.(message, "incoming")) {
1641
+ return;
1642
+ }
1601
1643
  if (this.random() < this.dropRate) {
1602
1644
  return;
1603
1645
  }
@@ -2693,6 +2735,7 @@ var OutboundQueue = class {
2693
2735
  // src/engine/sync-engine.ts
2694
2736
  var DEFAULT_BATCH_SIZE = 100;
2695
2737
  var DEFAULT_SCHEMA_VERSION = 1;
2738
+ var DEFAULT_OUTBOUND_ACK_TIMEOUT_MS = 3e4;
2696
2739
  var VALID_TRANSITIONS = {
2697
2740
  disconnected: ["connecting"],
2698
2741
  connecting: ["handshaking", "error", "disconnected"],
@@ -2706,6 +2749,17 @@ var nextQuerySubsetId = 0;
2706
2749
  function generateMessageId() {
2707
2750
  return `msg-${Date.now()}-${nextMessageId++}`;
2708
2751
  }
2752
+ var MAX_DELIVERY_VIEW_WATERMARKS = 64;
2753
+ function stableStringify(value) {
2754
+ if (value === null || typeof value !== "object") {
2755
+ return JSON.stringify(value) ?? "null";
2756
+ }
2757
+ if (Array.isArray(value)) {
2758
+ return `[${value.map(stableStringify).join(",")}]`;
2759
+ }
2760
+ 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)}`);
2761
+ return `{${entries.join(",")}}`;
2762
+ }
2709
2763
  var SyncEngine = class {
2710
2764
  state = "disconnected";
2711
2765
  transport;
@@ -2716,6 +2770,7 @@ var SyncEngine = class {
2716
2770
  outboundQueue;
2717
2771
  rejectedStorage;
2718
2772
  batchSize;
2773
+ outboundAckTimeoutMs;
2719
2774
  encryptor;
2720
2775
  awarenessManager;
2721
2776
  richtextDocChannel;
@@ -2735,6 +2790,9 @@ var SyncEngine = class {
2735
2790
  clockBlocked = false;
2736
2791
  clockSkewMs = null;
2737
2792
  blobStorageEnabled = false;
2793
+ outboundAckTimer = null;
2794
+ startPromise = null;
2795
+ stopPromise = null;
2738
2796
  // Track delta exchange state
2739
2797
  deltaBatchesReceived = 0;
2740
2798
  deltaReceiveComplete = false;
@@ -2755,6 +2813,25 @@ var SyncEngine = class {
2755
2813
  /** Resume cursor for paginated initial sync (persisted across reconnects) */
2756
2814
  resumeDeltaCursor = null;
2757
2815
  initialSyncTotalBatches = 0;
2816
+ /**
2817
+ * The delivery watermark for the CURRENT sync view: the highest server delivery
2818
+ * sequence up to which every operation in the current view (scope + active query
2819
+ * subscriptions) has been contiguously applied. Advanced only through the server's
2820
+ * gap-free delivery stream (never through live relay), so it is a durable lower bound
2821
+ * on what this client holds for this view. Reported at handshake to resume the stream.
2822
+ *
2823
+ * The watermark is keyed by view: switching views (a scope or subscription change)
2824
+ * saves the current view's watermark and loads the target view's, so returning to a
2825
+ * previously-synced view resumes exactly where it left off instead of re-syncing. This
2826
+ * is correct because a view's watermark is a lower bound specific to that view's
2827
+ * filter; a widened view is simply a different (initially unsynced) view.
2828
+ */
2829
+ deliveryWatermark = 0;
2830
+ /**
2831
+ * In-memory cache of watermark per view signature, preloaded from persistence at start
2832
+ * so a view switch resolves synchronously (no race with the reconnect it triggers).
2833
+ */
2834
+ deliverySignatureWatermarks = /* @__PURE__ */ new Map();
2758
2835
  constructor(options) {
2759
2836
  this.transport = options.transport;
2760
2837
  this.store = options.store;
@@ -2762,6 +2839,7 @@ var SyncEngine = class {
2762
2839
  this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
2763
2840
  this.emitter = options.emitter ?? null;
2764
2841
  this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
2842
+ this.outboundAckTimeoutMs = options.config.outboundAckTimeoutMs ?? DEFAULT_OUTBOUND_ACK_TIMEOUT_MS;
2765
2843
  this.encryptor = options.encryptor ?? null;
2766
2844
  this.syncState = options.syncState ?? null;
2767
2845
  this.activeScope = options.config.scopeMap;
@@ -2807,10 +2885,20 @@ var SyncEngine = class {
2807
2885
  * Start the sync engine: connect → handshake → delta exchange → streaming.
2808
2886
  */
2809
2887
  async start() {
2888
+ if (this.state === "streaming") return;
2889
+ if (this.startPromise) return this.startPromise;
2890
+ this.startPromise = this.startInternal().finally(() => {
2891
+ this.startPromise = null;
2892
+ });
2893
+ return this.startPromise;
2894
+ }
2895
+ async startInternal() {
2896
+ if (this.stopPromise) {
2897
+ await this.stopPromise;
2898
+ }
2899
+ if (this.state === "streaming") return;
2810
2900
  if (this.state !== "disconnected") {
2811
- throw new import_core7.SyncError("Cannot start sync engine: not in disconnected state", {
2812
- currentState: this.state
2813
- });
2901
+ await this.stop();
2814
2902
  }
2815
2903
  await this.outboundQueue.initialize();
2816
2904
  if (this.syncState) {
@@ -2818,6 +2906,16 @@ var SyncEngine = class {
2818
2906
  if (this.syncState.loadDeltaCursor) {
2819
2907
  this.resumeDeltaCursor = await this.syncState.loadDeltaCursor();
2820
2908
  }
2909
+ if (this.syncState.loadAllDeliveryWatermarks) {
2910
+ const all = await this.syncState.loadAllDeliveryWatermarks();
2911
+ for (const [signature, watermark] of Object.entries(all)) {
2912
+ this.deliverySignatureWatermarks.set(signature, watermark);
2913
+ }
2914
+ } else if (this.syncState.loadDeliveryWatermark) {
2915
+ this.deliverySignatureWatermarks.set("", await this.syncState.loadDeliveryWatermark(""));
2916
+ }
2917
+ this.deliveryWatermark = this.deliverySignatureWatermarks.get(this.deliverySignature()) ?? 0;
2918
+ this.evictColdViewWatermarks();
2821
2919
  }
2822
2920
  await this.reconcileOutboundFromOpLog();
2823
2921
  await this.refreshPendingCount();
@@ -2833,7 +2931,12 @@ var SyncEngine = class {
2833
2931
  this.transitionTo("connecting");
2834
2932
  try {
2835
2933
  const authToken = this.config.auth ? (await this.config.auth()).token : void 0;
2934
+ if (this.state !== "connecting") return;
2836
2935
  await this.transport.connect(this.config.url, { authToken });
2936
+ if (this.state !== "connecting") {
2937
+ await this.transport.disconnect();
2938
+ return;
2939
+ }
2837
2940
  this.transitionTo("handshaking");
2838
2941
  const localVector = this.store.getVersionVector();
2839
2942
  const activeQuerySubsets = this.getActiveQuerySubsets();
@@ -2847,7 +2950,11 @@ var SyncEngine = class {
2847
2950
  supportedWireFormats: ["json", "protobuf"],
2848
2951
  ...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
2849
2952
  ...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
2850
- ...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {}
2953
+ ...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {},
2954
+ // Resume the server's gap-free delivery stream from our watermark. A server
2955
+ // that understands it drives server->client sync from delivery sequences; an
2956
+ // older server ignores it and falls back to the version-vector delta.
2957
+ lastDeliverySequence: this.deliveryWatermark
2851
2958
  };
2852
2959
  this.transport.send(handshake);
2853
2960
  } catch (err) {
@@ -2859,7 +2966,15 @@ var SyncEngine = class {
2859
2966
  * Stop the sync engine. Disconnects the transport.
2860
2967
  */
2861
2968
  async stop() {
2969
+ if (this.stopPromise) return this.stopPromise;
2970
+ this.stopPromise = this.stopInternal().finally(() => {
2971
+ this.stopPromise = null;
2972
+ });
2973
+ return this.stopPromise;
2974
+ }
2975
+ async stopInternal() {
2862
2976
  if (this.state === "disconnected") return;
2977
+ this.clearOutboundAckTimer();
2863
2978
  this.awarenessManager.stopCleanupTimer();
2864
2979
  if (this.currentBatch) {
2865
2980
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
@@ -2884,7 +2999,7 @@ var SyncEngine = class {
2884
2999
  * because they should remain local-only and not be sent to the server.
2885
3000
  */
2886
3001
  async pushOperation(op) {
2887
- if (!this.operationAllowedForSync(op)) {
3002
+ if (!await this.operationAllowedForSync(op)) {
2888
3003
  return;
2889
3004
  }
2890
3005
  if (this.syncState) {
@@ -3063,8 +3178,84 @@ var SyncEngine = class {
3063
3178
  * @param scopeMap - New per-collection scope filters, or undefined to remove scope
3064
3179
  */
3065
3180
  updateScope(scopeMap) {
3181
+ const previousSignature = this.deliverySignature();
3066
3182
  this.activeScope = scopeMap;
3067
3183
  this.config.scopeMap = scopeMap;
3184
+ this.switchDeliveryView(previousSignature);
3185
+ }
3186
+ /**
3187
+ * A stable identifier for the current sync view: the auth/tenant scope plus the set of
3188
+ * active query subscriptions, canonicalized so the same view always produces the same
3189
+ * string. The delivery watermark is keyed by this, so each distinct view resumes from
3190
+ * its own last position.
3191
+ */
3192
+ deliverySignature() {
3193
+ const subsets = this.getActiveQuerySubsets();
3194
+ if (!this.activeScope && subsets.length === 0) {
3195
+ return "";
3196
+ }
3197
+ const normalizedSubsets = subsets.map((s) => `${s.collection}:${stableStringify(s.where)}`).sort();
3198
+ return stableStringify({ scope: this.activeScope ?? null, subsets: normalizedSubsets });
3199
+ }
3200
+ /**
3201
+ * Switch the active delivery watermark to the current view after a scope or subscription
3202
+ * change. The prior view's watermark is saved so returning to it resumes; the new view's
3203
+ * watermark is restored from the in-memory cache (0 for a never-synced view, which then
3204
+ * does a one-time resync under that view). No data is lost: a view's watermark is a lower
3205
+ * bound for that specific view's filter.
3206
+ */
3207
+ switchDeliveryView(previousSignature) {
3208
+ const nextSignature = this.deliverySignature();
3209
+ if (nextSignature === previousSignature) {
3210
+ return;
3211
+ }
3212
+ this.setViewWatermark(previousSignature, this.deliveryWatermark);
3213
+ void this.persistDeliveryWatermark(this.deliveryWatermark, previousSignature);
3214
+ this.deliveryWatermark = this.deliverySignatureWatermarks.get(nextSignature) ?? 0;
3215
+ }
3216
+ /**
3217
+ * Record a view's watermark in the in-memory cache as the most-recently-used entry (the
3218
+ * Map preserves insertion order, so re-inserting moves it to the tail), then evict cold
3219
+ * views past the retention cap. This is the single place the cache is written, so LRU
3220
+ * order and the size bound are always maintained together.
3221
+ */
3222
+ setViewWatermark(signature, watermark) {
3223
+ this.deliverySignatureWatermarks.delete(signature);
3224
+ this.deliverySignatureWatermarks.set(signature, watermark);
3225
+ this.evictColdViewWatermarks();
3226
+ }
3227
+ /**
3228
+ * Trim the per-view watermark cache to `MAX_DELIVERY_VIEW_WATERMARKS`, evicting the
3229
+ * least-recently-used views. The default view ('') and the live view are never evicted
3230
+ * (evicting the live view would force an immediate re-scan of what we are actively
3231
+ * syncing). Each eviction also removes the persisted row, bounding storage. Safe by
3232
+ * construction: an evicted view simply back-fills from 0 (deduplicated) when next seen.
3233
+ */
3234
+ evictColdViewWatermarks() {
3235
+ if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
3236
+ return;
3237
+ }
3238
+ const liveSignature = this.deliverySignature();
3239
+ for (const signature of this.deliverySignatureWatermarks.keys()) {
3240
+ if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
3241
+ break;
3242
+ }
3243
+ if (signature === "" || signature === liveSignature) {
3244
+ continue;
3245
+ }
3246
+ this.deliverySignatureWatermarks.delete(signature);
3247
+ void this.deleteDeliveryWatermark(signature);
3248
+ }
3249
+ }
3250
+ /**
3251
+ * Remove a view's persisted watermark row (best-effort; a persistence layer that omits
3252
+ * the delete simply keeps the row, which is harmless).
3253
+ */
3254
+ async deleteDeliveryWatermark(signature) {
3255
+ if (!this.syncState?.deleteDeliveryWatermark) {
3256
+ return;
3257
+ }
3258
+ await this.syncState.deleteDeliveryWatermark(signature);
3068
3259
  }
3069
3260
  /**
3070
3261
  * Get the currently active scope map. Returns undefined if no scope is configured.
@@ -3078,10 +3269,14 @@ var SyncEngine = class {
3078
3269
  */
3079
3270
  registerQuerySubset(subset) {
3080
3271
  const id = `query-${nextQuerySubsetId++}`;
3272
+ const previousSignature = this.deliverySignature();
3081
3273
  this.querySubsets.set(id, subset);
3274
+ this.switchDeliveryView(previousSignature);
3082
3275
  this.scheduleQuerySubsetReconnect();
3083
3276
  return () => {
3277
+ const sigBeforeRemove = this.deliverySignature();
3084
3278
  this.querySubsets.delete(id);
3279
+ this.switchDeliveryView(sigBeforeRemove);
3085
3280
  this.scheduleQuerySubsetReconnect();
3086
3281
  };
3087
3282
  }
@@ -3234,6 +3429,11 @@ var SyncEngine = class {
3234
3429
  }
3235
3430
  this.remoteVector = wireToVersionVector(msg.versionVector);
3236
3431
  void this.persistLastAckedServerVector(this.remoteVector);
3432
+ if (typeof msg.serverMaxDeliverySequence === "number" && this.deliveryWatermark > msg.serverMaxDeliverySequence) {
3433
+ this.deliveryWatermark = 0;
3434
+ this.setViewWatermark(this.deliverySignature(), 0);
3435
+ await this.persistDeliveryWatermark(0);
3436
+ }
3237
3437
  if (msg.selectedWireFormat) {
3238
3438
  this.setSerializerWireFormat(msg.selectedWireFormat);
3239
3439
  }
@@ -3299,7 +3499,7 @@ var SyncEngine = class {
3299
3499
  async sendDelta() {
3300
3500
  const localVector = this.store.getVersionVector();
3301
3501
  const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
3302
- const missingOps = allMissingOps.filter((op) => this.operationAllowedForSync(op));
3502
+ const missingOps = await this.filterAllowedForSync(allMissingOps);
3303
3503
  this.deltaSentOpIds = missingOps.map((op) => op.id);
3304
3504
  if (missingOps.length === 0) {
3305
3505
  const messageId = generateMessageId();
@@ -3374,11 +3574,23 @@ var SyncEngine = class {
3374
3574
  return missing;
3375
3575
  }
3376
3576
  async handleOperationBatch(msg) {
3577
+ const isDeliveryBatch = msg.maxDeliverySequence !== void 0;
3578
+ if (isDeliveryBatch) {
3579
+ const base = msg.baseDeliverySequence ?? 0;
3580
+ if (base > this.deliveryWatermark) {
3581
+ return;
3582
+ }
3583
+ if (base < this.deliveryWatermark) {
3584
+ this.sendDeliveryAck(msg.messageId, this.deliveryWatermark);
3585
+ return;
3586
+ }
3587
+ }
3377
3588
  const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
3378
3589
  const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
3379
- const inScopeOps = operations.filter((op) => this.operationAllowedForSync(op));
3590
+ const inScopeOps = await this.filterAllowedForSync(operations);
3380
3591
  const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
3381
3592
  const transforms = this.config.operationTransforms ?? [];
3593
+ let fullyApplied = true;
3382
3594
  for (const op of inScopeOps) {
3383
3595
  const transformed = transforms.length > 0 ? (0, import_core7.applyOperationTransforms)(op, targetSchemaVersion, transforms) : op;
3384
3596
  if (transformed === null) {
@@ -3390,6 +3602,7 @@ var SyncEngine = class {
3390
3602
  this.emitApplyFailure(transformed, result);
3391
3603
  }
3392
3604
  } catch (error) {
3605
+ fullyApplied = false;
3393
3606
  if (error instanceof import_core7.ClockDriftError) {
3394
3607
  this.emitApplyFailure(transformed, "rejected", {
3395
3608
  code: import_core7.APPLY_FAILURE_CODES.CLOCK_DRIFT,
@@ -3400,11 +3613,8 @@ var SyncEngine = class {
3400
3613
  }
3401
3614
  const message = error instanceof Error ? error.message : "Apply failed";
3402
3615
  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;
3403
- this.emitApplyFailure(transformed, "rejected", {
3404
- code,
3405
- message,
3406
- retriable: code !== import_core7.APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY
3407
- });
3616
+ const retriable = code !== import_core7.APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY;
3617
+ this.emitApplyFailure(transformed, "rejected", { code, message, retriable });
3408
3618
  }
3409
3619
  }
3410
3620
  if (inScopeOps.length > 0) {
@@ -3415,18 +3625,27 @@ var SyncEngine = class {
3415
3625
  batchSize: inScopeOps.length
3416
3626
  });
3417
3627
  }
3418
- const lastOp = operations[operations.length - 1];
3419
- const ack = {
3420
- type: "acknowledgment",
3421
- messageId: generateMessageId(),
3422
- acknowledgedMessageId: msg.messageId,
3423
- lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3424
- };
3425
- this.transport.send(ack);
3426
- this.emitter?.emit({
3427
- type: "sync:acknowledged",
3428
- sequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3429
- });
3628
+ const deliveryFullyApplied = isDeliveryBatch && fullyApplied;
3629
+ if (!isDeliveryBatch || deliveryFullyApplied) {
3630
+ const lastOp = operations[operations.length - 1];
3631
+ const ack = {
3632
+ type: "acknowledgment",
3633
+ messageId: generateMessageId(),
3634
+ acknowledgedMessageId: msg.messageId,
3635
+ lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0,
3636
+ ...deliveryFullyApplied && msg.maxDeliverySequence !== void 0 ? { deliverySequence: msg.maxDeliverySequence } : {}
3637
+ };
3638
+ this.transport.send(ack);
3639
+ this.emitter?.emit({
3640
+ type: "sync:acknowledged",
3641
+ sequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3642
+ });
3643
+ }
3644
+ if (isDeliveryBatch && fullyApplied && msg.maxDeliverySequence !== void 0) {
3645
+ this.deliveryWatermark = msg.maxDeliverySequence;
3646
+ this.setViewWatermark(this.deliverySignature(), this.deliveryWatermark);
3647
+ await this.persistDeliveryWatermark(this.deliveryWatermark);
3648
+ }
3430
3649
  if (this.state === "syncing") {
3431
3650
  this.deltaBatchesReceived++;
3432
3651
  const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
@@ -3434,15 +3653,17 @@ var SyncEngine = class {
3434
3653
  this.initialSyncTotalBatches = msg.totalBatches;
3435
3654
  }
3436
3655
  this.metricsCollector.updateInitialSyncProgress(this.deltaBatchesReceived, totalBatches);
3437
- const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
3438
- inScopeOps.length > 0 ? inScopeOps : operations,
3439
- msg.batchIndex
3440
- );
3441
- if (cursorFromBatch) {
3442
- this.resumeDeltaCursor = cursorFromBatch;
3443
- await this.persistDeltaCursor(cursorFromBatch);
3656
+ if (!isDeliveryBatch) {
3657
+ const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
3658
+ inScopeOps.length > 0 ? inScopeOps : operations,
3659
+ msg.batchIndex
3660
+ );
3661
+ if (cursorFromBatch) {
3662
+ this.resumeDeltaCursor = cursorFromBatch;
3663
+ await this.persistDeltaCursor(cursorFromBatch);
3664
+ }
3444
3665
  }
3445
- if (msg.isFinal) {
3666
+ if (msg.isFinal && (!isDeliveryBatch || fullyApplied)) {
3446
3667
  this.deltaReceiveComplete = true;
3447
3668
  this.resumeDeltaCursor = null;
3448
3669
  await this.persistDeltaCursor(null);
@@ -3451,7 +3672,19 @@ var SyncEngine = class {
3451
3672
  }
3452
3673
  }
3453
3674
  }
3675
+ /** Acknowledge a delivery batch (used for duplicates) without re-applying it. */
3676
+ sendDeliveryAck(acknowledgedMessageId, deliverySequence) {
3677
+ const ack = {
3678
+ type: "acknowledgment",
3679
+ messageId: generateMessageId(),
3680
+ acknowledgedMessageId,
3681
+ lastSequenceNumber: 0,
3682
+ deliverySequence
3683
+ };
3684
+ this.transport.send(ack);
3685
+ }
3454
3686
  handleAcknowledgment(msg) {
3687
+ this.clearOutboundAckTimer();
3455
3688
  if (this.state === "syncing" && this.config.strictHandshake) {
3456
3689
  this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
3457
3690
  this.markDeltaSendCompleteIfReady();
@@ -3471,6 +3704,11 @@ var SyncEngine = class {
3471
3704
  }
3472
3705
  }
3473
3706
  handleError(msg) {
3707
+ this.clearOutboundAckTimer();
3708
+ if (this.currentBatch) {
3709
+ this.outboundQueue.returnBatch(this.currentBatch.batchId);
3710
+ this.currentBatch = null;
3711
+ }
3474
3712
  this.transitionTo("error");
3475
3713
  if (msg.code === "AUTH_FAILED") {
3476
3714
  this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
@@ -3631,7 +3869,7 @@ var SyncEngine = class {
3631
3869
  return;
3632
3870
  }
3633
3871
  const ops = await this.store.getOperationRange(localNodeId, 1, localSeq);
3634
- const inScope = ops.filter((op) => this.operationAllowedForSync(op));
3872
+ const inScope = await this.filterAllowedForSync(ops);
3635
3873
  for (const op of inScope) {
3636
3874
  await this.outboundQueue.enqueue(op);
3637
3875
  }
@@ -3654,6 +3892,7 @@ var SyncEngine = class {
3654
3892
  batchIndex: 0
3655
3893
  };
3656
3894
  this.transport.send(batchMsg);
3895
+ this.startOutboundAckTimer(batch.batchId);
3657
3896
  this.emitter?.emit({
3658
3897
  type: "sync:sent",
3659
3898
  operations: batch.operations,
@@ -3679,6 +3918,7 @@ var SyncEngine = class {
3679
3918
  batchIndex: 0
3680
3919
  };
3681
3920
  this.transport.send(batchMsg);
3921
+ this.startOutboundAckTimer(batch.batchId);
3682
3922
  this.emitter?.emit({
3683
3923
  type: "sync:sent",
3684
3924
  operations: batch.operations,
@@ -3686,6 +3926,33 @@ var SyncEngine = class {
3686
3926
  });
3687
3927
  }
3688
3928
  }
3929
+ startOutboundAckTimer(batchId) {
3930
+ this.clearOutboundAckTimer();
3931
+ if (this.outboundAckTimeoutMs <= 0) return;
3932
+ this.outboundAckTimer = setTimeout(() => {
3933
+ void this.handleOutboundAckTimeout(batchId);
3934
+ }, this.outboundAckTimeoutMs);
3935
+ }
3936
+ clearOutboundAckTimer() {
3937
+ if (!this.outboundAckTimer) return;
3938
+ clearTimeout(this.outboundAckTimer);
3939
+ this.outboundAckTimer = null;
3940
+ }
3941
+ async handleOutboundAckTimeout(batchId) {
3942
+ if (!this.currentBatch || this.currentBatch.batchId !== batchId) return;
3943
+ this.outboundQueue.returnBatch(batchId);
3944
+ this.currentBatch = null;
3945
+ this.clearOutboundAckTimer();
3946
+ await this.refreshPendingCount();
3947
+ if (this.state === "disconnected") return;
3948
+ const reason = `Timed out waiting for acknowledgment of outbound batch ${batchId}`;
3949
+ try {
3950
+ await this.transport.disconnect();
3951
+ } finally {
3952
+ this.ensureDisconnected();
3953
+ this.emitter?.emit({ type: "sync:disconnected", reason });
3954
+ }
3955
+ }
3689
3956
  handleAwarenessUpdate(msg) {
3690
3957
  const awarenessMessage = {
3691
3958
  type: "awareness",
@@ -3695,6 +3962,7 @@ var SyncEngine = class {
3695
3962
  this.awarenessManager.handleRemoteMessage(awarenessMessage);
3696
3963
  }
3697
3964
  handleTransportClose(reason) {
3965
+ this.clearOutboundAckTimer();
3698
3966
  if (this.currentBatch) {
3699
3967
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
3700
3968
  this.currentBatch = null;
@@ -3729,11 +3997,40 @@ var SyncEngine = class {
3729
3997
  this.serializer.setWireFormat(format);
3730
3998
  }
3731
3999
  }
3732
- operationAllowedForSync(op) {
3733
- if (!operationMatchesScope(op, this.activeScope)) {
4000
+ async operationAllowedForSync(op) {
4001
+ if (this.matchesScopeAndSubsets(op)) {
4002
+ return true;
4003
+ }
4004
+ const fullRecord = await this.readRecordForBackfill(op);
4005
+ if (!fullRecord) {
3734
4006
  return false;
3735
4007
  }
3736
- return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets());
4008
+ return this.matchesScopeAndSubsets(op, fullRecord);
4009
+ }
4010
+ matchesScopeAndSubsets(op, fullRecord) {
4011
+ if (!operationMatchesScope(op, this.activeScope, fullRecord)) {
4012
+ return false;
4013
+ }
4014
+ return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets(), fullRecord);
4015
+ }
4016
+ async readRecordForBackfill(op) {
4017
+ if (!this.store.readRecordFields) {
4018
+ return null;
4019
+ }
4020
+ try {
4021
+ return await this.store.readRecordFields(op.collection, op.recordId);
4022
+ } catch {
4023
+ return null;
4024
+ }
4025
+ }
4026
+ async filterAllowedForSync(ops) {
4027
+ const allowed = [];
4028
+ for (const op of ops) {
4029
+ if (await this.operationAllowedForSync(op)) {
4030
+ allowed.push(op);
4031
+ }
4032
+ }
4033
+ return allowed;
3737
4034
  }
3738
4035
  async persistDeltaCursor(cursor) {
3739
4036
  if (!this.syncState?.saveDeltaCursor) {
@@ -3741,6 +4038,12 @@ var SyncEngine = class {
3741
4038
  }
3742
4039
  await this.syncState.saveDeltaCursor(cursor);
3743
4040
  }
4041
+ async persistDeliveryWatermark(watermark, signature = this.deliverySignature()) {
4042
+ if (!this.syncState?.saveDeliveryWatermark) {
4043
+ return;
4044
+ }
4045
+ await this.syncState.saveDeliveryWatermark(signature, watermark);
4046
+ }
3744
4047
  scheduleQuerySubsetReconnect() {
3745
4048
  if (this.querySubsetReconnectTimer) {
3746
4049
  clearTimeout(this.querySubsetReconnectTimer);