@korajs/sync 1.0.0-beta.1 → 1.0.0-beta.11

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,13 +14,29 @@ var SYNC_STATES = [
14
14
  ];
15
15
  var SYNC_STATUSES = [
16
16
  "connected",
17
+ "reconnecting",
17
18
  "syncing",
18
19
  "synced",
19
20
  "offline",
20
21
  "clock-error",
21
22
  "error",
22
- "schema-mismatch"
23
+ "schema-mismatch",
24
+ "auth-required"
23
25
  ];
26
+ var MemoryRejectedOperationStorage = class {
27
+ rejected = /* @__PURE__ */ new Map();
28
+ async record(rejected) {
29
+ this.rejected.set(rejected.operationId, rejected);
30
+ }
31
+ async list() {
32
+ return [...this.rejected.values()];
33
+ }
34
+ async remove(operationIds) {
35
+ for (const id of operationIds) {
36
+ this.rejected.delete(id);
37
+ }
38
+ }
39
+ };
24
40
  var ScopeViolationError = class extends KoraError {
25
41
  constructor(operationId, collection, scope, message) {
26
42
  super(
@@ -53,12 +69,19 @@ function operationMatchesScope(op, scopeMap, fullRecord) {
53
69
  const snapshot = buildSnapshot(op, fullRecord ?? void 0);
54
70
  if (!snapshot) return false;
55
71
  for (const [field, expected] of Object.entries(collectionScope)) {
56
- if (snapshot[field] !== expected) {
72
+ if (!matchesPredicate(snapshot[field], expected)) {
57
73
  return false;
58
74
  }
59
75
  }
60
76
  return true;
61
77
  }
78
+ function matchesPredicate(actual, expected) {
79
+ if (expected && typeof expected === "object" && !Array.isArray(expected) && "$in" in expected) {
80
+ const values = expected.$in;
81
+ return Array.isArray(values) && values.some((value) => Object.is(actual, value));
82
+ }
83
+ return Object.is(actual, expected);
84
+ }
62
85
  function filterOperationsByScope(operations, scopeMap) {
63
86
  if (!scopeMap) return operations;
64
87
  return operations.filter((op) => operationMatchesScope(op, scopeMap));
@@ -115,24 +138,77 @@ function operationMatchesQuerySubsets(op, subsets, fullRecord) {
115
138
  if (collectionSubsets.length === 0) {
116
139
  return true;
117
140
  }
141
+ if (collectionSubsets.some((subset) => Object.keys(subset.where).length === 0)) {
142
+ return true;
143
+ }
118
144
  const snapshot = buildSnapshot2(op, fullRecord);
119
145
  if (!snapshot) {
120
146
  return false;
121
147
  }
122
148
  return collectionSubsets.some((subset) => recordMatchesWhere(snapshot, subset.where));
123
149
  }
150
+ function stableValue(value) {
151
+ if (Array.isArray(value)) return value.map(stableValue);
152
+ if (value && typeof value === "object") {
153
+ return Object.fromEntries(
154
+ Object.entries(value).filter(([, entry]) => entry !== void 0).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => [key, stableValue(entry)])
155
+ );
156
+ }
157
+ return value;
158
+ }
159
+ function whereContains(broad, narrow) {
160
+ return Object.entries(broad).every(
161
+ ([field, expected]) => JSON.stringify(stableValue(narrow[field])) === JSON.stringify(stableValue(expected))
162
+ );
163
+ }
124
164
  function dedupeQuerySubsets(subsets) {
125
- const seen = /* @__PURE__ */ new Set();
165
+ const normalized = subsets.map((subset) => ({
166
+ collection: subset.collection,
167
+ where: stableValue(subset.where)
168
+ }));
126
169
  const result = [];
127
- for (const subset of subsets) {
128
- const key = `${subset.collection}:${JSON.stringify(subset.where)}`;
129
- if (seen.has(key)) {
170
+ for (const candidate of normalized) {
171
+ if (normalized.some(
172
+ (other) => other !== candidate && other.collection === candidate.collection && whereContains(other.where, candidate.where) && (Object.keys(other.where).length < Object.keys(candidate.where).length || JSON.stringify(other.where) === JSON.stringify(candidate.where))
173
+ )) {
174
+ const duplicateAlreadyKept = result.some(
175
+ (item) => item.collection === candidate.collection && JSON.stringify(item.where) === JSON.stringify(candidate.where)
176
+ );
177
+ if (duplicateAlreadyKept || normalized.some(
178
+ (other) => other !== candidate && other.collection === candidate.collection && Object.keys(other.where).length < Object.keys(candidate.where).length && whereContains(other.where, candidate.where)
179
+ ))
180
+ continue;
181
+ }
182
+ if (!result.some(
183
+ (item) => item.collection === candidate.collection && JSON.stringify(item.where) === JSON.stringify(candidate.where)
184
+ ))
185
+ result.push(candidate);
186
+ }
187
+ return result.sort(
188
+ (a, b) => `${a.collection}:${JSON.stringify(a.where)}`.localeCompare(
189
+ `${b.collection}:${JSON.stringify(b.where)}`
190
+ )
191
+ );
192
+ }
193
+ function querySubsetContains(broad, narrow) {
194
+ const collections = /* @__PURE__ */ new Set([
195
+ ...broad.map((subset) => subset.collection),
196
+ ...narrow.map((subset) => subset.collection)
197
+ ]);
198
+ for (const collection of collections) {
199
+ const broadForCollection = broad.filter((subset) => subset.collection === collection);
200
+ const narrowForCollection = narrow.filter((subset) => subset.collection === collection);
201
+ if (narrowForCollection.length === 0) {
202
+ if (broadForCollection.length > 0) return false;
130
203
  continue;
131
204
  }
132
- seen.add(key);
133
- result.push(subset);
205
+ if (broadForCollection.length === 0) continue;
206
+ if (!narrowForCollection.every(
207
+ (candidate) => broadForCollection.some((other) => whereContains(other.where, candidate.where))
208
+ ))
209
+ return false;
134
210
  }
135
- return result;
211
+ return true;
136
212
  }
137
213
 
138
214
  // src/delta/delta-cursor.ts
@@ -218,6 +294,8 @@ function isSyncMessage(value) {
218
294
  return isAcknowledgmentMessage(value);
219
295
  case "error":
220
296
  return isErrorMessage(value);
297
+ case "operation-rejected":
298
+ return isOperationRejectedMessage(value);
221
299
  case "awareness-update":
222
300
  return isAwarenessUpdateMessage(value);
223
301
  case "yjs-doc-update":
@@ -257,6 +335,11 @@ function isErrorMessage(value) {
257
335
  const msg = value;
258
336
  return msg.type === "error" && typeof msg.messageId === "string" && typeof msg.code === "string" && typeof msg.message === "string" && typeof msg.retriable === "boolean";
259
337
  }
338
+ function isOperationRejectedMessage(value) {
339
+ if (typeof value !== "object" || value === null) return false;
340
+ const msg = value;
341
+ 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";
342
+ }
260
343
  function isAwarenessUpdateMessage(value) {
261
344
  if (typeof value !== "object" || value === null) return false;
262
345
  const msg = value;
@@ -507,7 +590,8 @@ function toProtoEnvelope(message) {
507
590
  })),
508
591
  schemaVersion: message.schemaVersion,
509
592
  authToken: message.authToken,
510
- supportedWireFormats: message.supportedWireFormats
593
+ supportedWireFormats: message.supportedWireFormats,
594
+ ...message.lastDeliverySequence !== void 0 ? { lastDeliverySequence: message.lastDeliverySequence } : {}
511
595
  };
512
596
  case "handshake-response":
513
597
  return {
@@ -523,7 +607,8 @@ function toProtoEnvelope(message) {
523
607
  rejectReason: message.rejectReason,
524
608
  selectedWireFormat: message.selectedWireFormat,
525
609
  serverTime: message.serverTime,
526
- blobStorageEnabled: message.blobStorageEnabled
610
+ blobStorageEnabled: message.blobStorageEnabled,
611
+ ...message.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: message.serverMaxDeliverySequence } : {}
527
612
  };
528
613
  case "operation-batch":
529
614
  return {
@@ -531,14 +616,17 @@ function toProtoEnvelope(message) {
531
616
  messageId: message.messageId,
532
617
  operations: message.operations.map(serializeProtoOperation),
533
618
  isFinal: message.isFinal,
534
- batchIndex: message.batchIndex
619
+ batchIndex: message.batchIndex,
620
+ ...message.baseDeliverySequence !== void 0 ? { baseDeliverySequence: message.baseDeliverySequence } : {},
621
+ ...message.maxDeliverySequence !== void 0 ? { maxDeliverySequence: message.maxDeliverySequence } : {}
535
622
  };
536
623
  case "acknowledgment":
537
624
  return {
538
625
  type: message.type,
539
626
  messageId: message.messageId,
540
627
  acknowledgedMessageId: message.acknowledgedMessageId,
541
- lastSequenceNumber: message.lastSequenceNumber
628
+ lastSequenceNumber: message.lastSequenceNumber,
629
+ ...message.deliverySequence !== void 0 ? { deliverySequence: message.deliverySequence } : {}
542
630
  };
543
631
  case "error":
544
632
  return {
@@ -548,6 +636,17 @@ function toProtoEnvelope(message) {
548
636
  errorMessage: message.message,
549
637
  retriable: message.retriable
550
638
  };
639
+ case "operation-rejected":
640
+ return {
641
+ type: message.type,
642
+ messageId: message.messageId,
643
+ operationId: message.operationId,
644
+ collection: message.collection,
645
+ recordId: message.recordId,
646
+ errorCode: message.code,
647
+ errorMessage: message.message,
648
+ retriable: message.retriable
649
+ };
551
650
  case "awareness-update":
552
651
  case "yjs-doc-update":
553
652
  return {
@@ -592,7 +691,8 @@ function fromProtoEnvelope(envelope) {
592
691
  authToken: envelope.authToken,
593
692
  supportedWireFormats: envelope.supportedWireFormats?.filter(
594
693
  (format) => format === "json" || format === "protobuf"
595
- )
694
+ ),
695
+ ...envelope.lastDeliverySequence !== void 0 ? { lastDeliverySequence: envelope.lastDeliverySequence } : {}
596
696
  };
597
697
  case "handshake-response":
598
698
  return {
@@ -609,7 +709,8 @@ function fromProtoEnvelope(envelope) {
609
709
  // Preserve the server's wall-clock time so clock-skew detection and
610
710
  // automatic timestamp rebase work over the protobuf wire, not just JSON.
611
711
  ...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {},
612
- ...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {}
712
+ ...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {},
713
+ ...envelope.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: envelope.serverMaxDeliverySequence } : {}
613
714
  };
614
715
  case "operation-batch":
615
716
  return {
@@ -617,14 +718,17 @@ function fromProtoEnvelope(envelope) {
617
718
  messageId: envelope.messageId,
618
719
  operations: (envelope.operations ?? []).map(deserializeProtoOperation),
619
720
  isFinal: envelope.isFinal ?? false,
620
- batchIndex: envelope.batchIndex ?? 0
721
+ batchIndex: envelope.batchIndex ?? 0,
722
+ ...envelope.baseDeliverySequence !== void 0 ? { baseDeliverySequence: envelope.baseDeliverySequence } : {},
723
+ ...envelope.maxDeliverySequence !== void 0 ? { maxDeliverySequence: envelope.maxDeliverySequence } : {}
621
724
  };
622
725
  case "acknowledgment":
623
726
  return {
624
727
  type: "acknowledgment",
625
728
  messageId: envelope.messageId,
626
729
  acknowledgedMessageId: envelope.acknowledgedMessageId ?? "",
627
- lastSequenceNumber: envelope.lastSequenceNumber ?? 0
730
+ lastSequenceNumber: envelope.lastSequenceNumber ?? 0,
731
+ ...envelope.deliverySequence !== void 0 ? { deliverySequence: envelope.deliverySequence } : {}
628
732
  };
629
733
  case "error":
630
734
  return {
@@ -634,6 +738,17 @@ function fromProtoEnvelope(envelope) {
634
738
  message: envelope.errorMessage ?? "Unknown error",
635
739
  retriable: envelope.retriable ?? false
636
740
  };
741
+ case "operation-rejected":
742
+ return {
743
+ type: "operation-rejected",
744
+ messageId: envelope.messageId,
745
+ operationId: envelope.operationId ?? "",
746
+ collection: envelope.collection ?? "",
747
+ recordId: envelope.recordId ?? "",
748
+ code: envelope.errorCode ?? "REJECTED",
749
+ message: envelope.errorMessage ?? "",
750
+ retriable: envelope.retriable ?? false
751
+ };
637
752
  case "blob-chunk-request":
638
753
  return {
639
754
  type: "blob-chunk-request",
@@ -808,6 +923,21 @@ function encodeEnvelope(envelope) {
808
923
  if (envelope.hasBytes !== void 0) writer.uint32(184).bool(envelope.hasBytes);
809
924
  if (envelope.blobStorageEnabled !== void 0)
810
925
  writer.uint32(200).bool(envelope.blobStorageEnabled);
926
+ if (envelope.operationId && envelope.operationId.length > 0)
927
+ writer.uint32(210).string(envelope.operationId);
928
+ if (envelope.collection && envelope.collection.length > 0)
929
+ writer.uint32(218).string(envelope.collection);
930
+ if (envelope.recordId && envelope.recordId.length > 0)
931
+ writer.uint32(226).string(envelope.recordId);
932
+ if (envelope.lastDeliverySequence !== void 0)
933
+ writer.uint32(232).int64(envelope.lastDeliverySequence);
934
+ if (envelope.baseDeliverySequence !== void 0)
935
+ writer.uint32(240).int64(envelope.baseDeliverySequence);
936
+ if (envelope.maxDeliverySequence !== void 0)
937
+ writer.uint32(248).int64(envelope.maxDeliverySequence);
938
+ if (envelope.deliverySequence !== void 0) writer.uint32(256).int64(envelope.deliverySequence);
939
+ if (envelope.serverMaxDeliverySequence !== void 0)
940
+ writer.uint32(264).int64(envelope.serverMaxDeliverySequence);
811
941
  return writer.finish();
812
942
  }
813
943
  function decodeEnvelope(bytes) {
@@ -894,6 +1024,30 @@ function decodeEnvelope(bytes) {
894
1024
  case 25:
895
1025
  envelope.blobStorageEnabled = reader.bool();
896
1026
  break;
1027
+ case 26:
1028
+ envelope.operationId = reader.string();
1029
+ break;
1030
+ case 27:
1031
+ envelope.collection = reader.string();
1032
+ break;
1033
+ case 28:
1034
+ envelope.recordId = reader.string();
1035
+ break;
1036
+ case 29:
1037
+ envelope.lastDeliverySequence = longToNumber(reader.int64());
1038
+ break;
1039
+ case 30:
1040
+ envelope.baseDeliverySequence = longToNumber(reader.int64());
1041
+ break;
1042
+ case 31:
1043
+ envelope.maxDeliverySequence = longToNumber(reader.int64());
1044
+ break;
1045
+ case 32:
1046
+ envelope.deliverySequence = longToNumber(reader.int64());
1047
+ break;
1048
+ case 33:
1049
+ envelope.serverMaxDeliverySequence = longToNumber(reader.int64());
1050
+ break;
897
1051
  default:
898
1052
  reader.skipType(tag & 7);
899
1053
  }
@@ -1393,6 +1547,7 @@ var ChaosTransport = class {
1393
1547
  reorderRate;
1394
1548
  maxLatency;
1395
1549
  random;
1550
+ dropPredicate;
1396
1551
  messageHandler = null;
1397
1552
  reorderBuffer = [];
1398
1553
  pending = [];
@@ -1403,6 +1558,7 @@ var ChaosTransport = class {
1403
1558
  this.reorderRate = config?.reorderRate ?? 0;
1404
1559
  this.maxLatency = config?.maxLatency ?? 0;
1405
1560
  this.random = config?.randomSource ?? Math.random;
1561
+ this.dropPredicate = config?.dropPredicate ?? null;
1406
1562
  }
1407
1563
  async connect(url, options) {
1408
1564
  this.inner.onMessage((msg) => this.handleIncoming(msg));
@@ -1421,6 +1577,9 @@ var ChaosTransport = class {
1421
1577
  return this.inner.disconnect();
1422
1578
  }
1423
1579
  send(message) {
1580
+ if (this.dropPredicate?.(message, "outgoing")) {
1581
+ return;
1582
+ }
1424
1583
  if (this.random() < this.dropRate) {
1425
1584
  return;
1426
1585
  }
@@ -1448,6 +1607,9 @@ var ChaosTransport = class {
1448
1607
  }
1449
1608
  handleIncoming(message) {
1450
1609
  if (!this.messageHandler) return;
1610
+ if (this.dropPredicate?.(message, "incoming")) {
1611
+ return;
1612
+ }
1451
1613
  if (this.random() < this.dropRate) {
1452
1614
  return;
1453
1615
  }
@@ -2400,6 +2562,32 @@ var OutboundQueue = class {
2400
2562
  const ids = ops.map((op) => op.id);
2401
2563
  await this.storage.dequeue(ids);
2402
2564
  }
2565
+ /**
2566
+ * Acknowledge only operations in an in-flight batch whose local sequence number is
2567
+ * covered by the server's ack. Any later operations return to the front of the
2568
+ * queue for retry. This is required when the server accepts a prefix but rejects a
2569
+ * later operation retriably (for example a stale-scope push after auth changed).
2570
+ */
2571
+ async acknowledgeThrough(batchId, lastSequenceNumber) {
2572
+ const ops = this.inFlight.get(batchId);
2573
+ if (!ops) return;
2574
+ this.inFlight.delete(batchId);
2575
+ const acknowledged = ops.filter((op) => op.sequenceNumber <= lastSequenceNumber);
2576
+ const retry = ops.filter((op) => op.sequenceNumber > lastSequenceNumber);
2577
+ if (retry.length > 0) {
2578
+ this.queue.unshift(...retry);
2579
+ if (this.queue.length > 1) {
2580
+ this.queue = topologicalSort(this.queue);
2581
+ }
2582
+ }
2583
+ if (acknowledged.length > 0) {
2584
+ const ids = acknowledged.map((op) => op.id);
2585
+ for (const id of ids) {
2586
+ this.seen.delete(id);
2587
+ }
2588
+ await this.storage.dequeue(ids);
2589
+ }
2590
+ }
2403
2591
  /**
2404
2592
  * Return a failed batch to the front of the queue for retry.
2405
2593
  * Prepends the operations to maintain priority.
@@ -2482,6 +2670,37 @@ var OutboundQueue = class {
2482
2670
  get isInitialized() {
2483
2671
  return this.initialized;
2484
2672
  }
2673
+ /**
2674
+ * Remove a single operation the server permanently rejected — from the queue,
2675
+ * from any in-flight batch, and from durable storage — so a later batch ack or
2676
+ * a reconnect `returnBatch` can never resend or resurrect it. Returns the
2677
+ * removed operation so the caller can record it in a durable rejected store,
2678
+ * or null if it was not present (already acknowledged and removed).
2679
+ */
2680
+ async reject(opId) {
2681
+ let removed = null;
2682
+ for (const [batchId, ops] of this.inFlight) {
2683
+ const idx = ops.findIndex((op) => op.id === opId);
2684
+ if (idx !== -1) {
2685
+ removed = ops[idx] ?? null;
2686
+ ops.splice(idx, 1);
2687
+ if (ops.length === 0) {
2688
+ this.inFlight.delete(batchId);
2689
+ }
2690
+ break;
2691
+ }
2692
+ }
2693
+ if (!removed) {
2694
+ const idx = this.queue.findIndex((op) => op.id === opId);
2695
+ if (idx !== -1) {
2696
+ removed = this.queue[idx] ?? null;
2697
+ this.queue.splice(idx, 1);
2698
+ }
2699
+ }
2700
+ this.seen.delete(opId);
2701
+ await this.storage.dequeue([opId]);
2702
+ return removed;
2703
+ }
2485
2704
  /**
2486
2705
  * Remove operations by id from queue and persistent storage.
2487
2706
  * Used when ops were already sent during handshake delta exchange.
@@ -2500,6 +2719,7 @@ var OutboundQueue = class {
2500
2719
  // src/engine/sync-engine.ts
2501
2720
  var DEFAULT_BATCH_SIZE = 100;
2502
2721
  var DEFAULT_SCHEMA_VERSION = 1;
2722
+ var DEFAULT_OUTBOUND_ACK_TIMEOUT_MS = 3e4;
2503
2723
  var VALID_TRANSITIONS = {
2504
2724
  disconnected: ["connecting"],
2505
2725
  connecting: ["handshaking", "error", "disconnected"],
@@ -2513,6 +2733,17 @@ var nextQuerySubsetId = 0;
2513
2733
  function generateMessageId() {
2514
2734
  return `msg-${Date.now()}-${nextMessageId++}`;
2515
2735
  }
2736
+ var MAX_DELIVERY_VIEW_WATERMARKS = 64;
2737
+ function stableStringify(value) {
2738
+ if (value === null || typeof value !== "object") {
2739
+ return JSON.stringify(value) ?? "null";
2740
+ }
2741
+ if (Array.isArray(value)) {
2742
+ return `[${value.map(stableStringify).join(",")}]`;
2743
+ }
2744
+ 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)}`);
2745
+ return `{${entries.join(",")}}`;
2746
+ }
2516
2747
  var SyncEngine = class {
2517
2748
  state = "disconnected";
2518
2749
  transport;
@@ -2521,7 +2752,9 @@ var SyncEngine = class {
2521
2752
  serializer;
2522
2753
  emitter;
2523
2754
  outboundQueue;
2755
+ rejectedStorage;
2524
2756
  batchSize;
2757
+ outboundAckTimeoutMs;
2525
2758
  encryptor;
2526
2759
  awarenessManager;
2527
2760
  richtextDocChannel;
@@ -2536,11 +2769,21 @@ var SyncEngine = class {
2536
2769
  lastSuccessfulPull = null;
2537
2770
  conflictCount = 0;
2538
2771
  currentBatch = null;
2772
+ currentBatchRequiresPartialAck = false;
2539
2773
  reconnecting = false;
2540
2774
  schemaBlocked = false;
2541
2775
  clockBlocked = false;
2542
2776
  clockSkewMs = null;
2543
2777
  blobStorageEnabled = false;
2778
+ suspensionReason = null;
2779
+ authRejected = false;
2780
+ serverFrontier = null;
2781
+ hasInFlightDeliveryBatch = false;
2782
+ blockedFailure = null;
2783
+ outboundAckTimer = null;
2784
+ startPromise = null;
2785
+ stopPromise = null;
2786
+ reconnectPromise = null;
2544
2787
  // Track delta exchange state
2545
2788
  deltaBatchesReceived = 0;
2546
2789
  deltaReceiveComplete = false;
@@ -2557,10 +2800,32 @@ var SyncEngine = class {
2557
2800
  activeScope;
2558
2801
  /** Live query subsets registered from reactive subscriptions */
2559
2802
  querySubsets = /* @__PURE__ */ new Map();
2803
+ staticQuerySubsets = [];
2560
2804
  querySubsetReconnectTimer = null;
2561
2805
  /** Resume cursor for paginated initial sync (persisted across reconnects) */
2562
2806
  resumeDeltaCursor = null;
2563
2807
  initialSyncTotalBatches = 0;
2808
+ /**
2809
+ * The delivery watermark for the CURRENT sync view: the highest server delivery
2810
+ * sequence up to which every operation in the current view (scope + active query
2811
+ * subscriptions) has been contiguously applied. Advanced only through the server's
2812
+ * gap-free delivery stream (never through live relay), so it is a durable lower bound
2813
+ * on what this client holds for this view. Reported at handshake to resume the stream.
2814
+ *
2815
+ * The watermark is keyed by view: switching views (a scope or subscription change)
2816
+ * saves the current view's watermark and loads the target view's, so returning to a
2817
+ * previously-synced view resumes exactly where it left off instead of re-syncing. This
2818
+ * is correct because a view's watermark is a lower bound specific to that view's
2819
+ * filter; a widened view is simply a different (initially unsynced) view.
2820
+ */
2821
+ deliveryWatermark = 0;
2822
+ /**
2823
+ * In-memory cache of watermark per view signature, preloaded from persistence at start
2824
+ * so a view switch resolves synchronously (no race with the reconnect it triggers).
2825
+ */
2826
+ deliverySignatureWatermarks = /* @__PURE__ */ new Map();
2827
+ deliveryGapRepeatCount = 0;
2828
+ lastDeliveryGapKey = null;
2564
2829
  constructor(options) {
2565
2830
  this.transport = options.transport;
2566
2831
  this.store = options.store;
@@ -2568,11 +2833,13 @@ var SyncEngine = class {
2568
2833
  this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
2569
2834
  this.emitter = options.emitter ?? null;
2570
2835
  this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
2836
+ this.outboundAckTimeoutMs = options.config.outboundAckTimeoutMs ?? DEFAULT_OUTBOUND_ACK_TIMEOUT_MS;
2571
2837
  this.encryptor = options.encryptor ?? null;
2572
2838
  this.syncState = options.syncState ?? null;
2573
2839
  this.activeScope = options.config.scopeMap;
2574
2840
  const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
2575
2841
  this.outboundQueue = new OutboundQueue(queueStorage);
2842
+ this.rejectedStorage = options.rejectedStorage ?? new MemoryRejectedOperationStorage();
2576
2843
  this.metricsCollector = new SyncMetricsCollector(options.metricsConfig);
2577
2844
  if (this.emitter) {
2578
2845
  this.metricsCollector.attachEmitter(this.emitter);
@@ -2612,17 +2879,53 @@ var SyncEngine = class {
2612
2879
  * Start the sync engine: connect → handshake → delta exchange → streaming.
2613
2880
  */
2614
2881
  async start() {
2882
+ if (this.state === "streaming") return;
2883
+ if (this.startPromise) return this.startPromise;
2884
+ this.startPromise = this.startInternal().finally(() => {
2885
+ this.startPromise = null;
2886
+ });
2887
+ return this.startPromise;
2888
+ }
2889
+ async startInternal() {
2890
+ if (this.stopPromise) {
2891
+ await this.stopPromise;
2892
+ }
2893
+ if (this.state === "streaming") return;
2615
2894
  if (this.state !== "disconnected") {
2616
- throw new SyncError4("Cannot start sync engine: not in disconnected state", {
2617
- currentState: this.state
2895
+ await this.stop();
2896
+ }
2897
+ if (this.authRejected) {
2898
+ this.emitter?.emit({
2899
+ type: "sync:suspended",
2900
+ reason: this.suspensionReason ?? "auth-rejected"
2618
2901
  });
2902
+ return;
2619
2903
  }
2904
+ if (this.config.authState) {
2905
+ const authState = await this.config.authState();
2906
+ if (authState.state === "loading" || authState.state === "signed-out") {
2907
+ this.suspensionReason = authState.state === "loading" ? "auth-loading" : "auth-required";
2908
+ this.emitter?.emit({ type: "sync:suspended", reason: this.suspensionReason });
2909
+ return;
2910
+ }
2911
+ }
2912
+ this.suspensionReason = null;
2620
2913
  await this.outboundQueue.initialize();
2621
2914
  if (this.syncState) {
2622
2915
  this.lastAckedServerVector = await this.syncState.loadLastAckedServerVector();
2623
2916
  if (this.syncState.loadDeltaCursor) {
2624
2917
  this.resumeDeltaCursor = await this.syncState.loadDeltaCursor();
2625
2918
  }
2919
+ if (this.syncState.loadAllDeliveryWatermarks) {
2920
+ const all = await this.syncState.loadAllDeliveryWatermarks();
2921
+ for (const [signature, watermark] of Object.entries(all)) {
2922
+ this.deliverySignatureWatermarks.set(signature, watermark);
2923
+ }
2924
+ } else if (this.syncState.loadDeliveryWatermark) {
2925
+ this.deliverySignatureWatermarks.set("", await this.syncState.loadDeliveryWatermark(""));
2926
+ }
2927
+ this.deliveryWatermark = this.deliverySignatureWatermarks.get(this.deliverySignature()) ?? 0;
2928
+ this.evictColdViewWatermarks();
2626
2929
  }
2627
2930
  await this.reconcileOutboundFromOpLog();
2628
2931
  await this.refreshPendingCount();
@@ -2638,7 +2941,12 @@ var SyncEngine = class {
2638
2941
  this.transitionTo("connecting");
2639
2942
  try {
2640
2943
  const authToken = this.config.auth ? (await this.config.auth()).token : void 0;
2944
+ if (this.state !== "connecting") return;
2641
2945
  await this.transport.connect(this.config.url, { authToken });
2946
+ if (this.state !== "connecting") {
2947
+ await this.transport.disconnect();
2948
+ return;
2949
+ }
2642
2950
  this.transitionTo("handshaking");
2643
2951
  const localVector = this.store.getVersionVector();
2644
2952
  const activeQuerySubsets = this.getActiveQuerySubsets();
@@ -2652,7 +2960,11 @@ var SyncEngine = class {
2652
2960
  supportedWireFormats: ["json", "protobuf"],
2653
2961
  ...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
2654
2962
  ...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
2655
- ...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {}
2963
+ ...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {},
2964
+ // Resume the server's gap-free delivery stream from our watermark. A server
2965
+ // that understands it drives server->client sync from delivery sequences; an
2966
+ // older server ignores it and falls back to the version-vector delta.
2967
+ lastDeliverySequence: this.deliveryWatermark
2656
2968
  };
2657
2969
  this.transport.send(handshake);
2658
2970
  } catch (err) {
@@ -2664,11 +2976,20 @@ var SyncEngine = class {
2664
2976
  * Stop the sync engine. Disconnects the transport.
2665
2977
  */
2666
2978
  async stop() {
2979
+ if (this.stopPromise) return this.stopPromise;
2980
+ this.stopPromise = this.stopInternal().finally(() => {
2981
+ this.stopPromise = null;
2982
+ });
2983
+ return this.stopPromise;
2984
+ }
2985
+ async stopInternal() {
2667
2986
  if (this.state === "disconnected") return;
2987
+ this.clearOutboundAckTimer();
2668
2988
  this.awarenessManager.stopCleanupTimer();
2669
2989
  if (this.currentBatch) {
2670
2990
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
2671
2991
  this.currentBatch = null;
2992
+ this.currentBatchRequiresPartialAck = false;
2672
2993
  }
2673
2994
  try {
2674
2995
  await this.transport.disconnect();
@@ -2689,7 +3010,7 @@ var SyncEngine = class {
2689
3010
  * because they should remain local-only and not be sent to the server.
2690
3011
  */
2691
3012
  async pushOperation(op) {
2692
- if (!this.operationAllowedForSync(op)) {
3013
+ if (!await this.operationAllowedForSync(op)) {
2693
3014
  return;
2694
3015
  }
2695
3016
  if (this.syncState) {
@@ -2718,13 +3039,30 @@ var SyncEngine = class {
2718
3039
  getStatus() {
2719
3040
  const pendingOperations = this.syncState ? this.cachedUnsyncedCount : this.outboundQueue.totalPending;
2720
3041
  const base = {
3042
+ phase: this.resolvePhase(),
3043
+ ...this.suspensionReason ? { reason: this.suspensionReason } : {},
3044
+ reconnecting: this.reconnecting,
2721
3045
  pendingOperations,
2722
3046
  lastSyncedAt: this.lastSyncedAt,
2723
3047
  lastSuccessfulPush: this.lastSuccessfulPush,
2724
3048
  lastSuccessfulPull: this.lastSuccessfulPull,
2725
3049
  conflicts: this.conflictCount,
2726
- clockSkewMs: this.clockSkewMs
3050
+ clockSkewMs: this.clockSkewMs,
3051
+ inFlightUploadOperations: this.currentBatch?.operations.length ?? 0,
3052
+ hasInFlightDeliveryBatch: this.hasInFlightDeliveryBatch,
3053
+ activeViewId: this.deliverySignature(),
3054
+ activeViewComplete: this.state === "streaming" && !this.hasInFlightDeliveryBatch && this.blockedFailure === null && (this.serverFrontier === null || this.deliveryWatermark >= this.serverFrontier),
3055
+ initialSync: {
3056
+ complete: this.deltaReceiveComplete && this.deltaSendComplete,
3057
+ receivedBatches: this.deltaBatchesReceived,
3058
+ totalBatches: this.initialSyncTotalBatches || null,
3059
+ progress: this.initialSyncTotalBatches > 0 ? Math.min(1, this.deltaBatchesReceived / this.initialSyncTotalBatches) : null
3060
+ },
3061
+ deliveryWatermark: this.deliveryWatermark,
3062
+ serverFrontier: this.serverFrontier,
3063
+ blockedFailure: this.blockedFailure
2727
3064
  };
3065
+ if (this.suspensionReason) return { ...base, status: "auth-required" };
2728
3066
  switch (this.state) {
2729
3067
  case "disconnected":
2730
3068
  if (this.clockBlocked) {
@@ -2737,7 +3075,7 @@ var SyncEngine = class {
2737
3075
  case "connecting":
2738
3076
  case "handshaking":
2739
3077
  case "syncing":
2740
- return { ...base, status: this.reconnecting ? "offline" : "syncing" };
3078
+ return { ...base, status: this.reconnecting ? "reconnecting" : "syncing" };
2741
3079
  case "streaming":
2742
3080
  return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
2743
3081
  case "error":
@@ -2747,6 +3085,26 @@ var SyncEngine = class {
2747
3085
  return { ...base, status: this.schemaBlocked ? "schema-mismatch" : "error" };
2748
3086
  }
2749
3087
  }
3088
+ resolvePhase() {
3089
+ if (this.suspensionReason) return "suspended";
3090
+ if (this.blockedFailure || this.schemaBlocked || this.clockBlocked) return "blocked";
3091
+ if (this.hasInFlightDeliveryBatch) return "applying";
3092
+ if (this.currentBatch) return "uploading";
3093
+ switch (this.state) {
3094
+ case "disconnected":
3095
+ return "offline";
3096
+ case "connecting":
3097
+ return "connecting";
3098
+ case "handshaking":
3099
+ return "handshaking";
3100
+ case "syncing":
3101
+ return "receiving";
3102
+ case "streaming":
3103
+ return "streaming";
3104
+ case "error":
3105
+ return "blocked";
3106
+ }
3107
+ }
2750
3108
  /**
2751
3109
  * True when the server rejected the client's schema version at handshake.
2752
3110
  * Sync stays blocked until the app schema is upgraded or sync config changes.
@@ -2799,8 +3157,25 @@ var SyncEngine = class {
2799
3157
  await this.refreshPendingCount();
2800
3158
  return this.getStatus().pendingOperations;
2801
3159
  }
2802
- emitApplyFailure(op, result, overrides) {
3160
+ emitApplyFailure(op, result, overrides, blocking = false) {
2803
3161
  const reason = defaultApplyFailureReason(result, overrides);
3162
+ if (blocking || reason.retriable) {
3163
+ const prior = this.blockedFailure;
3164
+ this.blockedFailure = {
3165
+ operationId: op.id,
3166
+ collection: op.collection,
3167
+ recordId: op.recordId,
3168
+ code: reason.code,
3169
+ message: reason.message,
3170
+ retriable: reason.retriable,
3171
+ firstSeenAt: prior?.operationId === op.id ? prior.firstSeenAt : Date.now(),
3172
+ retryCount: prior?.operationId === op.id ? prior.retryCount + 1 : 0
3173
+ };
3174
+ this.emitter?.emit({
3175
+ type: prior?.operationId === op.id ? "sync:apply-retrying" : "sync:apply-blocked",
3176
+ failure: this.blockedFailure
3177
+ });
3178
+ }
2804
3179
  this.emitter?.emit({
2805
3180
  type: "sync:apply-failed",
2806
3181
  operationId: op.id,
@@ -2817,11 +3192,36 @@ var SyncEngine = class {
2817
3192
  */
2818
3193
  async retryNow() {
2819
3194
  if (this.schemaBlocked) return;
3195
+ this.authRejected = false;
3196
+ this.suspensionReason = null;
2820
3197
  if (this.state === "disconnected" || this.state === "error") {
2821
3198
  this.reconnecting = false;
2822
3199
  await this.start();
2823
3200
  }
2824
3201
  }
3202
+ /** Wake a permanent auth suspension after the binding reports a new auth state. */
3203
+ notifyAuthChanged() {
3204
+ this.authRejected = false;
3205
+ this.suspensionReason = null;
3206
+ }
3207
+ /**
3208
+ * Atomically refresh the transport session. Overlapping callers share one
3209
+ * reconnect, in-flight outbound batches are returned to the queue by stop(), and the
3210
+ * next start handshakes with the latest auth token, sync scope, and query subsets.
3211
+ */
3212
+ async reconnect() {
3213
+ if (this.schemaBlocked) return;
3214
+ if (this.reconnectPromise) return this.reconnectPromise;
3215
+ this.reconnecting = false;
3216
+ this.reconnectPromise = this.reconnectInternal().finally(() => {
3217
+ this.reconnectPromise = null;
3218
+ });
3219
+ return this.reconnectPromise;
3220
+ }
3221
+ async reconnectInternal() {
3222
+ await this.stop();
3223
+ await this.start();
3224
+ }
2825
3225
  /**
2826
3226
  * Export a diagnostics snapshot for debugging and support tickets.
2827
3227
  * Contains connection state, timing info, and queue metrics.
@@ -2840,6 +3240,8 @@ var SyncEngine = class {
2840
3240
  pendingOperations: this.outboundQueue.totalPending,
2841
3241
  hasInFlightBatch: this.currentBatch !== null,
2842
3242
  reconnecting: this.reconnecting,
3243
+ deliveryWatermark: this.deliveryWatermark,
3244
+ deliveryGapRepeatCount: this.deliveryGapRepeatCount,
2843
3245
  timestamp: Date.now()
2844
3246
  };
2845
3247
  }
@@ -2868,8 +3270,84 @@ var SyncEngine = class {
2868
3270
  * @param scopeMap - New per-collection scope filters, or undefined to remove scope
2869
3271
  */
2870
3272
  updateScope(scopeMap) {
3273
+ const previousSignature = this.deliverySignature();
2871
3274
  this.activeScope = scopeMap;
2872
3275
  this.config.scopeMap = scopeMap;
3276
+ this.switchDeliveryView(previousSignature);
3277
+ }
3278
+ /**
3279
+ * A stable identifier for the current sync view: the auth/tenant scope plus the set of
3280
+ * active query subscriptions, canonicalized so the same view always produces the same
3281
+ * string. The delivery watermark is keyed by this, so each distinct view resumes from
3282
+ * its own last position.
3283
+ */
3284
+ deliverySignature() {
3285
+ const subsets = this.getActiveQuerySubsets();
3286
+ if (!this.activeScope && subsets.length === 0) {
3287
+ return "";
3288
+ }
3289
+ const normalizedSubsets = subsets.map((s) => `${s.collection}:${stableStringify(s.where)}`).sort();
3290
+ return stableStringify({ scope: this.activeScope ?? null, subsets: normalizedSubsets });
3291
+ }
3292
+ /**
3293
+ * Switch the active delivery watermark to the current view after a scope or subscription
3294
+ * change. The prior view's watermark is saved so returning to it resumes; the new view's
3295
+ * watermark is restored from the in-memory cache (0 for a never-synced view, which then
3296
+ * does a one-time resync under that view). No data is lost: a view's watermark is a lower
3297
+ * bound for that specific view's filter.
3298
+ */
3299
+ switchDeliveryView(previousSignature) {
3300
+ const nextSignature = this.deliverySignature();
3301
+ if (nextSignature === previousSignature) {
3302
+ return;
3303
+ }
3304
+ this.setViewWatermark(previousSignature, this.deliveryWatermark);
3305
+ void this.persistDeliveryWatermark(this.deliveryWatermark, previousSignature);
3306
+ this.deliveryWatermark = this.deliverySignatureWatermarks.get(nextSignature) ?? 0;
3307
+ }
3308
+ /**
3309
+ * Record a view's watermark in the in-memory cache as the most-recently-used entry (the
3310
+ * Map preserves insertion order, so re-inserting moves it to the tail), then evict cold
3311
+ * views past the retention cap. This is the single place the cache is written, so LRU
3312
+ * order and the size bound are always maintained together.
3313
+ */
3314
+ setViewWatermark(signature, watermark) {
3315
+ this.deliverySignatureWatermarks.delete(signature);
3316
+ this.deliverySignatureWatermarks.set(signature, watermark);
3317
+ this.evictColdViewWatermarks();
3318
+ }
3319
+ /**
3320
+ * Trim the per-view watermark cache to `MAX_DELIVERY_VIEW_WATERMARKS`, evicting the
3321
+ * least-recently-used views. The default view ('') and the live view are never evicted
3322
+ * (evicting the live view would force an immediate re-scan of what we are actively
3323
+ * syncing). Each eviction also removes the persisted row, bounding storage. Safe by
3324
+ * construction: an evicted view simply back-fills from 0 (deduplicated) when next seen.
3325
+ */
3326
+ evictColdViewWatermarks() {
3327
+ if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
3328
+ return;
3329
+ }
3330
+ const liveSignature = this.deliverySignature();
3331
+ for (const signature of this.deliverySignatureWatermarks.keys()) {
3332
+ if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
3333
+ break;
3334
+ }
3335
+ if (signature === "" || signature === liveSignature) {
3336
+ continue;
3337
+ }
3338
+ this.deliverySignatureWatermarks.delete(signature);
3339
+ void this.deleteDeliveryWatermark(signature);
3340
+ }
3341
+ }
3342
+ /**
3343
+ * Remove a view's persisted watermark row (best-effort; a persistence layer that omits
3344
+ * the delete simply keeps the row, which is harmless).
3345
+ */
3346
+ async deleteDeliveryWatermark(signature) {
3347
+ if (!this.syncState?.deleteDeliveryWatermark) {
3348
+ return;
3349
+ }
3350
+ await this.syncState.deleteDeliveryWatermark(signature);
2873
3351
  }
2874
3352
  /**
2875
3353
  * Get the currently active scope map. Returns undefined if no scope is configured.
@@ -2882,19 +3360,49 @@ var SyncEngine = class {
2882
3360
  * Takes effect on the next connection; reconnects when already connected.
2883
3361
  */
2884
3362
  registerQuerySubset(subset) {
3363
+ if ((this.config.querySubsets?.mode ?? "reactive") !== "reactive") return () => {
3364
+ };
2885
3365
  const id = `query-${nextQuerySubsetId++}`;
3366
+ const previousSignature = this.deliverySignature();
3367
+ const previousEffective = this.getActiveQuerySubsets();
2886
3368
  this.querySubsets.set(id, subset);
2887
- this.scheduleQuerySubsetReconnect();
3369
+ this.switchDeliveryViewWithInheritance(previousSignature, previousEffective);
3370
+ if (this.deliverySignature() !== previousSignature) this.scheduleQuerySubsetReconnect();
2888
3371
  return () => {
3372
+ const sigBeforeRemove = this.deliverySignature();
3373
+ const effectiveBeforeRemove = this.getActiveQuerySubsets();
2889
3374
  this.querySubsets.delete(id);
2890
- this.scheduleQuerySubsetReconnect();
3375
+ this.switchDeliveryViewWithInheritance(sigBeforeRemove, effectiveBeforeRemove);
3376
+ if (this.deliverySignature() !== sigBeforeRemove) this.scheduleQuerySubsetReconnect();
2891
3377
  };
2892
3378
  }
3379
+ switchDeliveryViewWithInheritance(previousSignature, previousSubsets) {
3380
+ const nextSubsets = this.getActiveQuerySubsets();
3381
+ const previousWatermark = this.deliveryWatermark;
3382
+ this.switchDeliveryView(previousSignature);
3383
+ if (this.deliveryWatermark === 0 && querySubsetContains(previousSubsets, nextSubsets)) {
3384
+ this.deliveryWatermark = previousWatermark;
3385
+ this.setViewWatermark(this.deliverySignature(), previousWatermark);
3386
+ void this.persistDeliveryWatermark(previousWatermark);
3387
+ }
3388
+ }
3389
+ /** Atomically replace the manifest used by static query-subset mode. */
3390
+ setQuerySubsets(subsets) {
3391
+ if ((this.config.querySubsets?.mode ?? "reactive") !== "static")
3392
+ throw new Error('setQuerySubsets() requires sync.querySubsets.mode = "static"');
3393
+ const previousSignature = this.deliverySignature();
3394
+ const previousEffective = this.getActiveQuerySubsets();
3395
+ this.staticQuerySubsets = dedupeQuerySubsets(subsets);
3396
+ this.switchDeliveryViewWithInheritance(previousSignature, previousEffective);
3397
+ if (this.deliverySignature() !== previousSignature) this.scheduleQuerySubsetReconnect();
3398
+ }
2893
3399
  /**
2894
3400
  * Returns deduplicated active query subsets from registered subscriptions.
2895
3401
  */
2896
3402
  getActiveQuerySubsets() {
2897
- return dedupeQuerySubsets([...this.querySubsets.values()]);
3403
+ const mode = this.config.querySubsets?.mode ?? "reactive";
3404
+ if (mode === "disabled") return [];
3405
+ return mode === "static" ? this.staticQuerySubsets : dedupeQuerySubsets([...this.querySubsets.values()]);
2898
3406
  }
2899
3407
  /**
2900
3408
  * Get the awareness manager for collaborative presence.
@@ -2949,11 +3457,14 @@ var SyncEngine = class {
2949
3457
  await this.handleOperationBatch(message);
2950
3458
  break;
2951
3459
  case "acknowledgment":
2952
- this.handleAcknowledgment(message);
3460
+ await this.handleAcknowledgment(message);
2953
3461
  break;
2954
3462
  case "error":
2955
3463
  this.handleError(message);
2956
3464
  break;
3465
+ case "operation-rejected":
3466
+ await this.handleOperationRejected(message);
3467
+ break;
2957
3468
  case "awareness-update":
2958
3469
  this.handleAwarenessUpdate(message);
2959
3470
  break;
@@ -2968,6 +3479,7 @@ var SyncEngine = class {
2968
3479
  }
2969
3480
  }
2970
3481
  handleMessageFailure(error) {
3482
+ this.hasInFlightDeliveryBatch = false;
2971
3483
  const reason = error instanceof Error ? error.message : "Message handling failed";
2972
3484
  this.handleTransportClose(reason);
2973
3485
  }
@@ -3036,6 +3548,12 @@ var SyncEngine = class {
3036
3548
  }
3037
3549
  this.remoteVector = wireToVersionVector(msg.versionVector);
3038
3550
  void this.persistLastAckedServerVector(this.remoteVector);
3551
+ if (typeof msg.serverMaxDeliverySequence === "number" && this.deliveryWatermark > msg.serverMaxDeliverySequence) {
3552
+ this.deliveryWatermark = 0;
3553
+ this.setViewWatermark(this.deliverySignature(), 0);
3554
+ await this.persistDeliveryWatermark(0);
3555
+ }
3556
+ this.serverFrontier = msg.serverMaxDeliverySequence ?? null;
3039
3557
  if (msg.selectedWireFormat) {
3040
3558
  this.setSerializerWireFormat(msg.selectedWireFormat);
3041
3559
  }
@@ -3101,7 +3619,7 @@ var SyncEngine = class {
3101
3619
  async sendDelta() {
3102
3620
  const localVector = this.store.getVersionVector();
3103
3621
  const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
3104
- const missingOps = allMissingOps.filter((op) => this.operationAllowedForSync(op));
3622
+ const missingOps = await this.filterAllowedForSync(allMissingOps);
3105
3623
  this.deltaSentOpIds = missingOps.map((op) => op.id);
3106
3624
  if (missingOps.length === 0) {
3107
3625
  const messageId = generateMessageId();
@@ -3176,11 +3694,35 @@ var SyncEngine = class {
3176
3694
  return missing;
3177
3695
  }
3178
3696
  async handleOperationBatch(msg) {
3697
+ const isDeliveryBatch = msg.maxDeliverySequence !== void 0;
3698
+ if (isDeliveryBatch) {
3699
+ const base = msg.baseDeliverySequence ?? 0;
3700
+ if (base > this.deliveryWatermark) {
3701
+ const gapKey = `${this.deliveryWatermark}:${base}`;
3702
+ this.deliveryGapRepeatCount = this.lastDeliveryGapKey === gapKey ? this.deliveryGapRepeatCount + 1 : 1;
3703
+ this.lastDeliveryGapKey = gapKey;
3704
+ this.emitter?.emit({
3705
+ type: "sync:delivery-gap",
3706
+ expectedBase: this.deliveryWatermark,
3707
+ receivedBase: base,
3708
+ currentWatermark: this.deliveryWatermark,
3709
+ messageId: msg.messageId,
3710
+ repeatCount: this.deliveryGapRepeatCount
3711
+ });
3712
+ return;
3713
+ }
3714
+ if (base < this.deliveryWatermark) {
3715
+ this.sendDeliveryAck(msg.messageId, this.deliveryWatermark);
3716
+ return;
3717
+ }
3718
+ this.hasInFlightDeliveryBatch = true;
3719
+ }
3179
3720
  const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
3180
3721
  const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
3181
- const inScopeOps = operations.filter((op) => this.operationAllowedForSync(op));
3722
+ const inScopeOps = await this.filterAllowedForSync(operations);
3182
3723
  const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
3183
3724
  const transforms = this.config.operationTransforms ?? [];
3725
+ let fullyApplied = true;
3184
3726
  for (const op of inScopeOps) {
3185
3727
  const transformed = transforms.length > 0 ? applyOperationTransforms(op, targetSchemaVersion, transforms) : op;
3186
3728
  if (transformed === null) {
@@ -3192,21 +3734,24 @@ var SyncEngine = class {
3192
3734
  this.emitApplyFailure(transformed, result);
3193
3735
  }
3194
3736
  } catch (error) {
3737
+ fullyApplied = false;
3195
3738
  if (error instanceof ClockDriftError) {
3196
- this.emitApplyFailure(transformed, "rejected", {
3197
- code: APPLY_FAILURE_CODES.CLOCK_DRIFT,
3198
- message: error.message,
3199
- retriable: false
3200
- });
3739
+ this.emitApplyFailure(
3740
+ transformed,
3741
+ "rejected",
3742
+ {
3743
+ code: APPLY_FAILURE_CODES.CLOCK_DRIFT,
3744
+ message: error.message,
3745
+ retriable: false
3746
+ },
3747
+ true
3748
+ );
3201
3749
  continue;
3202
3750
  }
3203
3751
  const message = error instanceof Error ? error.message : "Apply failed";
3204
3752
  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
- });
3753
+ const retriable = code !== APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY;
3754
+ this.emitApplyFailure(transformed, "rejected", { code, message, retriable }, true);
3210
3755
  }
3211
3756
  }
3212
3757
  if (inScopeOps.length > 0) {
@@ -3217,18 +3762,35 @@ var SyncEngine = class {
3217
3762
  batchSize: inScopeOps.length
3218
3763
  });
3219
3764
  }
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
- });
3765
+ const deliveryFullyApplied = isDeliveryBatch && fullyApplied;
3766
+ if (!isDeliveryBatch || deliveryFullyApplied) {
3767
+ const lastOp = operations[operations.length - 1];
3768
+ const ack = {
3769
+ type: "acknowledgment",
3770
+ messageId: generateMessageId(),
3771
+ acknowledgedMessageId: msg.messageId,
3772
+ lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0,
3773
+ ...deliveryFullyApplied && msg.maxDeliverySequence !== void 0 ? { deliverySequence: msg.maxDeliverySequence } : {}
3774
+ };
3775
+ this.transport.send(ack);
3776
+ this.emitter?.emit({
3777
+ type: "sync:acknowledged",
3778
+ sequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3779
+ });
3780
+ }
3781
+ if (isDeliveryBatch && fullyApplied && msg.maxDeliverySequence !== void 0) {
3782
+ this.deliveryGapRepeatCount = 0;
3783
+ this.lastDeliveryGapKey = null;
3784
+ this.deliveryWatermark = msg.maxDeliverySequence;
3785
+ this.setViewWatermark(this.deliverySignature(), this.deliveryWatermark);
3786
+ await this.persistDeliveryWatermark(this.deliveryWatermark);
3787
+ if (this.blockedFailure && inScopeOps.some((op) => op.id === this.blockedFailure?.operationId)) {
3788
+ const recovered = this.blockedFailure;
3789
+ this.blockedFailure = null;
3790
+ this.emitter?.emit({ type: "sync:apply-recovered", failure: recovered });
3791
+ }
3792
+ }
3793
+ this.hasInFlightDeliveryBatch = false;
3232
3794
  if (this.state === "syncing") {
3233
3795
  this.deltaBatchesReceived++;
3234
3796
  const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
@@ -3236,15 +3798,17 @@ var SyncEngine = class {
3236
3798
  this.initialSyncTotalBatches = msg.totalBatches;
3237
3799
  }
3238
3800
  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);
3801
+ if (!isDeliveryBatch) {
3802
+ const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
3803
+ inScopeOps.length > 0 ? inScopeOps : operations,
3804
+ msg.batchIndex
3805
+ );
3806
+ if (cursorFromBatch) {
3807
+ this.resumeDeltaCursor = cursorFromBatch;
3808
+ await this.persistDeltaCursor(cursorFromBatch);
3809
+ }
3246
3810
  }
3247
- if (msg.isFinal) {
3811
+ if (msg.isFinal && (!isDeliveryBatch || fullyApplied)) {
3248
3812
  this.deltaReceiveComplete = true;
3249
3813
  this.resumeDeltaCursor = null;
3250
3814
  await this.persistDeltaCursor(null);
@@ -3253,29 +3817,57 @@ var SyncEngine = class {
3253
3817
  }
3254
3818
  }
3255
3819
  }
3256
- handleAcknowledgment(msg) {
3820
+ /** Acknowledge a delivery batch (used for duplicates) without re-applying it. */
3821
+ sendDeliveryAck(acknowledgedMessageId, deliverySequence) {
3822
+ const ack = {
3823
+ type: "acknowledgment",
3824
+ messageId: generateMessageId(),
3825
+ acknowledgedMessageId,
3826
+ lastSequenceNumber: 0,
3827
+ deliverySequence
3828
+ };
3829
+ this.transport.send(ack);
3830
+ }
3831
+ async handleAcknowledgment(msg) {
3832
+ this.clearOutboundAckTimer();
3257
3833
  if (this.state === "syncing" && this.config.strictHandshake) {
3258
3834
  this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
3259
3835
  this.markDeltaSendCompleteIfReady();
3260
3836
  }
3261
3837
  if (this.currentBatch) {
3262
- this.outboundQueue.acknowledge(this.currentBatch.batchId);
3838
+ if (this.currentBatchRequiresPartialAck) {
3839
+ await this.outboundQueue.acknowledgeThrough(
3840
+ this.currentBatch.batchId,
3841
+ msg.lastSequenceNumber
3842
+ );
3843
+ } else {
3844
+ await this.outboundQueue.acknowledge(this.currentBatch.batchId);
3845
+ }
3263
3846
  this.currentBatch = null;
3847
+ this.currentBatchRequiresPartialAck = false;
3264
3848
  const now = Date.now();
3265
3849
  this.lastSyncedAt = now;
3266
3850
  this.lastSuccessfulPush = now;
3267
3851
  }
3268
- void this.advanceLastAckedForLocalNode(msg.lastSequenceNumber).then(
3269
- () => this.refreshPendingCount()
3270
- );
3852
+ await this.advanceLastAckedForLocalNode(msg.lastSequenceNumber);
3853
+ await this.refreshPendingCount();
3271
3854
  if (this.state === "streaming" && this.outboundQueue.hasOperations) {
3272
3855
  this.flushQueue();
3273
3856
  }
3274
3857
  }
3275
3858
  handleError(msg) {
3859
+ this.clearOutboundAckTimer();
3860
+ if (this.currentBatch) {
3861
+ this.outboundQueue.returnBatch(this.currentBatch.batchId);
3862
+ this.currentBatch = null;
3863
+ this.currentBatchRequiresPartialAck = false;
3864
+ }
3276
3865
  this.transitionTo("error");
3277
- if (msg.code === "AUTH_FAILED") {
3866
+ if (msg.code === "AUTH_FAILED" || msg.code === "DEVICE_REVOKED") {
3867
+ this.authRejected = true;
3868
+ this.suspensionReason = msg.code === "DEVICE_REVOKED" ? "device-revoked" : "auth-rejected";
3278
3869
  this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
3870
+ this.emitter?.emit({ type: "sync:suspended", reason: this.suspensionReason });
3279
3871
  }
3280
3872
  if (msg.code === "INVALID_TIMESTAMP") {
3281
3873
  this.clockBlocked = true;
@@ -3291,6 +3883,64 @@ var SyncEngine = class {
3291
3883
  this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
3292
3884
  this.transitionTo("disconnected");
3293
3885
  }
3886
+ /**
3887
+ * Handle a per-operation rejection: divert the op out of the pending outbound
3888
+ * queue (so it is never retried or resurrected on reconnect), record it in the
3889
+ * durable rejected store (so it is kept and explainable), and emit an event so
3890
+ * the app can reconcile. Unlike {@link handleError}, this is a normal per-op
3891
+ * signal, so the connection stays up.
3892
+ */
3893
+ async handleOperationRejected(msg) {
3894
+ if (msg.retriable) {
3895
+ this.currentBatchRequiresPartialAck = true;
3896
+ await this.refreshPendingCount();
3897
+ this.emitter?.emit({
3898
+ type: "sync:operation-rejected",
3899
+ operationId: msg.operationId,
3900
+ collection: msg.collection,
3901
+ recordId: msg.recordId,
3902
+ code: msg.code,
3903
+ message: msg.message,
3904
+ retriable: true
3905
+ });
3906
+ return;
3907
+ }
3908
+ await this.outboundQueue.reject(msg.operationId);
3909
+ await this.rejectedStorage.record({
3910
+ operationId: msg.operationId,
3911
+ collection: msg.collection,
3912
+ recordId: msg.recordId,
3913
+ code: msg.code,
3914
+ message: msg.message,
3915
+ retriable: msg.retriable,
3916
+ rejectedAt: Date.now()
3917
+ });
3918
+ await this.refreshPendingCount();
3919
+ this.emitter?.emit({
3920
+ type: "sync:operation-rejected",
3921
+ operationId: msg.operationId,
3922
+ collection: msg.collection,
3923
+ recordId: msg.recordId,
3924
+ code: msg.code,
3925
+ message: msg.message,
3926
+ retriable: msg.retriable
3927
+ });
3928
+ }
3929
+ /**
3930
+ * Every operation the server rejected that has not yet been reconciled. The app
3931
+ * uses this (alongside the `sync:operation-rejected` event) to surface failed
3932
+ * submissions and decide whether to roll back or resubmit.
3933
+ */
3934
+ getRejectedOperations() {
3935
+ return this.rejectedStorage.list();
3936
+ }
3937
+ /**
3938
+ * Forget rejected operations by id once the app has reconciled them (rolled the
3939
+ * optimistic write back or resubmitted a corrected op).
3940
+ */
3941
+ clearRejectedOperations(operationIds) {
3942
+ return this.rejectedStorage.remove(operationIds);
3943
+ }
3294
3944
  async checkDeltaComplete() {
3295
3945
  if (!this.deltaSendComplete || !this.deltaReceiveComplete) {
3296
3946
  return;
@@ -3389,7 +4039,7 @@ var SyncEngine = class {
3389
4039
  return;
3390
4040
  }
3391
4041
  const ops = await this.store.getOperationRange(localNodeId, 1, localSeq);
3392
- const inScope = ops.filter((op) => this.operationAllowedForSync(op));
4042
+ const inScope = await this.filterAllowedForSync(ops);
3393
4043
  for (const op of inScope) {
3394
4044
  await this.outboundQueue.enqueue(op);
3395
4045
  }
@@ -3400,6 +4050,7 @@ var SyncEngine = class {
3400
4050
  const batch = this.outboundQueue.takeBatch(this.batchSize);
3401
4051
  if (!batch) return;
3402
4052
  this.currentBatch = batch;
4053
+ this.currentBatchRequiresPartialAck = false;
3403
4054
  if (this.encryptor) {
3404
4055
  this.encryptor.encryptBatch(batch.operations).then(
3405
4056
  (encrypted) => {
@@ -3412,6 +4063,7 @@ var SyncEngine = class {
3412
4063
  batchIndex: 0
3413
4064
  };
3414
4065
  this.transport.send(batchMsg);
4066
+ this.startOutboundAckTimer(batch.batchId);
3415
4067
  this.emitter?.emit({
3416
4068
  type: "sync:sent",
3417
4069
  operations: batch.operations,
@@ -3421,6 +4073,7 @@ var SyncEngine = class {
3421
4073
  (err) => {
3422
4074
  this.outboundQueue.returnBatch(batch.batchId);
3423
4075
  this.currentBatch = null;
4076
+ this.currentBatchRequiresPartialAck = false;
3424
4077
  this.emitter?.emit({
3425
4078
  type: "sync:disconnected",
3426
4079
  reason: err instanceof Error ? err.message : "Encryption failed"
@@ -3437,6 +4090,7 @@ var SyncEngine = class {
3437
4090
  batchIndex: 0
3438
4091
  };
3439
4092
  this.transport.send(batchMsg);
4093
+ this.startOutboundAckTimer(batch.batchId);
3440
4094
  this.emitter?.emit({
3441
4095
  type: "sync:sent",
3442
4096
  operations: batch.operations,
@@ -3444,6 +4098,34 @@ var SyncEngine = class {
3444
4098
  });
3445
4099
  }
3446
4100
  }
4101
+ startOutboundAckTimer(batchId) {
4102
+ this.clearOutboundAckTimer();
4103
+ if (this.outboundAckTimeoutMs <= 0) return;
4104
+ this.outboundAckTimer = setTimeout(() => {
4105
+ void this.handleOutboundAckTimeout(batchId);
4106
+ }, this.outboundAckTimeoutMs);
4107
+ }
4108
+ clearOutboundAckTimer() {
4109
+ if (!this.outboundAckTimer) return;
4110
+ clearTimeout(this.outboundAckTimer);
4111
+ this.outboundAckTimer = null;
4112
+ }
4113
+ async handleOutboundAckTimeout(batchId) {
4114
+ if (!this.currentBatch || this.currentBatch.batchId !== batchId) return;
4115
+ this.outboundQueue.returnBatch(batchId);
4116
+ this.currentBatch = null;
4117
+ this.currentBatchRequiresPartialAck = false;
4118
+ this.clearOutboundAckTimer();
4119
+ await this.refreshPendingCount();
4120
+ if (this.state === "disconnected") return;
4121
+ const reason = `Timed out waiting for acknowledgment of outbound batch ${batchId}`;
4122
+ try {
4123
+ await this.transport.disconnect();
4124
+ } finally {
4125
+ this.ensureDisconnected();
4126
+ this.emitter?.emit({ type: "sync:disconnected", reason });
4127
+ }
4128
+ }
3447
4129
  handleAwarenessUpdate(msg) {
3448
4130
  const awarenessMessage = {
3449
4131
  type: "awareness",
@@ -3453,9 +4135,11 @@ var SyncEngine = class {
3453
4135
  this.awarenessManager.handleRemoteMessage(awarenessMessage);
3454
4136
  }
3455
4137
  handleTransportClose(reason) {
4138
+ this.clearOutboundAckTimer();
3456
4139
  if (this.currentBatch) {
3457
4140
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
3458
4141
  this.currentBatch = null;
4142
+ this.currentBatchRequiresPartialAck = false;
3459
4143
  }
3460
4144
  if (this.schemaBlocked) {
3461
4145
  return;
@@ -3487,11 +4171,40 @@ var SyncEngine = class {
3487
4171
  this.serializer.setWireFormat(format);
3488
4172
  }
3489
4173
  }
3490
- operationAllowedForSync(op) {
3491
- if (!operationMatchesScope(op, this.activeScope)) {
4174
+ async operationAllowedForSync(op) {
4175
+ if (this.matchesScopeAndSubsets(op)) {
4176
+ return true;
4177
+ }
4178
+ const fullRecord = await this.readRecordForBackfill(op);
4179
+ if (!fullRecord) {
3492
4180
  return false;
3493
4181
  }
3494
- return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets());
4182
+ return this.matchesScopeAndSubsets(op, fullRecord);
4183
+ }
4184
+ matchesScopeAndSubsets(op, fullRecord) {
4185
+ if (!operationMatchesScope(op, this.activeScope, fullRecord)) {
4186
+ return false;
4187
+ }
4188
+ return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets(), fullRecord);
4189
+ }
4190
+ async readRecordForBackfill(op) {
4191
+ if (!this.store.readRecordFields) {
4192
+ return null;
4193
+ }
4194
+ try {
4195
+ return await this.store.readRecordFields(op.collection, op.recordId);
4196
+ } catch {
4197
+ return null;
4198
+ }
4199
+ }
4200
+ async filterAllowedForSync(ops) {
4201
+ const allowed = [];
4202
+ for (const op of ops) {
4203
+ if (await this.operationAllowedForSync(op)) {
4204
+ allowed.push(op);
4205
+ }
4206
+ }
4207
+ return allowed;
3495
4208
  }
3496
4209
  async persistDeltaCursor(cursor) {
3497
4210
  if (!this.syncState?.saveDeltaCursor) {
@@ -3499,6 +4212,12 @@ var SyncEngine = class {
3499
4212
  }
3500
4213
  await this.syncState.saveDeltaCursor(cursor);
3501
4214
  }
4215
+ async persistDeliveryWatermark(watermark, signature = this.deliverySignature()) {
4216
+ if (!this.syncState?.saveDeliveryWatermark) {
4217
+ return;
4218
+ }
4219
+ await this.syncState.saveDeliveryWatermark(signature, watermark);
4220
+ }
3502
4221
  scheduleQuerySubsetReconnect() {
3503
4222
  if (this.querySubsetReconnectTimer) {
3504
4223
  clearTimeout(this.querySubsetReconnectTimer);
@@ -3511,8 +4230,7 @@ var SyncEngine = class {
3511
4230
  }, 500);
3512
4231
  }
3513
4232
  async reconnectForQuerySubsets() {
3514
- await this.stop();
3515
- await this.start();
4233
+ await this.reconnect();
3516
4234
  }
3517
4235
  };
3518
4236
  function awarenessStatesToWire(states) {
@@ -4193,12 +4911,22 @@ function fromBase642(str) {
4193
4911
  // src/reactivity/sync-status-controller.ts
4194
4912
  var OFFLINE_SYNC_STATUS = Object.freeze({
4195
4913
  status: "offline",
4914
+ phase: "offline",
4915
+ reconnecting: false,
4196
4916
  pendingOperations: 0,
4197
4917
  lastSyncedAt: null,
4198
4918
  lastSuccessfulPush: null,
4199
4919
  lastSuccessfulPull: null,
4200
4920
  conflicts: 0,
4201
- clockSkewMs: null
4921
+ clockSkewMs: null,
4922
+ inFlightUploadOperations: 0,
4923
+ hasInFlightDeliveryBatch: false,
4924
+ activeViewId: "",
4925
+ activeViewComplete: false,
4926
+ initialSync: { complete: false, receivedBatches: 0, totalBatches: null, progress: null },
4927
+ deliveryWatermark: 0,
4928
+ serverFrontier: null,
4929
+ blockedFailure: null
4202
4930
  });
4203
4931
  var SYNC_STATUS_EVENT_TYPES = [
4204
4932
  "sync:connected",
@@ -4210,6 +4938,11 @@ var SYNC_STATUS_EVENT_TYPES = [
4210
4938
  "sync:received",
4211
4939
  "sync:acknowledged",
4212
4940
  "sync:apply-failed",
4941
+ "sync:suspended",
4942
+ "sync:apply-blocked",
4943
+ "sync:apply-retrying",
4944
+ "sync:apply-recovered",
4945
+ "sync:delivery-gap",
4213
4946
  "sync:diagnostics",
4214
4947
  "sync:initial-sync-progress"
4215
4948
  ];
@@ -4355,6 +5088,7 @@ export {
4355
5088
  InvalidScopeError,
4356
5089
  JsonMessageSerializer,
4357
5090
  KeyDerivationError,
5091
+ MemoryRejectedOperationStorage,
4358
5092
  NegotiatedMessageSerializer,
4359
5093
  OFFLINE_SYNC_STATUS,
4360
5094
  OutboundQueue,
@@ -4393,11 +5127,13 @@ export {
4393
5127
  isHandshakeMessage,
4394
5128
  isHandshakeResponseMessage,
4395
5129
  isOperationBatchMessage,
5130
+ isOperationRejectedMessage,
4396
5131
  isSchemaMismatchReject,
4397
5132
  isSyncMessage,
4398
5133
  isYjsDocUpdateMessage,
4399
5134
  operationMatchesQuerySubsets,
4400
5135
  operationMatchesScope,
5136
+ querySubsetContains,
4401
5137
  richtextDocKey,
4402
5138
  sliceOperationsAfterCursor,
4403
5139
  subscribeRemoteAwarenessStates,