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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -106,6 +106,7 @@ var SYNC_STATES = [
106
106
  ];
107
107
  var SYNC_STATUSES = [
108
108
  "connected",
109
+ "reconnecting",
109
110
  "syncing",
110
111
  "synced",
111
112
  "offline",
@@ -2604,6 +2605,32 @@ var OutboundQueue = class {
2604
2605
  const ids = ops.map((op) => op.id);
2605
2606
  await this.storage.dequeue(ids);
2606
2607
  }
2608
+ /**
2609
+ * Acknowledge only operations in an in-flight batch whose local sequence number is
2610
+ * covered by the server's ack. Any later operations return to the front of the
2611
+ * queue for retry. This is required when the server accepts a prefix but rejects a
2612
+ * later operation retriably (for example a stale-scope push after auth changed).
2613
+ */
2614
+ async acknowledgeThrough(batchId, lastSequenceNumber) {
2615
+ const ops = this.inFlight.get(batchId);
2616
+ if (!ops) return;
2617
+ this.inFlight.delete(batchId);
2618
+ const acknowledged = ops.filter((op) => op.sequenceNumber <= lastSequenceNumber);
2619
+ const retry = ops.filter((op) => op.sequenceNumber > lastSequenceNumber);
2620
+ if (retry.length > 0) {
2621
+ this.queue.unshift(...retry);
2622
+ if (this.queue.length > 1) {
2623
+ this.queue = (0, import_internal.topologicalSort)(this.queue);
2624
+ }
2625
+ }
2626
+ if (acknowledged.length > 0) {
2627
+ const ids = acknowledged.map((op) => op.id);
2628
+ for (const id of ids) {
2629
+ this.seen.delete(id);
2630
+ }
2631
+ await this.storage.dequeue(ids);
2632
+ }
2633
+ }
2607
2634
  /**
2608
2635
  * Return a failed batch to the front of the queue for retry.
2609
2636
  * Prepends the operations to maintain priority.
@@ -2785,6 +2812,7 @@ var SyncEngine = class {
2785
2812
  lastSuccessfulPull = null;
2786
2813
  conflictCount = 0;
2787
2814
  currentBatch = null;
2815
+ currentBatchRequiresPartialAck = false;
2788
2816
  reconnecting = false;
2789
2817
  schemaBlocked = false;
2790
2818
  clockBlocked = false;
@@ -2793,6 +2821,7 @@ var SyncEngine = class {
2793
2821
  outboundAckTimer = null;
2794
2822
  startPromise = null;
2795
2823
  stopPromise = null;
2824
+ reconnectPromise = null;
2796
2825
  // Track delta exchange state
2797
2826
  deltaBatchesReceived = 0;
2798
2827
  deltaReceiveComplete = false;
@@ -2832,6 +2861,8 @@ var SyncEngine = class {
2832
2861
  * so a view switch resolves synchronously (no race with the reconnect it triggers).
2833
2862
  */
2834
2863
  deliverySignatureWatermarks = /* @__PURE__ */ new Map();
2864
+ deliveryGapRepeatCount = 0;
2865
+ lastDeliveryGapKey = null;
2835
2866
  constructor(options) {
2836
2867
  this.transport = options.transport;
2837
2868
  this.store = options.store;
@@ -2979,6 +3010,7 @@ var SyncEngine = class {
2979
3010
  if (this.currentBatch) {
2980
3011
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
2981
3012
  this.currentBatch = null;
3013
+ this.currentBatchRequiresPartialAck = false;
2982
3014
  }
2983
3015
  try {
2984
3016
  await this.transport.disconnect();
@@ -3028,6 +3060,7 @@ var SyncEngine = class {
3028
3060
  getStatus() {
3029
3061
  const pendingOperations = this.syncState ? this.cachedUnsyncedCount : this.outboundQueue.totalPending;
3030
3062
  const base = {
3063
+ reconnecting: this.reconnecting,
3031
3064
  pendingOperations,
3032
3065
  lastSyncedAt: this.lastSyncedAt,
3033
3066
  lastSuccessfulPush: this.lastSuccessfulPush,
@@ -3047,7 +3080,7 @@ var SyncEngine = class {
3047
3080
  case "connecting":
3048
3081
  case "handshaking":
3049
3082
  case "syncing":
3050
- return { ...base, status: this.reconnecting ? "offline" : "syncing" };
3083
+ return { ...base, status: this.reconnecting ? "reconnecting" : "syncing" };
3051
3084
  case "streaming":
3052
3085
  return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
3053
3086
  case "error":
@@ -3132,6 +3165,24 @@ var SyncEngine = class {
3132
3165
  await this.start();
3133
3166
  }
3134
3167
  }
3168
+ /**
3169
+ * Atomically refresh the transport session. Overlapping callers share one
3170
+ * reconnect, in-flight outbound batches are returned to the queue by stop(), and the
3171
+ * next start handshakes with the latest auth token, sync scope, and query subsets.
3172
+ */
3173
+ async reconnect() {
3174
+ if (this.schemaBlocked) return;
3175
+ if (this.reconnectPromise) return this.reconnectPromise;
3176
+ this.reconnecting = false;
3177
+ this.reconnectPromise = this.reconnectInternal().finally(() => {
3178
+ this.reconnectPromise = null;
3179
+ });
3180
+ return this.reconnectPromise;
3181
+ }
3182
+ async reconnectInternal() {
3183
+ await this.stop();
3184
+ await this.start();
3185
+ }
3135
3186
  /**
3136
3187
  * Export a diagnostics snapshot for debugging and support tickets.
3137
3188
  * Contains connection state, timing info, and queue metrics.
@@ -3150,6 +3201,8 @@ var SyncEngine = class {
3150
3201
  pendingOperations: this.outboundQueue.totalPending,
3151
3202
  hasInFlightBatch: this.currentBatch !== null,
3152
3203
  reconnecting: this.reconnecting,
3204
+ deliveryWatermark: this.deliveryWatermark,
3205
+ deliveryGapRepeatCount: this.deliveryGapRepeatCount,
3153
3206
  timestamp: Date.now()
3154
3207
  };
3155
3208
  }
@@ -3339,7 +3392,7 @@ var SyncEngine = class {
3339
3392
  await this.handleOperationBatch(message);
3340
3393
  break;
3341
3394
  case "acknowledgment":
3342
- this.handleAcknowledgment(message);
3395
+ await this.handleAcknowledgment(message);
3343
3396
  break;
3344
3397
  case "error":
3345
3398
  this.handleError(message);
@@ -3578,6 +3631,17 @@ var SyncEngine = class {
3578
3631
  if (isDeliveryBatch) {
3579
3632
  const base = msg.baseDeliverySequence ?? 0;
3580
3633
  if (base > this.deliveryWatermark) {
3634
+ const gapKey = `${this.deliveryWatermark}:${base}`;
3635
+ this.deliveryGapRepeatCount = this.lastDeliveryGapKey === gapKey ? this.deliveryGapRepeatCount + 1 : 1;
3636
+ this.lastDeliveryGapKey = gapKey;
3637
+ this.emitter?.emit({
3638
+ type: "sync:delivery-gap",
3639
+ expectedBase: this.deliveryWatermark,
3640
+ receivedBase: base,
3641
+ currentWatermark: this.deliveryWatermark,
3642
+ messageId: msg.messageId,
3643
+ repeatCount: this.deliveryGapRepeatCount
3644
+ });
3581
3645
  return;
3582
3646
  }
3583
3647
  if (base < this.deliveryWatermark) {
@@ -3642,6 +3706,8 @@ var SyncEngine = class {
3642
3706
  });
3643
3707
  }
3644
3708
  if (isDeliveryBatch && fullyApplied && msg.maxDeliverySequence !== void 0) {
3709
+ this.deliveryGapRepeatCount = 0;
3710
+ this.lastDeliveryGapKey = null;
3645
3711
  this.deliveryWatermark = msg.maxDeliverySequence;
3646
3712
  this.setViewWatermark(this.deliverySignature(), this.deliveryWatermark);
3647
3713
  await this.persistDeliveryWatermark(this.deliveryWatermark);
@@ -3683,22 +3749,29 @@ var SyncEngine = class {
3683
3749
  };
3684
3750
  this.transport.send(ack);
3685
3751
  }
3686
- handleAcknowledgment(msg) {
3752
+ async handleAcknowledgment(msg) {
3687
3753
  this.clearOutboundAckTimer();
3688
3754
  if (this.state === "syncing" && this.config.strictHandshake) {
3689
3755
  this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
3690
3756
  this.markDeltaSendCompleteIfReady();
3691
3757
  }
3692
3758
  if (this.currentBatch) {
3693
- this.outboundQueue.acknowledge(this.currentBatch.batchId);
3759
+ if (this.currentBatchRequiresPartialAck) {
3760
+ await this.outboundQueue.acknowledgeThrough(
3761
+ this.currentBatch.batchId,
3762
+ msg.lastSequenceNumber
3763
+ );
3764
+ } else {
3765
+ await this.outboundQueue.acknowledge(this.currentBatch.batchId);
3766
+ }
3694
3767
  this.currentBatch = null;
3768
+ this.currentBatchRequiresPartialAck = false;
3695
3769
  const now = Date.now();
3696
3770
  this.lastSyncedAt = now;
3697
3771
  this.lastSuccessfulPush = now;
3698
3772
  }
3699
- void this.advanceLastAckedForLocalNode(msg.lastSequenceNumber).then(
3700
- () => this.refreshPendingCount()
3701
- );
3773
+ await this.advanceLastAckedForLocalNode(msg.lastSequenceNumber);
3774
+ await this.refreshPendingCount();
3702
3775
  if (this.state === "streaming" && this.outboundQueue.hasOperations) {
3703
3776
  this.flushQueue();
3704
3777
  }
@@ -3708,6 +3781,7 @@ var SyncEngine = class {
3708
3781
  if (this.currentBatch) {
3709
3782
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
3710
3783
  this.currentBatch = null;
3784
+ this.currentBatchRequiresPartialAck = false;
3711
3785
  }
3712
3786
  this.transitionTo("error");
3713
3787
  if (msg.code === "AUTH_FAILED") {
@@ -3735,6 +3809,20 @@ var SyncEngine = class {
3735
3809
  * signal, so the connection stays up.
3736
3810
  */
3737
3811
  async handleOperationRejected(msg) {
3812
+ if (msg.retriable) {
3813
+ this.currentBatchRequiresPartialAck = true;
3814
+ await this.refreshPendingCount();
3815
+ this.emitter?.emit({
3816
+ type: "sync:operation-rejected",
3817
+ operationId: msg.operationId,
3818
+ collection: msg.collection,
3819
+ recordId: msg.recordId,
3820
+ code: msg.code,
3821
+ message: msg.message,
3822
+ retriable: true
3823
+ });
3824
+ return;
3825
+ }
3738
3826
  await this.outboundQueue.reject(msg.operationId);
3739
3827
  await this.rejectedStorage.record({
3740
3828
  operationId: msg.operationId,
@@ -3880,6 +3968,7 @@ var SyncEngine = class {
3880
3968
  const batch = this.outboundQueue.takeBatch(this.batchSize);
3881
3969
  if (!batch) return;
3882
3970
  this.currentBatch = batch;
3971
+ this.currentBatchRequiresPartialAck = false;
3883
3972
  if (this.encryptor) {
3884
3973
  this.encryptor.encryptBatch(batch.operations).then(
3885
3974
  (encrypted) => {
@@ -3902,6 +3991,7 @@ var SyncEngine = class {
3902
3991
  (err) => {
3903
3992
  this.outboundQueue.returnBatch(batch.batchId);
3904
3993
  this.currentBatch = null;
3994
+ this.currentBatchRequiresPartialAck = false;
3905
3995
  this.emitter?.emit({
3906
3996
  type: "sync:disconnected",
3907
3997
  reason: err instanceof Error ? err.message : "Encryption failed"
@@ -3942,6 +4032,7 @@ var SyncEngine = class {
3942
4032
  if (!this.currentBatch || this.currentBatch.batchId !== batchId) return;
3943
4033
  this.outboundQueue.returnBatch(batchId);
3944
4034
  this.currentBatch = null;
4035
+ this.currentBatchRequiresPartialAck = false;
3945
4036
  this.clearOutboundAckTimer();
3946
4037
  await this.refreshPendingCount();
3947
4038
  if (this.state === "disconnected") return;
@@ -3966,6 +4057,7 @@ var SyncEngine = class {
3966
4057
  if (this.currentBatch) {
3967
4058
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
3968
4059
  this.currentBatch = null;
4060
+ this.currentBatchRequiresPartialAck = false;
3969
4061
  }
3970
4062
  if (this.schemaBlocked) {
3971
4063
  return;
@@ -4056,8 +4148,7 @@ var SyncEngine = class {
4056
4148
  }, 500);
4057
4149
  }
4058
4150
  async reconnectForQuerySubsets() {
4059
- await this.stop();
4060
- await this.start();
4151
+ await this.reconnect();
4061
4152
  }
4062
4153
  };
4063
4154
  function awarenessStatesToWire(states) {
@@ -4738,6 +4829,7 @@ function fromBase642(str) {
4738
4829
  // src/reactivity/sync-status-controller.ts
4739
4830
  var OFFLINE_SYNC_STATUS = Object.freeze({
4740
4831
  status: "offline",
4832
+ reconnecting: false,
4741
4833
  pendingOperations: 0,
4742
4834
  lastSyncedAt: null,
4743
4835
  lastSuccessfulPush: null,
@@ -4755,6 +4847,7 @@ var SYNC_STATUS_EVENT_TYPES = [
4755
4847
  "sync:received",
4756
4848
  "sync:acknowledged",
4757
4849
  "sync:apply-failed",
4850
+ "sync:delivery-gap",
4758
4851
  "sync:diagnostics",
4759
4852
  "sync:initial-sync-progress"
4760
4853
  ];