@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.js CHANGED
@@ -14,6 +14,7 @@ var SYNC_STATES = [
14
14
  ];
15
15
  var SYNC_STATUSES = [
16
16
  "connected",
17
+ "reconnecting",
17
18
  "syncing",
18
19
  "synced",
19
20
  "offline",
@@ -21,6 +22,20 @@ var SYNC_STATUSES = [
21
22
  "error",
22
23
  "schema-mismatch"
23
24
  ];
25
+ var MemoryRejectedOperationStorage = class {
26
+ rejected = /* @__PURE__ */ new Map();
27
+ async record(rejected) {
28
+ this.rejected.set(rejected.operationId, rejected);
29
+ }
30
+ async list() {
31
+ return [...this.rejected.values()];
32
+ }
33
+ async remove(operationIds) {
34
+ for (const id of operationIds) {
35
+ this.rejected.delete(id);
36
+ }
37
+ }
38
+ };
24
39
  var ScopeViolationError = class extends KoraError {
25
40
  constructor(operationId, collection, scope, message) {
26
41
  super(
@@ -218,6 +233,8 @@ function isSyncMessage(value) {
218
233
  return isAcknowledgmentMessage(value);
219
234
  case "error":
220
235
  return isErrorMessage(value);
236
+ case "operation-rejected":
237
+ return isOperationRejectedMessage(value);
221
238
  case "awareness-update":
222
239
  return isAwarenessUpdateMessage(value);
223
240
  case "yjs-doc-update":
@@ -257,6 +274,11 @@ function isErrorMessage(value) {
257
274
  const msg = value;
258
275
  return msg.type === "error" && typeof msg.messageId === "string" && typeof msg.code === "string" && typeof msg.message === "string" && typeof msg.retriable === "boolean";
259
276
  }
277
+ function isOperationRejectedMessage(value) {
278
+ if (typeof value !== "object" || value === null) return false;
279
+ const msg = value;
280
+ return msg.type === "operation-rejected" && typeof msg.messageId === "string" && typeof msg.operationId === "string" && typeof msg.collection === "string" && typeof msg.recordId === "string" && typeof msg.code === "string" && typeof msg.message === "string" && typeof msg.retriable === "boolean";
281
+ }
260
282
  function isAwarenessUpdateMessage(value) {
261
283
  if (typeof value !== "object" || value === null) return false;
262
284
  const msg = value;
@@ -507,7 +529,8 @@ function toProtoEnvelope(message) {
507
529
  })),
508
530
  schemaVersion: message.schemaVersion,
509
531
  authToken: message.authToken,
510
- supportedWireFormats: message.supportedWireFormats
532
+ supportedWireFormats: message.supportedWireFormats,
533
+ ...message.lastDeliverySequence !== void 0 ? { lastDeliverySequence: message.lastDeliverySequence } : {}
511
534
  };
512
535
  case "handshake-response":
513
536
  return {
@@ -523,7 +546,8 @@ function toProtoEnvelope(message) {
523
546
  rejectReason: message.rejectReason,
524
547
  selectedWireFormat: message.selectedWireFormat,
525
548
  serverTime: message.serverTime,
526
- blobStorageEnabled: message.blobStorageEnabled
549
+ blobStorageEnabled: message.blobStorageEnabled,
550
+ ...message.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: message.serverMaxDeliverySequence } : {}
527
551
  };
528
552
  case "operation-batch":
529
553
  return {
@@ -531,14 +555,17 @@ function toProtoEnvelope(message) {
531
555
  messageId: message.messageId,
532
556
  operations: message.operations.map(serializeProtoOperation),
533
557
  isFinal: message.isFinal,
534
- batchIndex: message.batchIndex
558
+ batchIndex: message.batchIndex,
559
+ ...message.baseDeliverySequence !== void 0 ? { baseDeliverySequence: message.baseDeliverySequence } : {},
560
+ ...message.maxDeliverySequence !== void 0 ? { maxDeliverySequence: message.maxDeliverySequence } : {}
535
561
  };
536
562
  case "acknowledgment":
537
563
  return {
538
564
  type: message.type,
539
565
  messageId: message.messageId,
540
566
  acknowledgedMessageId: message.acknowledgedMessageId,
541
- lastSequenceNumber: message.lastSequenceNumber
567
+ lastSequenceNumber: message.lastSequenceNumber,
568
+ ...message.deliverySequence !== void 0 ? { deliverySequence: message.deliverySequence } : {}
542
569
  };
543
570
  case "error":
544
571
  return {
@@ -548,6 +575,17 @@ function toProtoEnvelope(message) {
548
575
  errorMessage: message.message,
549
576
  retriable: message.retriable
550
577
  };
578
+ case "operation-rejected":
579
+ return {
580
+ type: message.type,
581
+ messageId: message.messageId,
582
+ operationId: message.operationId,
583
+ collection: message.collection,
584
+ recordId: message.recordId,
585
+ errorCode: message.code,
586
+ errorMessage: message.message,
587
+ retriable: message.retriable
588
+ };
551
589
  case "awareness-update":
552
590
  case "yjs-doc-update":
553
591
  return {
@@ -592,7 +630,8 @@ function fromProtoEnvelope(envelope) {
592
630
  authToken: envelope.authToken,
593
631
  supportedWireFormats: envelope.supportedWireFormats?.filter(
594
632
  (format) => format === "json" || format === "protobuf"
595
- )
633
+ ),
634
+ ...envelope.lastDeliverySequence !== void 0 ? { lastDeliverySequence: envelope.lastDeliverySequence } : {}
596
635
  };
597
636
  case "handshake-response":
598
637
  return {
@@ -609,7 +648,8 @@ function fromProtoEnvelope(envelope) {
609
648
  // Preserve the server's wall-clock time so clock-skew detection and
610
649
  // automatic timestamp rebase work over the protobuf wire, not just JSON.
611
650
  ...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {},
612
- ...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {}
651
+ ...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {},
652
+ ...envelope.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: envelope.serverMaxDeliverySequence } : {}
613
653
  };
614
654
  case "operation-batch":
615
655
  return {
@@ -617,14 +657,17 @@ function fromProtoEnvelope(envelope) {
617
657
  messageId: envelope.messageId,
618
658
  operations: (envelope.operations ?? []).map(deserializeProtoOperation),
619
659
  isFinal: envelope.isFinal ?? false,
620
- batchIndex: envelope.batchIndex ?? 0
660
+ batchIndex: envelope.batchIndex ?? 0,
661
+ ...envelope.baseDeliverySequence !== void 0 ? { baseDeliverySequence: envelope.baseDeliverySequence } : {},
662
+ ...envelope.maxDeliverySequence !== void 0 ? { maxDeliverySequence: envelope.maxDeliverySequence } : {}
621
663
  };
622
664
  case "acknowledgment":
623
665
  return {
624
666
  type: "acknowledgment",
625
667
  messageId: envelope.messageId,
626
668
  acknowledgedMessageId: envelope.acknowledgedMessageId ?? "",
627
- lastSequenceNumber: envelope.lastSequenceNumber ?? 0
669
+ lastSequenceNumber: envelope.lastSequenceNumber ?? 0,
670
+ ...envelope.deliverySequence !== void 0 ? { deliverySequence: envelope.deliverySequence } : {}
628
671
  };
629
672
  case "error":
630
673
  return {
@@ -634,6 +677,17 @@ function fromProtoEnvelope(envelope) {
634
677
  message: envelope.errorMessage ?? "Unknown error",
635
678
  retriable: envelope.retriable ?? false
636
679
  };
680
+ case "operation-rejected":
681
+ return {
682
+ type: "operation-rejected",
683
+ messageId: envelope.messageId,
684
+ operationId: envelope.operationId ?? "",
685
+ collection: envelope.collection ?? "",
686
+ recordId: envelope.recordId ?? "",
687
+ code: envelope.errorCode ?? "REJECTED",
688
+ message: envelope.errorMessage ?? "",
689
+ retriable: envelope.retriable ?? false
690
+ };
637
691
  case "blob-chunk-request":
638
692
  return {
639
693
  type: "blob-chunk-request",
@@ -808,6 +862,21 @@ function encodeEnvelope(envelope) {
808
862
  if (envelope.hasBytes !== void 0) writer.uint32(184).bool(envelope.hasBytes);
809
863
  if (envelope.blobStorageEnabled !== void 0)
810
864
  writer.uint32(200).bool(envelope.blobStorageEnabled);
865
+ if (envelope.operationId && envelope.operationId.length > 0)
866
+ writer.uint32(210).string(envelope.operationId);
867
+ if (envelope.collection && envelope.collection.length > 0)
868
+ writer.uint32(218).string(envelope.collection);
869
+ if (envelope.recordId && envelope.recordId.length > 0)
870
+ writer.uint32(226).string(envelope.recordId);
871
+ if (envelope.lastDeliverySequence !== void 0)
872
+ writer.uint32(232).int64(envelope.lastDeliverySequence);
873
+ if (envelope.baseDeliverySequence !== void 0)
874
+ writer.uint32(240).int64(envelope.baseDeliverySequence);
875
+ if (envelope.maxDeliverySequence !== void 0)
876
+ writer.uint32(248).int64(envelope.maxDeliverySequence);
877
+ if (envelope.deliverySequence !== void 0) writer.uint32(256).int64(envelope.deliverySequence);
878
+ if (envelope.serverMaxDeliverySequence !== void 0)
879
+ writer.uint32(264).int64(envelope.serverMaxDeliverySequence);
811
880
  return writer.finish();
812
881
  }
813
882
  function decodeEnvelope(bytes) {
@@ -894,6 +963,30 @@ function decodeEnvelope(bytes) {
894
963
  case 25:
895
964
  envelope.blobStorageEnabled = reader.bool();
896
965
  break;
966
+ case 26:
967
+ envelope.operationId = reader.string();
968
+ break;
969
+ case 27:
970
+ envelope.collection = reader.string();
971
+ break;
972
+ case 28:
973
+ envelope.recordId = reader.string();
974
+ break;
975
+ case 29:
976
+ envelope.lastDeliverySequence = longToNumber(reader.int64());
977
+ break;
978
+ case 30:
979
+ envelope.baseDeliverySequence = longToNumber(reader.int64());
980
+ break;
981
+ case 31:
982
+ envelope.maxDeliverySequence = longToNumber(reader.int64());
983
+ break;
984
+ case 32:
985
+ envelope.deliverySequence = longToNumber(reader.int64());
986
+ break;
987
+ case 33:
988
+ envelope.serverMaxDeliverySequence = longToNumber(reader.int64());
989
+ break;
897
990
  default:
898
991
  reader.skipType(tag & 7);
899
992
  }
@@ -1393,6 +1486,7 @@ var ChaosTransport = class {
1393
1486
  reorderRate;
1394
1487
  maxLatency;
1395
1488
  random;
1489
+ dropPredicate;
1396
1490
  messageHandler = null;
1397
1491
  reorderBuffer = [];
1398
1492
  pending = [];
@@ -1403,6 +1497,7 @@ var ChaosTransport = class {
1403
1497
  this.reorderRate = config?.reorderRate ?? 0;
1404
1498
  this.maxLatency = config?.maxLatency ?? 0;
1405
1499
  this.random = config?.randomSource ?? Math.random;
1500
+ this.dropPredicate = config?.dropPredicate ?? null;
1406
1501
  }
1407
1502
  async connect(url, options) {
1408
1503
  this.inner.onMessage((msg) => this.handleIncoming(msg));
@@ -1421,6 +1516,9 @@ var ChaosTransport = class {
1421
1516
  return this.inner.disconnect();
1422
1517
  }
1423
1518
  send(message) {
1519
+ if (this.dropPredicate?.(message, "outgoing")) {
1520
+ return;
1521
+ }
1424
1522
  if (this.random() < this.dropRate) {
1425
1523
  return;
1426
1524
  }
@@ -1448,6 +1546,9 @@ var ChaosTransport = class {
1448
1546
  }
1449
1547
  handleIncoming(message) {
1450
1548
  if (!this.messageHandler) return;
1549
+ if (this.dropPredicate?.(message, "incoming")) {
1550
+ return;
1551
+ }
1451
1552
  if (this.random() < this.dropRate) {
1452
1553
  return;
1453
1554
  }
@@ -2400,6 +2501,32 @@ var OutboundQueue = class {
2400
2501
  const ids = ops.map((op) => op.id);
2401
2502
  await this.storage.dequeue(ids);
2402
2503
  }
2504
+ /**
2505
+ * Acknowledge only operations in an in-flight batch whose local sequence number is
2506
+ * covered by the server's ack. Any later operations return to the front of the
2507
+ * queue for retry. This is required when the server accepts a prefix but rejects a
2508
+ * later operation retriably (for example a stale-scope push after auth changed).
2509
+ */
2510
+ async acknowledgeThrough(batchId, lastSequenceNumber) {
2511
+ const ops = this.inFlight.get(batchId);
2512
+ if (!ops) return;
2513
+ this.inFlight.delete(batchId);
2514
+ const acknowledged = ops.filter((op) => op.sequenceNumber <= lastSequenceNumber);
2515
+ const retry = ops.filter((op) => op.sequenceNumber > lastSequenceNumber);
2516
+ if (retry.length > 0) {
2517
+ this.queue.unshift(...retry);
2518
+ if (this.queue.length > 1) {
2519
+ this.queue = topologicalSort(this.queue);
2520
+ }
2521
+ }
2522
+ if (acknowledged.length > 0) {
2523
+ const ids = acknowledged.map((op) => op.id);
2524
+ for (const id of ids) {
2525
+ this.seen.delete(id);
2526
+ }
2527
+ await this.storage.dequeue(ids);
2528
+ }
2529
+ }
2403
2530
  /**
2404
2531
  * Return a failed batch to the front of the queue for retry.
2405
2532
  * Prepends the operations to maintain priority.
@@ -2482,6 +2609,37 @@ var OutboundQueue = class {
2482
2609
  get isInitialized() {
2483
2610
  return this.initialized;
2484
2611
  }
2612
+ /**
2613
+ * Remove a single operation the server permanently rejected — from the queue,
2614
+ * from any in-flight batch, and from durable storage — so a later batch ack or
2615
+ * a reconnect `returnBatch` can never resend or resurrect it. Returns the
2616
+ * removed operation so the caller can record it in a durable rejected store,
2617
+ * or null if it was not present (already acknowledged and removed).
2618
+ */
2619
+ async reject(opId) {
2620
+ let removed = null;
2621
+ for (const [batchId, ops] of this.inFlight) {
2622
+ const idx = ops.findIndex((op) => op.id === opId);
2623
+ if (idx !== -1) {
2624
+ removed = ops[idx] ?? null;
2625
+ ops.splice(idx, 1);
2626
+ if (ops.length === 0) {
2627
+ this.inFlight.delete(batchId);
2628
+ }
2629
+ break;
2630
+ }
2631
+ }
2632
+ if (!removed) {
2633
+ const idx = this.queue.findIndex((op) => op.id === opId);
2634
+ if (idx !== -1) {
2635
+ removed = this.queue[idx] ?? null;
2636
+ this.queue.splice(idx, 1);
2637
+ }
2638
+ }
2639
+ this.seen.delete(opId);
2640
+ await this.storage.dequeue([opId]);
2641
+ return removed;
2642
+ }
2485
2643
  /**
2486
2644
  * Remove operations by id from queue and persistent storage.
2487
2645
  * Used when ops were already sent during handshake delta exchange.
@@ -2500,6 +2658,7 @@ var OutboundQueue = class {
2500
2658
  // src/engine/sync-engine.ts
2501
2659
  var DEFAULT_BATCH_SIZE = 100;
2502
2660
  var DEFAULT_SCHEMA_VERSION = 1;
2661
+ var DEFAULT_OUTBOUND_ACK_TIMEOUT_MS = 3e4;
2503
2662
  var VALID_TRANSITIONS = {
2504
2663
  disconnected: ["connecting"],
2505
2664
  connecting: ["handshaking", "error", "disconnected"],
@@ -2513,6 +2672,17 @@ var nextQuerySubsetId = 0;
2513
2672
  function generateMessageId() {
2514
2673
  return `msg-${Date.now()}-${nextMessageId++}`;
2515
2674
  }
2675
+ var MAX_DELIVERY_VIEW_WATERMARKS = 64;
2676
+ function stableStringify(value) {
2677
+ if (value === null || typeof value !== "object") {
2678
+ return JSON.stringify(value) ?? "null";
2679
+ }
2680
+ if (Array.isArray(value)) {
2681
+ return `[${value.map(stableStringify).join(",")}]`;
2682
+ }
2683
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
2684
+ return `{${entries.join(",")}}`;
2685
+ }
2516
2686
  var SyncEngine = class {
2517
2687
  state = "disconnected";
2518
2688
  transport;
@@ -2521,7 +2691,9 @@ var SyncEngine = class {
2521
2691
  serializer;
2522
2692
  emitter;
2523
2693
  outboundQueue;
2694
+ rejectedStorage;
2524
2695
  batchSize;
2696
+ outboundAckTimeoutMs;
2525
2697
  encryptor;
2526
2698
  awarenessManager;
2527
2699
  richtextDocChannel;
@@ -2536,11 +2708,16 @@ var SyncEngine = class {
2536
2708
  lastSuccessfulPull = null;
2537
2709
  conflictCount = 0;
2538
2710
  currentBatch = null;
2711
+ currentBatchRequiresPartialAck = false;
2539
2712
  reconnecting = false;
2540
2713
  schemaBlocked = false;
2541
2714
  clockBlocked = false;
2542
2715
  clockSkewMs = null;
2543
2716
  blobStorageEnabled = false;
2717
+ outboundAckTimer = null;
2718
+ startPromise = null;
2719
+ stopPromise = null;
2720
+ reconnectPromise = null;
2544
2721
  // Track delta exchange state
2545
2722
  deltaBatchesReceived = 0;
2546
2723
  deltaReceiveComplete = false;
@@ -2561,6 +2738,27 @@ var SyncEngine = class {
2561
2738
  /** Resume cursor for paginated initial sync (persisted across reconnects) */
2562
2739
  resumeDeltaCursor = null;
2563
2740
  initialSyncTotalBatches = 0;
2741
+ /**
2742
+ * The delivery watermark for the CURRENT sync view: the highest server delivery
2743
+ * sequence up to which every operation in the current view (scope + active query
2744
+ * subscriptions) has been contiguously applied. Advanced only through the server's
2745
+ * gap-free delivery stream (never through live relay), so it is a durable lower bound
2746
+ * on what this client holds for this view. Reported at handshake to resume the stream.
2747
+ *
2748
+ * The watermark is keyed by view: switching views (a scope or subscription change)
2749
+ * saves the current view's watermark and loads the target view's, so returning to a
2750
+ * previously-synced view resumes exactly where it left off instead of re-syncing. This
2751
+ * is correct because a view's watermark is a lower bound specific to that view's
2752
+ * filter; a widened view is simply a different (initially unsynced) view.
2753
+ */
2754
+ deliveryWatermark = 0;
2755
+ /**
2756
+ * In-memory cache of watermark per view signature, preloaded from persistence at start
2757
+ * so a view switch resolves synchronously (no race with the reconnect it triggers).
2758
+ */
2759
+ deliverySignatureWatermarks = /* @__PURE__ */ new Map();
2760
+ deliveryGapRepeatCount = 0;
2761
+ lastDeliveryGapKey = null;
2564
2762
  constructor(options) {
2565
2763
  this.transport = options.transport;
2566
2764
  this.store = options.store;
@@ -2568,11 +2766,13 @@ var SyncEngine = class {
2568
2766
  this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
2569
2767
  this.emitter = options.emitter ?? null;
2570
2768
  this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
2769
+ this.outboundAckTimeoutMs = options.config.outboundAckTimeoutMs ?? DEFAULT_OUTBOUND_ACK_TIMEOUT_MS;
2571
2770
  this.encryptor = options.encryptor ?? null;
2572
2771
  this.syncState = options.syncState ?? null;
2573
2772
  this.activeScope = options.config.scopeMap;
2574
2773
  const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
2575
2774
  this.outboundQueue = new OutboundQueue(queueStorage);
2775
+ this.rejectedStorage = options.rejectedStorage ?? new MemoryRejectedOperationStorage();
2576
2776
  this.metricsCollector = new SyncMetricsCollector(options.metricsConfig);
2577
2777
  if (this.emitter) {
2578
2778
  this.metricsCollector.attachEmitter(this.emitter);
@@ -2612,10 +2812,20 @@ var SyncEngine = class {
2612
2812
  * Start the sync engine: connect → handshake → delta exchange → streaming.
2613
2813
  */
2614
2814
  async start() {
2815
+ if (this.state === "streaming") return;
2816
+ if (this.startPromise) return this.startPromise;
2817
+ this.startPromise = this.startInternal().finally(() => {
2818
+ this.startPromise = null;
2819
+ });
2820
+ return this.startPromise;
2821
+ }
2822
+ async startInternal() {
2823
+ if (this.stopPromise) {
2824
+ await this.stopPromise;
2825
+ }
2826
+ if (this.state === "streaming") return;
2615
2827
  if (this.state !== "disconnected") {
2616
- throw new SyncError4("Cannot start sync engine: not in disconnected state", {
2617
- currentState: this.state
2618
- });
2828
+ await this.stop();
2619
2829
  }
2620
2830
  await this.outboundQueue.initialize();
2621
2831
  if (this.syncState) {
@@ -2623,6 +2833,16 @@ var SyncEngine = class {
2623
2833
  if (this.syncState.loadDeltaCursor) {
2624
2834
  this.resumeDeltaCursor = await this.syncState.loadDeltaCursor();
2625
2835
  }
2836
+ if (this.syncState.loadAllDeliveryWatermarks) {
2837
+ const all = await this.syncState.loadAllDeliveryWatermarks();
2838
+ for (const [signature, watermark] of Object.entries(all)) {
2839
+ this.deliverySignatureWatermarks.set(signature, watermark);
2840
+ }
2841
+ } else if (this.syncState.loadDeliveryWatermark) {
2842
+ this.deliverySignatureWatermarks.set("", await this.syncState.loadDeliveryWatermark(""));
2843
+ }
2844
+ this.deliveryWatermark = this.deliverySignatureWatermarks.get(this.deliverySignature()) ?? 0;
2845
+ this.evictColdViewWatermarks();
2626
2846
  }
2627
2847
  await this.reconcileOutboundFromOpLog();
2628
2848
  await this.refreshPendingCount();
@@ -2638,7 +2858,12 @@ var SyncEngine = class {
2638
2858
  this.transitionTo("connecting");
2639
2859
  try {
2640
2860
  const authToken = this.config.auth ? (await this.config.auth()).token : void 0;
2861
+ if (this.state !== "connecting") return;
2641
2862
  await this.transport.connect(this.config.url, { authToken });
2863
+ if (this.state !== "connecting") {
2864
+ await this.transport.disconnect();
2865
+ return;
2866
+ }
2642
2867
  this.transitionTo("handshaking");
2643
2868
  const localVector = this.store.getVersionVector();
2644
2869
  const activeQuerySubsets = this.getActiveQuerySubsets();
@@ -2652,7 +2877,11 @@ var SyncEngine = class {
2652
2877
  supportedWireFormats: ["json", "protobuf"],
2653
2878
  ...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
2654
2879
  ...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
2655
- ...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {}
2880
+ ...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {},
2881
+ // Resume the server's gap-free delivery stream from our watermark. A server
2882
+ // that understands it drives server->client sync from delivery sequences; an
2883
+ // older server ignores it and falls back to the version-vector delta.
2884
+ lastDeliverySequence: this.deliveryWatermark
2656
2885
  };
2657
2886
  this.transport.send(handshake);
2658
2887
  } catch (err) {
@@ -2664,11 +2893,20 @@ var SyncEngine = class {
2664
2893
  * Stop the sync engine. Disconnects the transport.
2665
2894
  */
2666
2895
  async stop() {
2896
+ if (this.stopPromise) return this.stopPromise;
2897
+ this.stopPromise = this.stopInternal().finally(() => {
2898
+ this.stopPromise = null;
2899
+ });
2900
+ return this.stopPromise;
2901
+ }
2902
+ async stopInternal() {
2667
2903
  if (this.state === "disconnected") return;
2904
+ this.clearOutboundAckTimer();
2668
2905
  this.awarenessManager.stopCleanupTimer();
2669
2906
  if (this.currentBatch) {
2670
2907
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
2671
2908
  this.currentBatch = null;
2909
+ this.currentBatchRequiresPartialAck = false;
2672
2910
  }
2673
2911
  try {
2674
2912
  await this.transport.disconnect();
@@ -2689,7 +2927,7 @@ var SyncEngine = class {
2689
2927
  * because they should remain local-only and not be sent to the server.
2690
2928
  */
2691
2929
  async pushOperation(op) {
2692
- if (!this.operationAllowedForSync(op)) {
2930
+ if (!await this.operationAllowedForSync(op)) {
2693
2931
  return;
2694
2932
  }
2695
2933
  if (this.syncState) {
@@ -2718,6 +2956,7 @@ var SyncEngine = class {
2718
2956
  getStatus() {
2719
2957
  const pendingOperations = this.syncState ? this.cachedUnsyncedCount : this.outboundQueue.totalPending;
2720
2958
  const base = {
2959
+ reconnecting: this.reconnecting,
2721
2960
  pendingOperations,
2722
2961
  lastSyncedAt: this.lastSyncedAt,
2723
2962
  lastSuccessfulPush: this.lastSuccessfulPush,
@@ -2737,7 +2976,7 @@ var SyncEngine = class {
2737
2976
  case "connecting":
2738
2977
  case "handshaking":
2739
2978
  case "syncing":
2740
- return { ...base, status: this.reconnecting ? "offline" : "syncing" };
2979
+ return { ...base, status: this.reconnecting ? "reconnecting" : "syncing" };
2741
2980
  case "streaming":
2742
2981
  return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
2743
2982
  case "error":
@@ -2822,6 +3061,24 @@ var SyncEngine = class {
2822
3061
  await this.start();
2823
3062
  }
2824
3063
  }
3064
+ /**
3065
+ * Atomically refresh the transport session. Overlapping callers share one
3066
+ * reconnect, in-flight outbound batches are returned to the queue by stop(), and the
3067
+ * next start handshakes with the latest auth token, sync scope, and query subsets.
3068
+ */
3069
+ async reconnect() {
3070
+ if (this.schemaBlocked) return;
3071
+ if (this.reconnectPromise) return this.reconnectPromise;
3072
+ this.reconnecting = false;
3073
+ this.reconnectPromise = this.reconnectInternal().finally(() => {
3074
+ this.reconnectPromise = null;
3075
+ });
3076
+ return this.reconnectPromise;
3077
+ }
3078
+ async reconnectInternal() {
3079
+ await this.stop();
3080
+ await this.start();
3081
+ }
2825
3082
  /**
2826
3083
  * Export a diagnostics snapshot for debugging and support tickets.
2827
3084
  * Contains connection state, timing info, and queue metrics.
@@ -2840,6 +3097,8 @@ var SyncEngine = class {
2840
3097
  pendingOperations: this.outboundQueue.totalPending,
2841
3098
  hasInFlightBatch: this.currentBatch !== null,
2842
3099
  reconnecting: this.reconnecting,
3100
+ deliveryWatermark: this.deliveryWatermark,
3101
+ deliveryGapRepeatCount: this.deliveryGapRepeatCount,
2843
3102
  timestamp: Date.now()
2844
3103
  };
2845
3104
  }
@@ -2868,8 +3127,84 @@ var SyncEngine = class {
2868
3127
  * @param scopeMap - New per-collection scope filters, or undefined to remove scope
2869
3128
  */
2870
3129
  updateScope(scopeMap) {
3130
+ const previousSignature = this.deliverySignature();
2871
3131
  this.activeScope = scopeMap;
2872
3132
  this.config.scopeMap = scopeMap;
3133
+ this.switchDeliveryView(previousSignature);
3134
+ }
3135
+ /**
3136
+ * A stable identifier for the current sync view: the auth/tenant scope plus the set of
3137
+ * active query subscriptions, canonicalized so the same view always produces the same
3138
+ * string. The delivery watermark is keyed by this, so each distinct view resumes from
3139
+ * its own last position.
3140
+ */
3141
+ deliverySignature() {
3142
+ const subsets = this.getActiveQuerySubsets();
3143
+ if (!this.activeScope && subsets.length === 0) {
3144
+ return "";
3145
+ }
3146
+ const normalizedSubsets = subsets.map((s) => `${s.collection}:${stableStringify(s.where)}`).sort();
3147
+ return stableStringify({ scope: this.activeScope ?? null, subsets: normalizedSubsets });
3148
+ }
3149
+ /**
3150
+ * Switch the active delivery watermark to the current view after a scope or subscription
3151
+ * change. The prior view's watermark is saved so returning to it resumes; the new view's
3152
+ * watermark is restored from the in-memory cache (0 for a never-synced view, which then
3153
+ * does a one-time resync under that view). No data is lost: a view's watermark is a lower
3154
+ * bound for that specific view's filter.
3155
+ */
3156
+ switchDeliveryView(previousSignature) {
3157
+ const nextSignature = this.deliverySignature();
3158
+ if (nextSignature === previousSignature) {
3159
+ return;
3160
+ }
3161
+ this.setViewWatermark(previousSignature, this.deliveryWatermark);
3162
+ void this.persistDeliveryWatermark(this.deliveryWatermark, previousSignature);
3163
+ this.deliveryWatermark = this.deliverySignatureWatermarks.get(nextSignature) ?? 0;
3164
+ }
3165
+ /**
3166
+ * Record a view's watermark in the in-memory cache as the most-recently-used entry (the
3167
+ * Map preserves insertion order, so re-inserting moves it to the tail), then evict cold
3168
+ * views past the retention cap. This is the single place the cache is written, so LRU
3169
+ * order and the size bound are always maintained together.
3170
+ */
3171
+ setViewWatermark(signature, watermark) {
3172
+ this.deliverySignatureWatermarks.delete(signature);
3173
+ this.deliverySignatureWatermarks.set(signature, watermark);
3174
+ this.evictColdViewWatermarks();
3175
+ }
3176
+ /**
3177
+ * Trim the per-view watermark cache to `MAX_DELIVERY_VIEW_WATERMARKS`, evicting the
3178
+ * least-recently-used views. The default view ('') and the live view are never evicted
3179
+ * (evicting the live view would force an immediate re-scan of what we are actively
3180
+ * syncing). Each eviction also removes the persisted row, bounding storage. Safe by
3181
+ * construction: an evicted view simply back-fills from 0 (deduplicated) when next seen.
3182
+ */
3183
+ evictColdViewWatermarks() {
3184
+ if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
3185
+ return;
3186
+ }
3187
+ const liveSignature = this.deliverySignature();
3188
+ for (const signature of this.deliverySignatureWatermarks.keys()) {
3189
+ if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
3190
+ break;
3191
+ }
3192
+ if (signature === "" || signature === liveSignature) {
3193
+ continue;
3194
+ }
3195
+ this.deliverySignatureWatermarks.delete(signature);
3196
+ void this.deleteDeliveryWatermark(signature);
3197
+ }
3198
+ }
3199
+ /**
3200
+ * Remove a view's persisted watermark row (best-effort; a persistence layer that omits
3201
+ * the delete simply keeps the row, which is harmless).
3202
+ */
3203
+ async deleteDeliveryWatermark(signature) {
3204
+ if (!this.syncState?.deleteDeliveryWatermark) {
3205
+ return;
3206
+ }
3207
+ await this.syncState.deleteDeliveryWatermark(signature);
2873
3208
  }
2874
3209
  /**
2875
3210
  * Get the currently active scope map. Returns undefined if no scope is configured.
@@ -2883,10 +3218,14 @@ var SyncEngine = class {
2883
3218
  */
2884
3219
  registerQuerySubset(subset) {
2885
3220
  const id = `query-${nextQuerySubsetId++}`;
3221
+ const previousSignature = this.deliverySignature();
2886
3222
  this.querySubsets.set(id, subset);
3223
+ this.switchDeliveryView(previousSignature);
2887
3224
  this.scheduleQuerySubsetReconnect();
2888
3225
  return () => {
3226
+ const sigBeforeRemove = this.deliverySignature();
2889
3227
  this.querySubsets.delete(id);
3228
+ this.switchDeliveryView(sigBeforeRemove);
2890
3229
  this.scheduleQuerySubsetReconnect();
2891
3230
  };
2892
3231
  }
@@ -2949,11 +3288,14 @@ var SyncEngine = class {
2949
3288
  await this.handleOperationBatch(message);
2950
3289
  break;
2951
3290
  case "acknowledgment":
2952
- this.handleAcknowledgment(message);
3291
+ await this.handleAcknowledgment(message);
2953
3292
  break;
2954
3293
  case "error":
2955
3294
  this.handleError(message);
2956
3295
  break;
3296
+ case "operation-rejected":
3297
+ await this.handleOperationRejected(message);
3298
+ break;
2957
3299
  case "awareness-update":
2958
3300
  this.handleAwarenessUpdate(message);
2959
3301
  break;
@@ -3036,6 +3378,11 @@ var SyncEngine = class {
3036
3378
  }
3037
3379
  this.remoteVector = wireToVersionVector(msg.versionVector);
3038
3380
  void this.persistLastAckedServerVector(this.remoteVector);
3381
+ if (typeof msg.serverMaxDeliverySequence === "number" && this.deliveryWatermark > msg.serverMaxDeliverySequence) {
3382
+ this.deliveryWatermark = 0;
3383
+ this.setViewWatermark(this.deliverySignature(), 0);
3384
+ await this.persistDeliveryWatermark(0);
3385
+ }
3039
3386
  if (msg.selectedWireFormat) {
3040
3387
  this.setSerializerWireFormat(msg.selectedWireFormat);
3041
3388
  }
@@ -3101,7 +3448,7 @@ var SyncEngine = class {
3101
3448
  async sendDelta() {
3102
3449
  const localVector = this.store.getVersionVector();
3103
3450
  const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
3104
- const missingOps = allMissingOps.filter((op) => this.operationAllowedForSync(op));
3451
+ const missingOps = await this.filterAllowedForSync(allMissingOps);
3105
3452
  this.deltaSentOpIds = missingOps.map((op) => op.id);
3106
3453
  if (missingOps.length === 0) {
3107
3454
  const messageId = generateMessageId();
@@ -3176,11 +3523,34 @@ var SyncEngine = class {
3176
3523
  return missing;
3177
3524
  }
3178
3525
  async handleOperationBatch(msg) {
3526
+ const isDeliveryBatch = msg.maxDeliverySequence !== void 0;
3527
+ if (isDeliveryBatch) {
3528
+ const base = msg.baseDeliverySequence ?? 0;
3529
+ if (base > this.deliveryWatermark) {
3530
+ const gapKey = `${this.deliveryWatermark}:${base}`;
3531
+ this.deliveryGapRepeatCount = this.lastDeliveryGapKey === gapKey ? this.deliveryGapRepeatCount + 1 : 1;
3532
+ this.lastDeliveryGapKey = gapKey;
3533
+ this.emitter?.emit({
3534
+ type: "sync:delivery-gap",
3535
+ expectedBase: this.deliveryWatermark,
3536
+ receivedBase: base,
3537
+ currentWatermark: this.deliveryWatermark,
3538
+ messageId: msg.messageId,
3539
+ repeatCount: this.deliveryGapRepeatCount
3540
+ });
3541
+ return;
3542
+ }
3543
+ if (base < this.deliveryWatermark) {
3544
+ this.sendDeliveryAck(msg.messageId, this.deliveryWatermark);
3545
+ return;
3546
+ }
3547
+ }
3179
3548
  const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
3180
3549
  const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
3181
- const inScopeOps = operations.filter((op) => this.operationAllowedForSync(op));
3550
+ const inScopeOps = await this.filterAllowedForSync(operations);
3182
3551
  const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
3183
3552
  const transforms = this.config.operationTransforms ?? [];
3553
+ let fullyApplied = true;
3184
3554
  for (const op of inScopeOps) {
3185
3555
  const transformed = transforms.length > 0 ? applyOperationTransforms(op, targetSchemaVersion, transforms) : op;
3186
3556
  if (transformed === null) {
@@ -3192,6 +3562,7 @@ var SyncEngine = class {
3192
3562
  this.emitApplyFailure(transformed, result);
3193
3563
  }
3194
3564
  } catch (error) {
3565
+ fullyApplied = false;
3195
3566
  if (error instanceof ClockDriftError) {
3196
3567
  this.emitApplyFailure(transformed, "rejected", {
3197
3568
  code: APPLY_FAILURE_CODES.CLOCK_DRIFT,
@@ -3202,11 +3573,8 @@ var SyncEngine = class {
3202
3573
  }
3203
3574
  const message = error instanceof Error ? error.message : "Apply failed";
3204
3575
  const code = error instanceof SyncError4 ? error.code : error instanceof KoraError2 ? error.code : error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : APPLY_FAILURE_CODES.APPLY_FAILED;
3205
- this.emitApplyFailure(transformed, "rejected", {
3206
- code,
3207
- message,
3208
- retriable: code !== APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY
3209
- });
3576
+ const retriable = code !== APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY;
3577
+ this.emitApplyFailure(transformed, "rejected", { code, message, retriable });
3210
3578
  }
3211
3579
  }
3212
3580
  if (inScopeOps.length > 0) {
@@ -3217,18 +3585,29 @@ var SyncEngine = class {
3217
3585
  batchSize: inScopeOps.length
3218
3586
  });
3219
3587
  }
3220
- const lastOp = operations[operations.length - 1];
3221
- const ack = {
3222
- type: "acknowledgment",
3223
- messageId: generateMessageId(),
3224
- acknowledgedMessageId: msg.messageId,
3225
- lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3226
- };
3227
- this.transport.send(ack);
3228
- this.emitter?.emit({
3229
- type: "sync:acknowledged",
3230
- sequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3231
- });
3588
+ const deliveryFullyApplied = isDeliveryBatch && fullyApplied;
3589
+ if (!isDeliveryBatch || deliveryFullyApplied) {
3590
+ const lastOp = operations[operations.length - 1];
3591
+ const ack = {
3592
+ type: "acknowledgment",
3593
+ messageId: generateMessageId(),
3594
+ acknowledgedMessageId: msg.messageId,
3595
+ lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0,
3596
+ ...deliveryFullyApplied && msg.maxDeliverySequence !== void 0 ? { deliverySequence: msg.maxDeliverySequence } : {}
3597
+ };
3598
+ this.transport.send(ack);
3599
+ this.emitter?.emit({
3600
+ type: "sync:acknowledged",
3601
+ sequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3602
+ });
3603
+ }
3604
+ if (isDeliveryBatch && fullyApplied && msg.maxDeliverySequence !== void 0) {
3605
+ this.deliveryGapRepeatCount = 0;
3606
+ this.lastDeliveryGapKey = null;
3607
+ this.deliveryWatermark = msg.maxDeliverySequence;
3608
+ this.setViewWatermark(this.deliverySignature(), this.deliveryWatermark);
3609
+ await this.persistDeliveryWatermark(this.deliveryWatermark);
3610
+ }
3232
3611
  if (this.state === "syncing") {
3233
3612
  this.deltaBatchesReceived++;
3234
3613
  const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
@@ -3236,15 +3615,17 @@ var SyncEngine = class {
3236
3615
  this.initialSyncTotalBatches = msg.totalBatches;
3237
3616
  }
3238
3617
  this.metricsCollector.updateInitialSyncProgress(this.deltaBatchesReceived, totalBatches);
3239
- const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
3240
- inScopeOps.length > 0 ? inScopeOps : operations,
3241
- msg.batchIndex
3242
- );
3243
- if (cursorFromBatch) {
3244
- this.resumeDeltaCursor = cursorFromBatch;
3245
- await this.persistDeltaCursor(cursorFromBatch);
3618
+ if (!isDeliveryBatch) {
3619
+ const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
3620
+ inScopeOps.length > 0 ? inScopeOps : operations,
3621
+ msg.batchIndex
3622
+ );
3623
+ if (cursorFromBatch) {
3624
+ this.resumeDeltaCursor = cursorFromBatch;
3625
+ await this.persistDeltaCursor(cursorFromBatch);
3626
+ }
3246
3627
  }
3247
- if (msg.isFinal) {
3628
+ if (msg.isFinal && (!isDeliveryBatch || fullyApplied)) {
3248
3629
  this.deltaReceiveComplete = true;
3249
3630
  this.resumeDeltaCursor = null;
3250
3631
  await this.persistDeltaCursor(null);
@@ -3253,26 +3634,51 @@ var SyncEngine = class {
3253
3634
  }
3254
3635
  }
3255
3636
  }
3256
- handleAcknowledgment(msg) {
3637
+ /** Acknowledge a delivery batch (used for duplicates) without re-applying it. */
3638
+ sendDeliveryAck(acknowledgedMessageId, deliverySequence) {
3639
+ const ack = {
3640
+ type: "acknowledgment",
3641
+ messageId: generateMessageId(),
3642
+ acknowledgedMessageId,
3643
+ lastSequenceNumber: 0,
3644
+ deliverySequence
3645
+ };
3646
+ this.transport.send(ack);
3647
+ }
3648
+ async handleAcknowledgment(msg) {
3649
+ this.clearOutboundAckTimer();
3257
3650
  if (this.state === "syncing" && this.config.strictHandshake) {
3258
3651
  this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
3259
3652
  this.markDeltaSendCompleteIfReady();
3260
3653
  }
3261
3654
  if (this.currentBatch) {
3262
- this.outboundQueue.acknowledge(this.currentBatch.batchId);
3655
+ if (this.currentBatchRequiresPartialAck) {
3656
+ await this.outboundQueue.acknowledgeThrough(
3657
+ this.currentBatch.batchId,
3658
+ msg.lastSequenceNumber
3659
+ );
3660
+ } else {
3661
+ await this.outboundQueue.acknowledge(this.currentBatch.batchId);
3662
+ }
3263
3663
  this.currentBatch = null;
3664
+ this.currentBatchRequiresPartialAck = false;
3264
3665
  const now = Date.now();
3265
3666
  this.lastSyncedAt = now;
3266
3667
  this.lastSuccessfulPush = now;
3267
3668
  }
3268
- void this.advanceLastAckedForLocalNode(msg.lastSequenceNumber).then(
3269
- () => this.refreshPendingCount()
3270
- );
3669
+ await this.advanceLastAckedForLocalNode(msg.lastSequenceNumber);
3670
+ await this.refreshPendingCount();
3271
3671
  if (this.state === "streaming" && this.outboundQueue.hasOperations) {
3272
3672
  this.flushQueue();
3273
3673
  }
3274
3674
  }
3275
3675
  handleError(msg) {
3676
+ this.clearOutboundAckTimer();
3677
+ if (this.currentBatch) {
3678
+ this.outboundQueue.returnBatch(this.currentBatch.batchId);
3679
+ this.currentBatch = null;
3680
+ this.currentBatchRequiresPartialAck = false;
3681
+ }
3276
3682
  this.transitionTo("error");
3277
3683
  if (msg.code === "AUTH_FAILED") {
3278
3684
  this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
@@ -3291,6 +3697,64 @@ var SyncEngine = class {
3291
3697
  this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
3292
3698
  this.transitionTo("disconnected");
3293
3699
  }
3700
+ /**
3701
+ * Handle a per-operation rejection: divert the op out of the pending outbound
3702
+ * queue (so it is never retried or resurrected on reconnect), record it in the
3703
+ * durable rejected store (so it is kept and explainable), and emit an event so
3704
+ * the app can reconcile. Unlike {@link handleError}, this is a normal per-op
3705
+ * signal, so the connection stays up.
3706
+ */
3707
+ async handleOperationRejected(msg) {
3708
+ if (msg.retriable) {
3709
+ this.currentBatchRequiresPartialAck = true;
3710
+ await this.refreshPendingCount();
3711
+ this.emitter?.emit({
3712
+ type: "sync:operation-rejected",
3713
+ operationId: msg.operationId,
3714
+ collection: msg.collection,
3715
+ recordId: msg.recordId,
3716
+ code: msg.code,
3717
+ message: msg.message,
3718
+ retriable: true
3719
+ });
3720
+ return;
3721
+ }
3722
+ await this.outboundQueue.reject(msg.operationId);
3723
+ await this.rejectedStorage.record({
3724
+ operationId: msg.operationId,
3725
+ collection: msg.collection,
3726
+ recordId: msg.recordId,
3727
+ code: msg.code,
3728
+ message: msg.message,
3729
+ retriable: msg.retriable,
3730
+ rejectedAt: Date.now()
3731
+ });
3732
+ await this.refreshPendingCount();
3733
+ this.emitter?.emit({
3734
+ type: "sync:operation-rejected",
3735
+ operationId: msg.operationId,
3736
+ collection: msg.collection,
3737
+ recordId: msg.recordId,
3738
+ code: msg.code,
3739
+ message: msg.message,
3740
+ retriable: msg.retriable
3741
+ });
3742
+ }
3743
+ /**
3744
+ * Every operation the server rejected that has not yet been reconciled. The app
3745
+ * uses this (alongside the `sync:operation-rejected` event) to surface failed
3746
+ * submissions and decide whether to roll back or resubmit.
3747
+ */
3748
+ getRejectedOperations() {
3749
+ return this.rejectedStorage.list();
3750
+ }
3751
+ /**
3752
+ * Forget rejected operations by id once the app has reconciled them (rolled the
3753
+ * optimistic write back or resubmitted a corrected op).
3754
+ */
3755
+ clearRejectedOperations(operationIds) {
3756
+ return this.rejectedStorage.remove(operationIds);
3757
+ }
3294
3758
  async checkDeltaComplete() {
3295
3759
  if (!this.deltaSendComplete || !this.deltaReceiveComplete) {
3296
3760
  return;
@@ -3389,7 +3853,7 @@ var SyncEngine = class {
3389
3853
  return;
3390
3854
  }
3391
3855
  const ops = await this.store.getOperationRange(localNodeId, 1, localSeq);
3392
- const inScope = ops.filter((op) => this.operationAllowedForSync(op));
3856
+ const inScope = await this.filterAllowedForSync(ops);
3393
3857
  for (const op of inScope) {
3394
3858
  await this.outboundQueue.enqueue(op);
3395
3859
  }
@@ -3400,6 +3864,7 @@ var SyncEngine = class {
3400
3864
  const batch = this.outboundQueue.takeBatch(this.batchSize);
3401
3865
  if (!batch) return;
3402
3866
  this.currentBatch = batch;
3867
+ this.currentBatchRequiresPartialAck = false;
3403
3868
  if (this.encryptor) {
3404
3869
  this.encryptor.encryptBatch(batch.operations).then(
3405
3870
  (encrypted) => {
@@ -3412,6 +3877,7 @@ var SyncEngine = class {
3412
3877
  batchIndex: 0
3413
3878
  };
3414
3879
  this.transport.send(batchMsg);
3880
+ this.startOutboundAckTimer(batch.batchId);
3415
3881
  this.emitter?.emit({
3416
3882
  type: "sync:sent",
3417
3883
  operations: batch.operations,
@@ -3421,6 +3887,7 @@ var SyncEngine = class {
3421
3887
  (err) => {
3422
3888
  this.outboundQueue.returnBatch(batch.batchId);
3423
3889
  this.currentBatch = null;
3890
+ this.currentBatchRequiresPartialAck = false;
3424
3891
  this.emitter?.emit({
3425
3892
  type: "sync:disconnected",
3426
3893
  reason: err instanceof Error ? err.message : "Encryption failed"
@@ -3437,6 +3904,7 @@ var SyncEngine = class {
3437
3904
  batchIndex: 0
3438
3905
  };
3439
3906
  this.transport.send(batchMsg);
3907
+ this.startOutboundAckTimer(batch.batchId);
3440
3908
  this.emitter?.emit({
3441
3909
  type: "sync:sent",
3442
3910
  operations: batch.operations,
@@ -3444,6 +3912,34 @@ var SyncEngine = class {
3444
3912
  });
3445
3913
  }
3446
3914
  }
3915
+ startOutboundAckTimer(batchId) {
3916
+ this.clearOutboundAckTimer();
3917
+ if (this.outboundAckTimeoutMs <= 0) return;
3918
+ this.outboundAckTimer = setTimeout(() => {
3919
+ void this.handleOutboundAckTimeout(batchId);
3920
+ }, this.outboundAckTimeoutMs);
3921
+ }
3922
+ clearOutboundAckTimer() {
3923
+ if (!this.outboundAckTimer) return;
3924
+ clearTimeout(this.outboundAckTimer);
3925
+ this.outboundAckTimer = null;
3926
+ }
3927
+ async handleOutboundAckTimeout(batchId) {
3928
+ if (!this.currentBatch || this.currentBatch.batchId !== batchId) return;
3929
+ this.outboundQueue.returnBatch(batchId);
3930
+ this.currentBatch = null;
3931
+ this.currentBatchRequiresPartialAck = false;
3932
+ this.clearOutboundAckTimer();
3933
+ await this.refreshPendingCount();
3934
+ if (this.state === "disconnected") return;
3935
+ const reason = `Timed out waiting for acknowledgment of outbound batch ${batchId}`;
3936
+ try {
3937
+ await this.transport.disconnect();
3938
+ } finally {
3939
+ this.ensureDisconnected();
3940
+ this.emitter?.emit({ type: "sync:disconnected", reason });
3941
+ }
3942
+ }
3447
3943
  handleAwarenessUpdate(msg) {
3448
3944
  const awarenessMessage = {
3449
3945
  type: "awareness",
@@ -3453,9 +3949,11 @@ var SyncEngine = class {
3453
3949
  this.awarenessManager.handleRemoteMessage(awarenessMessage);
3454
3950
  }
3455
3951
  handleTransportClose(reason) {
3952
+ this.clearOutboundAckTimer();
3456
3953
  if (this.currentBatch) {
3457
3954
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
3458
3955
  this.currentBatch = null;
3956
+ this.currentBatchRequiresPartialAck = false;
3459
3957
  }
3460
3958
  if (this.schemaBlocked) {
3461
3959
  return;
@@ -3487,11 +3985,40 @@ var SyncEngine = class {
3487
3985
  this.serializer.setWireFormat(format);
3488
3986
  }
3489
3987
  }
3490
- operationAllowedForSync(op) {
3491
- if (!operationMatchesScope(op, this.activeScope)) {
3988
+ async operationAllowedForSync(op) {
3989
+ if (this.matchesScopeAndSubsets(op)) {
3990
+ return true;
3991
+ }
3992
+ const fullRecord = await this.readRecordForBackfill(op);
3993
+ if (!fullRecord) {
3994
+ return false;
3995
+ }
3996
+ return this.matchesScopeAndSubsets(op, fullRecord);
3997
+ }
3998
+ matchesScopeAndSubsets(op, fullRecord) {
3999
+ if (!operationMatchesScope(op, this.activeScope, fullRecord)) {
3492
4000
  return false;
3493
4001
  }
3494
- return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets());
4002
+ return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets(), fullRecord);
4003
+ }
4004
+ async readRecordForBackfill(op) {
4005
+ if (!this.store.readRecordFields) {
4006
+ return null;
4007
+ }
4008
+ try {
4009
+ return await this.store.readRecordFields(op.collection, op.recordId);
4010
+ } catch {
4011
+ return null;
4012
+ }
4013
+ }
4014
+ async filterAllowedForSync(ops) {
4015
+ const allowed = [];
4016
+ for (const op of ops) {
4017
+ if (await this.operationAllowedForSync(op)) {
4018
+ allowed.push(op);
4019
+ }
4020
+ }
4021
+ return allowed;
3495
4022
  }
3496
4023
  async persistDeltaCursor(cursor) {
3497
4024
  if (!this.syncState?.saveDeltaCursor) {
@@ -3499,6 +4026,12 @@ var SyncEngine = class {
3499
4026
  }
3500
4027
  await this.syncState.saveDeltaCursor(cursor);
3501
4028
  }
4029
+ async persistDeliveryWatermark(watermark, signature = this.deliverySignature()) {
4030
+ if (!this.syncState?.saveDeliveryWatermark) {
4031
+ return;
4032
+ }
4033
+ await this.syncState.saveDeliveryWatermark(signature, watermark);
4034
+ }
3502
4035
  scheduleQuerySubsetReconnect() {
3503
4036
  if (this.querySubsetReconnectTimer) {
3504
4037
  clearTimeout(this.querySubsetReconnectTimer);
@@ -3511,8 +4044,7 @@ var SyncEngine = class {
3511
4044
  }, 500);
3512
4045
  }
3513
4046
  async reconnectForQuerySubsets() {
3514
- await this.stop();
3515
- await this.start();
4047
+ await this.reconnect();
3516
4048
  }
3517
4049
  };
3518
4050
  function awarenessStatesToWire(states) {
@@ -4193,6 +4725,7 @@ function fromBase642(str) {
4193
4725
  // src/reactivity/sync-status-controller.ts
4194
4726
  var OFFLINE_SYNC_STATUS = Object.freeze({
4195
4727
  status: "offline",
4728
+ reconnecting: false,
4196
4729
  pendingOperations: 0,
4197
4730
  lastSyncedAt: null,
4198
4731
  lastSuccessfulPush: null,
@@ -4210,6 +4743,7 @@ var SYNC_STATUS_EVENT_TYPES = [
4210
4743
  "sync:received",
4211
4744
  "sync:acknowledged",
4212
4745
  "sync:apply-failed",
4746
+ "sync:delivery-gap",
4213
4747
  "sync:diagnostics",
4214
4748
  "sync:initial-sync-progress"
4215
4749
  ];
@@ -4355,6 +4889,7 @@ export {
4355
4889
  InvalidScopeError,
4356
4890
  JsonMessageSerializer,
4357
4891
  KeyDerivationError,
4892
+ MemoryRejectedOperationStorage,
4358
4893
  NegotiatedMessageSerializer,
4359
4894
  OFFLINE_SYNC_STATUS,
4360
4895
  OutboundQueue,
@@ -4393,6 +4928,7 @@ export {
4393
4928
  isHandshakeMessage,
4394
4929
  isHandshakeResponseMessage,
4395
4930
  isOperationBatchMessage,
4931
+ isOperationRejectedMessage,
4396
4932
  isSchemaMismatchReject,
4397
4933
  isSyncMessage,
4398
4934
  isYjsDocUpdateMessage,