@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.js CHANGED
@@ -528,7 +528,8 @@ function toProtoEnvelope(message) {
528
528
  })),
529
529
  schemaVersion: message.schemaVersion,
530
530
  authToken: message.authToken,
531
- supportedWireFormats: message.supportedWireFormats
531
+ supportedWireFormats: message.supportedWireFormats,
532
+ ...message.lastDeliverySequence !== void 0 ? { lastDeliverySequence: message.lastDeliverySequence } : {}
532
533
  };
533
534
  case "handshake-response":
534
535
  return {
@@ -544,7 +545,8 @@ function toProtoEnvelope(message) {
544
545
  rejectReason: message.rejectReason,
545
546
  selectedWireFormat: message.selectedWireFormat,
546
547
  serverTime: message.serverTime,
547
- blobStorageEnabled: message.blobStorageEnabled
548
+ blobStorageEnabled: message.blobStorageEnabled,
549
+ ...message.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: message.serverMaxDeliverySequence } : {}
548
550
  };
549
551
  case "operation-batch":
550
552
  return {
@@ -552,14 +554,17 @@ function toProtoEnvelope(message) {
552
554
  messageId: message.messageId,
553
555
  operations: message.operations.map(serializeProtoOperation),
554
556
  isFinal: message.isFinal,
555
- batchIndex: message.batchIndex
557
+ batchIndex: message.batchIndex,
558
+ ...message.baseDeliverySequence !== void 0 ? { baseDeliverySequence: message.baseDeliverySequence } : {},
559
+ ...message.maxDeliverySequence !== void 0 ? { maxDeliverySequence: message.maxDeliverySequence } : {}
556
560
  };
557
561
  case "acknowledgment":
558
562
  return {
559
563
  type: message.type,
560
564
  messageId: message.messageId,
561
565
  acknowledgedMessageId: message.acknowledgedMessageId,
562
- lastSequenceNumber: message.lastSequenceNumber
566
+ lastSequenceNumber: message.lastSequenceNumber,
567
+ ...message.deliverySequence !== void 0 ? { deliverySequence: message.deliverySequence } : {}
563
568
  };
564
569
  case "error":
565
570
  return {
@@ -624,7 +629,8 @@ function fromProtoEnvelope(envelope) {
624
629
  authToken: envelope.authToken,
625
630
  supportedWireFormats: envelope.supportedWireFormats?.filter(
626
631
  (format) => format === "json" || format === "protobuf"
627
- )
632
+ ),
633
+ ...envelope.lastDeliverySequence !== void 0 ? { lastDeliverySequence: envelope.lastDeliverySequence } : {}
628
634
  };
629
635
  case "handshake-response":
630
636
  return {
@@ -641,7 +647,8 @@ function fromProtoEnvelope(envelope) {
641
647
  // Preserve the server's wall-clock time so clock-skew detection and
642
648
  // automatic timestamp rebase work over the protobuf wire, not just JSON.
643
649
  ...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {},
644
- ...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {}
650
+ ...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {},
651
+ ...envelope.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: envelope.serverMaxDeliverySequence } : {}
645
652
  };
646
653
  case "operation-batch":
647
654
  return {
@@ -649,14 +656,17 @@ function fromProtoEnvelope(envelope) {
649
656
  messageId: envelope.messageId,
650
657
  operations: (envelope.operations ?? []).map(deserializeProtoOperation),
651
658
  isFinal: envelope.isFinal ?? false,
652
- batchIndex: envelope.batchIndex ?? 0
659
+ batchIndex: envelope.batchIndex ?? 0,
660
+ ...envelope.baseDeliverySequence !== void 0 ? { baseDeliverySequence: envelope.baseDeliverySequence } : {},
661
+ ...envelope.maxDeliverySequence !== void 0 ? { maxDeliverySequence: envelope.maxDeliverySequence } : {}
653
662
  };
654
663
  case "acknowledgment":
655
664
  return {
656
665
  type: "acknowledgment",
657
666
  messageId: envelope.messageId,
658
667
  acknowledgedMessageId: envelope.acknowledgedMessageId ?? "",
659
- lastSequenceNumber: envelope.lastSequenceNumber ?? 0
668
+ lastSequenceNumber: envelope.lastSequenceNumber ?? 0,
669
+ ...envelope.deliverySequence !== void 0 ? { deliverySequence: envelope.deliverySequence } : {}
660
670
  };
661
671
  case "error":
662
672
  return {
@@ -857,6 +867,15 @@ function encodeEnvelope(envelope) {
857
867
  writer.uint32(218).string(envelope.collection);
858
868
  if (envelope.recordId && envelope.recordId.length > 0)
859
869
  writer.uint32(226).string(envelope.recordId);
870
+ if (envelope.lastDeliverySequence !== void 0)
871
+ writer.uint32(232).int64(envelope.lastDeliverySequence);
872
+ if (envelope.baseDeliverySequence !== void 0)
873
+ writer.uint32(240).int64(envelope.baseDeliverySequence);
874
+ if (envelope.maxDeliverySequence !== void 0)
875
+ writer.uint32(248).int64(envelope.maxDeliverySequence);
876
+ if (envelope.deliverySequence !== void 0) writer.uint32(256).int64(envelope.deliverySequence);
877
+ if (envelope.serverMaxDeliverySequence !== void 0)
878
+ writer.uint32(264).int64(envelope.serverMaxDeliverySequence);
860
879
  return writer.finish();
861
880
  }
862
881
  function decodeEnvelope(bytes) {
@@ -952,6 +971,21 @@ function decodeEnvelope(bytes) {
952
971
  case 28:
953
972
  envelope.recordId = reader.string();
954
973
  break;
974
+ case 29:
975
+ envelope.lastDeliverySequence = longToNumber(reader.int64());
976
+ break;
977
+ case 30:
978
+ envelope.baseDeliverySequence = longToNumber(reader.int64());
979
+ break;
980
+ case 31:
981
+ envelope.maxDeliverySequence = longToNumber(reader.int64());
982
+ break;
983
+ case 32:
984
+ envelope.deliverySequence = longToNumber(reader.int64());
985
+ break;
986
+ case 33:
987
+ envelope.serverMaxDeliverySequence = longToNumber(reader.int64());
988
+ break;
955
989
  default:
956
990
  reader.skipType(tag & 7);
957
991
  }
@@ -1451,6 +1485,7 @@ var ChaosTransport = class {
1451
1485
  reorderRate;
1452
1486
  maxLatency;
1453
1487
  random;
1488
+ dropPredicate;
1454
1489
  messageHandler = null;
1455
1490
  reorderBuffer = [];
1456
1491
  pending = [];
@@ -1461,6 +1496,7 @@ var ChaosTransport = class {
1461
1496
  this.reorderRate = config?.reorderRate ?? 0;
1462
1497
  this.maxLatency = config?.maxLatency ?? 0;
1463
1498
  this.random = config?.randomSource ?? Math.random;
1499
+ this.dropPredicate = config?.dropPredicate ?? null;
1464
1500
  }
1465
1501
  async connect(url, options) {
1466
1502
  this.inner.onMessage((msg) => this.handleIncoming(msg));
@@ -1479,6 +1515,9 @@ var ChaosTransport = class {
1479
1515
  return this.inner.disconnect();
1480
1516
  }
1481
1517
  send(message) {
1518
+ if (this.dropPredicate?.(message, "outgoing")) {
1519
+ return;
1520
+ }
1482
1521
  if (this.random() < this.dropRate) {
1483
1522
  return;
1484
1523
  }
@@ -1506,6 +1545,9 @@ var ChaosTransport = class {
1506
1545
  }
1507
1546
  handleIncoming(message) {
1508
1547
  if (!this.messageHandler) return;
1548
+ if (this.dropPredicate?.(message, "incoming")) {
1549
+ return;
1550
+ }
1509
1551
  if (this.random() < this.dropRate) {
1510
1552
  return;
1511
1553
  }
@@ -2589,6 +2631,7 @@ var OutboundQueue = class {
2589
2631
  // src/engine/sync-engine.ts
2590
2632
  var DEFAULT_BATCH_SIZE = 100;
2591
2633
  var DEFAULT_SCHEMA_VERSION = 1;
2634
+ var DEFAULT_OUTBOUND_ACK_TIMEOUT_MS = 3e4;
2592
2635
  var VALID_TRANSITIONS = {
2593
2636
  disconnected: ["connecting"],
2594
2637
  connecting: ["handshaking", "error", "disconnected"],
@@ -2602,6 +2645,17 @@ var nextQuerySubsetId = 0;
2602
2645
  function generateMessageId() {
2603
2646
  return `msg-${Date.now()}-${nextMessageId++}`;
2604
2647
  }
2648
+ var MAX_DELIVERY_VIEW_WATERMARKS = 64;
2649
+ function stableStringify(value) {
2650
+ if (value === null || typeof value !== "object") {
2651
+ return JSON.stringify(value) ?? "null";
2652
+ }
2653
+ if (Array.isArray(value)) {
2654
+ return `[${value.map(stableStringify).join(",")}]`;
2655
+ }
2656
+ 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)}`);
2657
+ return `{${entries.join(",")}}`;
2658
+ }
2605
2659
  var SyncEngine = class {
2606
2660
  state = "disconnected";
2607
2661
  transport;
@@ -2612,6 +2666,7 @@ var SyncEngine = class {
2612
2666
  outboundQueue;
2613
2667
  rejectedStorage;
2614
2668
  batchSize;
2669
+ outboundAckTimeoutMs;
2615
2670
  encryptor;
2616
2671
  awarenessManager;
2617
2672
  richtextDocChannel;
@@ -2631,6 +2686,9 @@ var SyncEngine = class {
2631
2686
  clockBlocked = false;
2632
2687
  clockSkewMs = null;
2633
2688
  blobStorageEnabled = false;
2689
+ outboundAckTimer = null;
2690
+ startPromise = null;
2691
+ stopPromise = null;
2634
2692
  // Track delta exchange state
2635
2693
  deltaBatchesReceived = 0;
2636
2694
  deltaReceiveComplete = false;
@@ -2651,6 +2709,25 @@ var SyncEngine = class {
2651
2709
  /** Resume cursor for paginated initial sync (persisted across reconnects) */
2652
2710
  resumeDeltaCursor = null;
2653
2711
  initialSyncTotalBatches = 0;
2712
+ /**
2713
+ * The delivery watermark for the CURRENT sync view: the highest server delivery
2714
+ * sequence up to which every operation in the current view (scope + active query
2715
+ * subscriptions) has been contiguously applied. Advanced only through the server's
2716
+ * gap-free delivery stream (never through live relay), so it is a durable lower bound
2717
+ * on what this client holds for this view. Reported at handshake to resume the stream.
2718
+ *
2719
+ * The watermark is keyed by view: switching views (a scope or subscription change)
2720
+ * saves the current view's watermark and loads the target view's, so returning to a
2721
+ * previously-synced view resumes exactly where it left off instead of re-syncing. This
2722
+ * is correct because a view's watermark is a lower bound specific to that view's
2723
+ * filter; a widened view is simply a different (initially unsynced) view.
2724
+ */
2725
+ deliveryWatermark = 0;
2726
+ /**
2727
+ * In-memory cache of watermark per view signature, preloaded from persistence at start
2728
+ * so a view switch resolves synchronously (no race with the reconnect it triggers).
2729
+ */
2730
+ deliverySignatureWatermarks = /* @__PURE__ */ new Map();
2654
2731
  constructor(options) {
2655
2732
  this.transport = options.transport;
2656
2733
  this.store = options.store;
@@ -2658,6 +2735,7 @@ var SyncEngine = class {
2658
2735
  this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
2659
2736
  this.emitter = options.emitter ?? null;
2660
2737
  this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
2738
+ this.outboundAckTimeoutMs = options.config.outboundAckTimeoutMs ?? DEFAULT_OUTBOUND_ACK_TIMEOUT_MS;
2661
2739
  this.encryptor = options.encryptor ?? null;
2662
2740
  this.syncState = options.syncState ?? null;
2663
2741
  this.activeScope = options.config.scopeMap;
@@ -2703,10 +2781,20 @@ var SyncEngine = class {
2703
2781
  * Start the sync engine: connect → handshake → delta exchange → streaming.
2704
2782
  */
2705
2783
  async start() {
2784
+ if (this.state === "streaming") return;
2785
+ if (this.startPromise) return this.startPromise;
2786
+ this.startPromise = this.startInternal().finally(() => {
2787
+ this.startPromise = null;
2788
+ });
2789
+ return this.startPromise;
2790
+ }
2791
+ async startInternal() {
2792
+ if (this.stopPromise) {
2793
+ await this.stopPromise;
2794
+ }
2795
+ if (this.state === "streaming") return;
2706
2796
  if (this.state !== "disconnected") {
2707
- throw new SyncError4("Cannot start sync engine: not in disconnected state", {
2708
- currentState: this.state
2709
- });
2797
+ await this.stop();
2710
2798
  }
2711
2799
  await this.outboundQueue.initialize();
2712
2800
  if (this.syncState) {
@@ -2714,6 +2802,16 @@ var SyncEngine = class {
2714
2802
  if (this.syncState.loadDeltaCursor) {
2715
2803
  this.resumeDeltaCursor = await this.syncState.loadDeltaCursor();
2716
2804
  }
2805
+ if (this.syncState.loadAllDeliveryWatermarks) {
2806
+ const all = await this.syncState.loadAllDeliveryWatermarks();
2807
+ for (const [signature, watermark] of Object.entries(all)) {
2808
+ this.deliverySignatureWatermarks.set(signature, watermark);
2809
+ }
2810
+ } else if (this.syncState.loadDeliveryWatermark) {
2811
+ this.deliverySignatureWatermarks.set("", await this.syncState.loadDeliveryWatermark(""));
2812
+ }
2813
+ this.deliveryWatermark = this.deliverySignatureWatermarks.get(this.deliverySignature()) ?? 0;
2814
+ this.evictColdViewWatermarks();
2717
2815
  }
2718
2816
  await this.reconcileOutboundFromOpLog();
2719
2817
  await this.refreshPendingCount();
@@ -2729,7 +2827,12 @@ var SyncEngine = class {
2729
2827
  this.transitionTo("connecting");
2730
2828
  try {
2731
2829
  const authToken = this.config.auth ? (await this.config.auth()).token : void 0;
2830
+ if (this.state !== "connecting") return;
2732
2831
  await this.transport.connect(this.config.url, { authToken });
2832
+ if (this.state !== "connecting") {
2833
+ await this.transport.disconnect();
2834
+ return;
2835
+ }
2733
2836
  this.transitionTo("handshaking");
2734
2837
  const localVector = this.store.getVersionVector();
2735
2838
  const activeQuerySubsets = this.getActiveQuerySubsets();
@@ -2743,7 +2846,11 @@ var SyncEngine = class {
2743
2846
  supportedWireFormats: ["json", "protobuf"],
2744
2847
  ...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
2745
2848
  ...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
2746
- ...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {}
2849
+ ...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {},
2850
+ // Resume the server's gap-free delivery stream from our watermark. A server
2851
+ // that understands it drives server->client sync from delivery sequences; an
2852
+ // older server ignores it and falls back to the version-vector delta.
2853
+ lastDeliverySequence: this.deliveryWatermark
2747
2854
  };
2748
2855
  this.transport.send(handshake);
2749
2856
  } catch (err) {
@@ -2755,7 +2862,15 @@ var SyncEngine = class {
2755
2862
  * Stop the sync engine. Disconnects the transport.
2756
2863
  */
2757
2864
  async stop() {
2865
+ if (this.stopPromise) return this.stopPromise;
2866
+ this.stopPromise = this.stopInternal().finally(() => {
2867
+ this.stopPromise = null;
2868
+ });
2869
+ return this.stopPromise;
2870
+ }
2871
+ async stopInternal() {
2758
2872
  if (this.state === "disconnected") return;
2873
+ this.clearOutboundAckTimer();
2759
2874
  this.awarenessManager.stopCleanupTimer();
2760
2875
  if (this.currentBatch) {
2761
2876
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
@@ -2780,7 +2895,7 @@ var SyncEngine = class {
2780
2895
  * because they should remain local-only and not be sent to the server.
2781
2896
  */
2782
2897
  async pushOperation(op) {
2783
- if (!this.operationAllowedForSync(op)) {
2898
+ if (!await this.operationAllowedForSync(op)) {
2784
2899
  return;
2785
2900
  }
2786
2901
  if (this.syncState) {
@@ -2959,8 +3074,84 @@ var SyncEngine = class {
2959
3074
  * @param scopeMap - New per-collection scope filters, or undefined to remove scope
2960
3075
  */
2961
3076
  updateScope(scopeMap) {
3077
+ const previousSignature = this.deliverySignature();
2962
3078
  this.activeScope = scopeMap;
2963
3079
  this.config.scopeMap = scopeMap;
3080
+ this.switchDeliveryView(previousSignature);
3081
+ }
3082
+ /**
3083
+ * A stable identifier for the current sync view: the auth/tenant scope plus the set of
3084
+ * active query subscriptions, canonicalized so the same view always produces the same
3085
+ * string. The delivery watermark is keyed by this, so each distinct view resumes from
3086
+ * its own last position.
3087
+ */
3088
+ deliverySignature() {
3089
+ const subsets = this.getActiveQuerySubsets();
3090
+ if (!this.activeScope && subsets.length === 0) {
3091
+ return "";
3092
+ }
3093
+ const normalizedSubsets = subsets.map((s) => `${s.collection}:${stableStringify(s.where)}`).sort();
3094
+ return stableStringify({ scope: this.activeScope ?? null, subsets: normalizedSubsets });
3095
+ }
3096
+ /**
3097
+ * Switch the active delivery watermark to the current view after a scope or subscription
3098
+ * change. The prior view's watermark is saved so returning to it resumes; the new view's
3099
+ * watermark is restored from the in-memory cache (0 for a never-synced view, which then
3100
+ * does a one-time resync under that view). No data is lost: a view's watermark is a lower
3101
+ * bound for that specific view's filter.
3102
+ */
3103
+ switchDeliveryView(previousSignature) {
3104
+ const nextSignature = this.deliverySignature();
3105
+ if (nextSignature === previousSignature) {
3106
+ return;
3107
+ }
3108
+ this.setViewWatermark(previousSignature, this.deliveryWatermark);
3109
+ void this.persistDeliveryWatermark(this.deliveryWatermark, previousSignature);
3110
+ this.deliveryWatermark = this.deliverySignatureWatermarks.get(nextSignature) ?? 0;
3111
+ }
3112
+ /**
3113
+ * Record a view's watermark in the in-memory cache as the most-recently-used entry (the
3114
+ * Map preserves insertion order, so re-inserting moves it to the tail), then evict cold
3115
+ * views past the retention cap. This is the single place the cache is written, so LRU
3116
+ * order and the size bound are always maintained together.
3117
+ */
3118
+ setViewWatermark(signature, watermark) {
3119
+ this.deliverySignatureWatermarks.delete(signature);
3120
+ this.deliverySignatureWatermarks.set(signature, watermark);
3121
+ this.evictColdViewWatermarks();
3122
+ }
3123
+ /**
3124
+ * Trim the per-view watermark cache to `MAX_DELIVERY_VIEW_WATERMARKS`, evicting the
3125
+ * least-recently-used views. The default view ('') and the live view are never evicted
3126
+ * (evicting the live view would force an immediate re-scan of what we are actively
3127
+ * syncing). Each eviction also removes the persisted row, bounding storage. Safe by
3128
+ * construction: an evicted view simply back-fills from 0 (deduplicated) when next seen.
3129
+ */
3130
+ evictColdViewWatermarks() {
3131
+ if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
3132
+ return;
3133
+ }
3134
+ const liveSignature = this.deliverySignature();
3135
+ for (const signature of this.deliverySignatureWatermarks.keys()) {
3136
+ if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
3137
+ break;
3138
+ }
3139
+ if (signature === "" || signature === liveSignature) {
3140
+ continue;
3141
+ }
3142
+ this.deliverySignatureWatermarks.delete(signature);
3143
+ void this.deleteDeliveryWatermark(signature);
3144
+ }
3145
+ }
3146
+ /**
3147
+ * Remove a view's persisted watermark row (best-effort; a persistence layer that omits
3148
+ * the delete simply keeps the row, which is harmless).
3149
+ */
3150
+ async deleteDeliveryWatermark(signature) {
3151
+ if (!this.syncState?.deleteDeliveryWatermark) {
3152
+ return;
3153
+ }
3154
+ await this.syncState.deleteDeliveryWatermark(signature);
2964
3155
  }
2965
3156
  /**
2966
3157
  * Get the currently active scope map. Returns undefined if no scope is configured.
@@ -2974,10 +3165,14 @@ var SyncEngine = class {
2974
3165
  */
2975
3166
  registerQuerySubset(subset) {
2976
3167
  const id = `query-${nextQuerySubsetId++}`;
3168
+ const previousSignature = this.deliverySignature();
2977
3169
  this.querySubsets.set(id, subset);
3170
+ this.switchDeliveryView(previousSignature);
2978
3171
  this.scheduleQuerySubsetReconnect();
2979
3172
  return () => {
3173
+ const sigBeforeRemove = this.deliverySignature();
2980
3174
  this.querySubsets.delete(id);
3175
+ this.switchDeliveryView(sigBeforeRemove);
2981
3176
  this.scheduleQuerySubsetReconnect();
2982
3177
  };
2983
3178
  }
@@ -3130,6 +3325,11 @@ var SyncEngine = class {
3130
3325
  }
3131
3326
  this.remoteVector = wireToVersionVector(msg.versionVector);
3132
3327
  void this.persistLastAckedServerVector(this.remoteVector);
3328
+ if (typeof msg.serverMaxDeliverySequence === "number" && this.deliveryWatermark > msg.serverMaxDeliverySequence) {
3329
+ this.deliveryWatermark = 0;
3330
+ this.setViewWatermark(this.deliverySignature(), 0);
3331
+ await this.persistDeliveryWatermark(0);
3332
+ }
3133
3333
  if (msg.selectedWireFormat) {
3134
3334
  this.setSerializerWireFormat(msg.selectedWireFormat);
3135
3335
  }
@@ -3195,7 +3395,7 @@ var SyncEngine = class {
3195
3395
  async sendDelta() {
3196
3396
  const localVector = this.store.getVersionVector();
3197
3397
  const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
3198
- const missingOps = allMissingOps.filter((op) => this.operationAllowedForSync(op));
3398
+ const missingOps = await this.filterAllowedForSync(allMissingOps);
3199
3399
  this.deltaSentOpIds = missingOps.map((op) => op.id);
3200
3400
  if (missingOps.length === 0) {
3201
3401
  const messageId = generateMessageId();
@@ -3270,11 +3470,23 @@ var SyncEngine = class {
3270
3470
  return missing;
3271
3471
  }
3272
3472
  async handleOperationBatch(msg) {
3473
+ const isDeliveryBatch = msg.maxDeliverySequence !== void 0;
3474
+ if (isDeliveryBatch) {
3475
+ const base = msg.baseDeliverySequence ?? 0;
3476
+ if (base > this.deliveryWatermark) {
3477
+ return;
3478
+ }
3479
+ if (base < this.deliveryWatermark) {
3480
+ this.sendDeliveryAck(msg.messageId, this.deliveryWatermark);
3481
+ return;
3482
+ }
3483
+ }
3273
3484
  const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
3274
3485
  const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
3275
- const inScopeOps = operations.filter((op) => this.operationAllowedForSync(op));
3486
+ const inScopeOps = await this.filterAllowedForSync(operations);
3276
3487
  const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
3277
3488
  const transforms = this.config.operationTransforms ?? [];
3489
+ let fullyApplied = true;
3278
3490
  for (const op of inScopeOps) {
3279
3491
  const transformed = transforms.length > 0 ? applyOperationTransforms(op, targetSchemaVersion, transforms) : op;
3280
3492
  if (transformed === null) {
@@ -3286,6 +3498,7 @@ var SyncEngine = class {
3286
3498
  this.emitApplyFailure(transformed, result);
3287
3499
  }
3288
3500
  } catch (error) {
3501
+ fullyApplied = false;
3289
3502
  if (error instanceof ClockDriftError) {
3290
3503
  this.emitApplyFailure(transformed, "rejected", {
3291
3504
  code: APPLY_FAILURE_CODES.CLOCK_DRIFT,
@@ -3296,11 +3509,8 @@ var SyncEngine = class {
3296
3509
  }
3297
3510
  const message = error instanceof Error ? error.message : "Apply failed";
3298
3511
  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;
3299
- this.emitApplyFailure(transformed, "rejected", {
3300
- code,
3301
- message,
3302
- retriable: code !== APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY
3303
- });
3512
+ const retriable = code !== APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY;
3513
+ this.emitApplyFailure(transformed, "rejected", { code, message, retriable });
3304
3514
  }
3305
3515
  }
3306
3516
  if (inScopeOps.length > 0) {
@@ -3311,18 +3521,27 @@ var SyncEngine = class {
3311
3521
  batchSize: inScopeOps.length
3312
3522
  });
3313
3523
  }
3314
- const lastOp = operations[operations.length - 1];
3315
- const ack = {
3316
- type: "acknowledgment",
3317
- messageId: generateMessageId(),
3318
- acknowledgedMessageId: msg.messageId,
3319
- lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3320
- };
3321
- this.transport.send(ack);
3322
- this.emitter?.emit({
3323
- type: "sync:acknowledged",
3324
- sequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3325
- });
3524
+ const deliveryFullyApplied = isDeliveryBatch && fullyApplied;
3525
+ if (!isDeliveryBatch || deliveryFullyApplied) {
3526
+ const lastOp = operations[operations.length - 1];
3527
+ const ack = {
3528
+ type: "acknowledgment",
3529
+ messageId: generateMessageId(),
3530
+ acknowledgedMessageId: msg.messageId,
3531
+ lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0,
3532
+ ...deliveryFullyApplied && msg.maxDeliverySequence !== void 0 ? { deliverySequence: msg.maxDeliverySequence } : {}
3533
+ };
3534
+ this.transport.send(ack);
3535
+ this.emitter?.emit({
3536
+ type: "sync:acknowledged",
3537
+ sequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3538
+ });
3539
+ }
3540
+ if (isDeliveryBatch && fullyApplied && msg.maxDeliverySequence !== void 0) {
3541
+ this.deliveryWatermark = msg.maxDeliverySequence;
3542
+ this.setViewWatermark(this.deliverySignature(), this.deliveryWatermark);
3543
+ await this.persistDeliveryWatermark(this.deliveryWatermark);
3544
+ }
3326
3545
  if (this.state === "syncing") {
3327
3546
  this.deltaBatchesReceived++;
3328
3547
  const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
@@ -3330,15 +3549,17 @@ var SyncEngine = class {
3330
3549
  this.initialSyncTotalBatches = msg.totalBatches;
3331
3550
  }
3332
3551
  this.metricsCollector.updateInitialSyncProgress(this.deltaBatchesReceived, totalBatches);
3333
- const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
3334
- inScopeOps.length > 0 ? inScopeOps : operations,
3335
- msg.batchIndex
3336
- );
3337
- if (cursorFromBatch) {
3338
- this.resumeDeltaCursor = cursorFromBatch;
3339
- await this.persistDeltaCursor(cursorFromBatch);
3552
+ if (!isDeliveryBatch) {
3553
+ const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
3554
+ inScopeOps.length > 0 ? inScopeOps : operations,
3555
+ msg.batchIndex
3556
+ );
3557
+ if (cursorFromBatch) {
3558
+ this.resumeDeltaCursor = cursorFromBatch;
3559
+ await this.persistDeltaCursor(cursorFromBatch);
3560
+ }
3340
3561
  }
3341
- if (msg.isFinal) {
3562
+ if (msg.isFinal && (!isDeliveryBatch || fullyApplied)) {
3342
3563
  this.deltaReceiveComplete = true;
3343
3564
  this.resumeDeltaCursor = null;
3344
3565
  await this.persistDeltaCursor(null);
@@ -3347,7 +3568,19 @@ var SyncEngine = class {
3347
3568
  }
3348
3569
  }
3349
3570
  }
3571
+ /** Acknowledge a delivery batch (used for duplicates) without re-applying it. */
3572
+ sendDeliveryAck(acknowledgedMessageId, deliverySequence) {
3573
+ const ack = {
3574
+ type: "acknowledgment",
3575
+ messageId: generateMessageId(),
3576
+ acknowledgedMessageId,
3577
+ lastSequenceNumber: 0,
3578
+ deliverySequence
3579
+ };
3580
+ this.transport.send(ack);
3581
+ }
3350
3582
  handleAcknowledgment(msg) {
3583
+ this.clearOutboundAckTimer();
3351
3584
  if (this.state === "syncing" && this.config.strictHandshake) {
3352
3585
  this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
3353
3586
  this.markDeltaSendCompleteIfReady();
@@ -3367,6 +3600,11 @@ var SyncEngine = class {
3367
3600
  }
3368
3601
  }
3369
3602
  handleError(msg) {
3603
+ this.clearOutboundAckTimer();
3604
+ if (this.currentBatch) {
3605
+ this.outboundQueue.returnBatch(this.currentBatch.batchId);
3606
+ this.currentBatch = null;
3607
+ }
3370
3608
  this.transitionTo("error");
3371
3609
  if (msg.code === "AUTH_FAILED") {
3372
3610
  this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
@@ -3527,7 +3765,7 @@ var SyncEngine = class {
3527
3765
  return;
3528
3766
  }
3529
3767
  const ops = await this.store.getOperationRange(localNodeId, 1, localSeq);
3530
- const inScope = ops.filter((op) => this.operationAllowedForSync(op));
3768
+ const inScope = await this.filterAllowedForSync(ops);
3531
3769
  for (const op of inScope) {
3532
3770
  await this.outboundQueue.enqueue(op);
3533
3771
  }
@@ -3550,6 +3788,7 @@ var SyncEngine = class {
3550
3788
  batchIndex: 0
3551
3789
  };
3552
3790
  this.transport.send(batchMsg);
3791
+ this.startOutboundAckTimer(batch.batchId);
3553
3792
  this.emitter?.emit({
3554
3793
  type: "sync:sent",
3555
3794
  operations: batch.operations,
@@ -3575,6 +3814,7 @@ var SyncEngine = class {
3575
3814
  batchIndex: 0
3576
3815
  };
3577
3816
  this.transport.send(batchMsg);
3817
+ this.startOutboundAckTimer(batch.batchId);
3578
3818
  this.emitter?.emit({
3579
3819
  type: "sync:sent",
3580
3820
  operations: batch.operations,
@@ -3582,6 +3822,33 @@ var SyncEngine = class {
3582
3822
  });
3583
3823
  }
3584
3824
  }
3825
+ startOutboundAckTimer(batchId) {
3826
+ this.clearOutboundAckTimer();
3827
+ if (this.outboundAckTimeoutMs <= 0) return;
3828
+ this.outboundAckTimer = setTimeout(() => {
3829
+ void this.handleOutboundAckTimeout(batchId);
3830
+ }, this.outboundAckTimeoutMs);
3831
+ }
3832
+ clearOutboundAckTimer() {
3833
+ if (!this.outboundAckTimer) return;
3834
+ clearTimeout(this.outboundAckTimer);
3835
+ this.outboundAckTimer = null;
3836
+ }
3837
+ async handleOutboundAckTimeout(batchId) {
3838
+ if (!this.currentBatch || this.currentBatch.batchId !== batchId) return;
3839
+ this.outboundQueue.returnBatch(batchId);
3840
+ this.currentBatch = null;
3841
+ this.clearOutboundAckTimer();
3842
+ await this.refreshPendingCount();
3843
+ if (this.state === "disconnected") return;
3844
+ const reason = `Timed out waiting for acknowledgment of outbound batch ${batchId}`;
3845
+ try {
3846
+ await this.transport.disconnect();
3847
+ } finally {
3848
+ this.ensureDisconnected();
3849
+ this.emitter?.emit({ type: "sync:disconnected", reason });
3850
+ }
3851
+ }
3585
3852
  handleAwarenessUpdate(msg) {
3586
3853
  const awarenessMessage = {
3587
3854
  type: "awareness",
@@ -3591,6 +3858,7 @@ var SyncEngine = class {
3591
3858
  this.awarenessManager.handleRemoteMessage(awarenessMessage);
3592
3859
  }
3593
3860
  handleTransportClose(reason) {
3861
+ this.clearOutboundAckTimer();
3594
3862
  if (this.currentBatch) {
3595
3863
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
3596
3864
  this.currentBatch = null;
@@ -3625,11 +3893,40 @@ var SyncEngine = class {
3625
3893
  this.serializer.setWireFormat(format);
3626
3894
  }
3627
3895
  }
3628
- operationAllowedForSync(op) {
3629
- if (!operationMatchesScope(op, this.activeScope)) {
3896
+ async operationAllowedForSync(op) {
3897
+ if (this.matchesScopeAndSubsets(op)) {
3898
+ return true;
3899
+ }
3900
+ const fullRecord = await this.readRecordForBackfill(op);
3901
+ if (!fullRecord) {
3630
3902
  return false;
3631
3903
  }
3632
- return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets());
3904
+ return this.matchesScopeAndSubsets(op, fullRecord);
3905
+ }
3906
+ matchesScopeAndSubsets(op, fullRecord) {
3907
+ if (!operationMatchesScope(op, this.activeScope, fullRecord)) {
3908
+ return false;
3909
+ }
3910
+ return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets(), fullRecord);
3911
+ }
3912
+ async readRecordForBackfill(op) {
3913
+ if (!this.store.readRecordFields) {
3914
+ return null;
3915
+ }
3916
+ try {
3917
+ return await this.store.readRecordFields(op.collection, op.recordId);
3918
+ } catch {
3919
+ return null;
3920
+ }
3921
+ }
3922
+ async filterAllowedForSync(ops) {
3923
+ const allowed = [];
3924
+ for (const op of ops) {
3925
+ if (await this.operationAllowedForSync(op)) {
3926
+ allowed.push(op);
3927
+ }
3928
+ }
3929
+ return allowed;
3633
3930
  }
3634
3931
  async persistDeltaCursor(cursor) {
3635
3932
  if (!this.syncState?.saveDeltaCursor) {
@@ -3637,6 +3934,12 @@ var SyncEngine = class {
3637
3934
  }
3638
3935
  await this.syncState.saveDeltaCursor(cursor);
3639
3936
  }
3937
+ async persistDeliveryWatermark(watermark, signature = this.deliverySignature()) {
3938
+ if (!this.syncState?.saveDeliveryWatermark) {
3939
+ return;
3940
+ }
3941
+ await this.syncState.saveDeliveryWatermark(signature, watermark);
3942
+ }
3640
3943
  scheduleQuerySubsetReconnect() {
3641
3944
  if (this.querySubsetReconnectTimer) {
3642
3945
  clearTimeout(this.querySubsetReconnectTimer);