@korajs/sync 1.0.0-beta.10 → 1.0.0-beta.12

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
@@ -20,7 +20,8 @@ var SYNC_STATUSES = [
20
20
  "offline",
21
21
  "clock-error",
22
22
  "error",
23
- "schema-mismatch"
23
+ "schema-mismatch",
24
+ "auth-required"
24
25
  ];
25
26
  var MemoryRejectedOperationStorage = class {
26
27
  rejected = /* @__PURE__ */ new Map();
@@ -68,12 +69,19 @@ function operationMatchesScope(op, scopeMap, fullRecord) {
68
69
  const snapshot = buildSnapshot(op, fullRecord ?? void 0);
69
70
  if (!snapshot) return false;
70
71
  for (const [field, expected] of Object.entries(collectionScope)) {
71
- if (snapshot[field] !== expected) {
72
+ if (!matchesPredicate(snapshot[field], expected)) {
72
73
  return false;
73
74
  }
74
75
  }
75
76
  return true;
76
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
+ }
77
85
  function filterOperationsByScope(operations, scopeMap) {
78
86
  if (!scopeMap) return operations;
79
87
  return operations.filter((op) => operationMatchesScope(op, scopeMap));
@@ -130,24 +138,77 @@ function operationMatchesQuerySubsets(op, subsets, fullRecord) {
130
138
  if (collectionSubsets.length === 0) {
131
139
  return true;
132
140
  }
141
+ if (collectionSubsets.some((subset) => Object.keys(subset.where).length === 0)) {
142
+ return true;
143
+ }
133
144
  const snapshot = buildSnapshot2(op, fullRecord);
134
145
  if (!snapshot) {
135
146
  return false;
136
147
  }
137
148
  return collectionSubsets.some((subset) => recordMatchesWhere(snapshot, subset.where));
138
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
+ }
139
164
  function dedupeQuerySubsets(subsets) {
140
- const seen = /* @__PURE__ */ new Set();
165
+ const normalized = subsets.map((subset) => ({
166
+ collection: subset.collection,
167
+ where: stableValue(subset.where)
168
+ }));
141
169
  const result = [];
142
- for (const subset of subsets) {
143
- const key = `${subset.collection}:${JSON.stringify(subset.where)}`;
144
- 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;
145
203
  continue;
146
204
  }
147
- seen.add(key);
148
- 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;
149
210
  }
150
- return result;
211
+ return true;
151
212
  }
152
213
 
153
214
  // src/delta/delta-cursor.ts
@@ -530,7 +591,8 @@ function toProtoEnvelope(message) {
530
591
  schemaVersion: message.schemaVersion,
531
592
  authToken: message.authToken,
532
593
  supportedWireFormats: message.supportedWireFormats,
533
- ...message.lastDeliverySequence !== void 0 ? { lastDeliverySequence: message.lastDeliverySequence } : {}
594
+ ...message.lastDeliverySequence !== void 0 ? { lastDeliverySequence: message.lastDeliverySequence } : {},
595
+ scopeExitPolicy: message.scopeExitPolicy
534
596
  };
535
597
  case "handshake-response":
536
598
  return {
@@ -547,7 +609,10 @@ function toProtoEnvelope(message) {
547
609
  selectedWireFormat: message.selectedWireFormat,
548
610
  serverTime: message.serverTime,
549
611
  blobStorageEnabled: message.blobStorageEnabled,
550
- ...message.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: message.serverMaxDeliverySequence } : {}
612
+ ...message.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: message.serverMaxDeliverySequence } : {},
613
+ acceptedScopeJson: message.acceptedScope ? JSON.stringify(message.acceptedScope) : void 0,
614
+ acceptedDownlinkScopesJson: message.acceptedDownlinkScopes ? JSON.stringify(message.acceptedDownlinkScopes) : void 0,
615
+ acceptedUplinkScopesJson: message.acceptedUplinkScopes ? JSON.stringify(message.acceptedUplinkScopes) : void 0
551
616
  };
552
617
  case "operation-batch":
553
618
  return {
@@ -557,7 +622,8 @@ function toProtoEnvelope(message) {
557
622
  isFinal: message.isFinal,
558
623
  batchIndex: message.batchIndex,
559
624
  ...message.baseDeliverySequence !== void 0 ? { baseDeliverySequence: message.baseDeliverySequence } : {},
560
- ...message.maxDeliverySequence !== void 0 ? { maxDeliverySequence: message.maxDeliverySequence } : {}
625
+ ...message.maxDeliverySequence !== void 0 ? { maxDeliverySequence: message.maxDeliverySequence } : {},
626
+ retractionsJson: message.retractions ? JSON.stringify(message.retractions) : void 0
561
627
  };
562
628
  case "acknowledgment":
563
629
  return {
@@ -631,7 +697,8 @@ function fromProtoEnvelope(envelope) {
631
697
  supportedWireFormats: envelope.supportedWireFormats?.filter(
632
698
  (format) => format === "json" || format === "protobuf"
633
699
  ),
634
- ...envelope.lastDeliverySequence !== void 0 ? { lastDeliverySequence: envelope.lastDeliverySequence } : {}
700
+ ...envelope.lastDeliverySequence !== void 0 ? { lastDeliverySequence: envelope.lastDeliverySequence } : {},
701
+ ...envelope.scopeExitPolicy === "retain" || envelope.scopeExitPolicy === "retract" ? { scopeExitPolicy: envelope.scopeExitPolicy } : {}
635
702
  };
636
703
  case "handshake-response":
637
704
  return {
@@ -649,7 +716,10 @@ function fromProtoEnvelope(envelope) {
649
716
  // automatic timestamp rebase work over the protobuf wire, not just JSON.
650
717
  ...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {},
651
718
  ...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {},
652
- ...envelope.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: envelope.serverMaxDeliverySequence } : {}
719
+ ...envelope.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: envelope.serverMaxDeliverySequence } : {},
720
+ ...envelope.acceptedScopeJson ? { acceptedScope: JSON.parse(envelope.acceptedScopeJson) } : {},
721
+ ...envelope.acceptedDownlinkScopesJson ? { acceptedDownlinkScopes: JSON.parse(envelope.acceptedDownlinkScopesJson) } : {},
722
+ ...envelope.acceptedUplinkScopesJson ? { acceptedUplinkScopes: JSON.parse(envelope.acceptedUplinkScopesJson) } : {}
653
723
  };
654
724
  case "operation-batch":
655
725
  return {
@@ -659,7 +729,8 @@ function fromProtoEnvelope(envelope) {
659
729
  isFinal: envelope.isFinal ?? false,
660
730
  batchIndex: envelope.batchIndex ?? 0,
661
731
  ...envelope.baseDeliverySequence !== void 0 ? { baseDeliverySequence: envelope.baseDeliverySequence } : {},
662
- ...envelope.maxDeliverySequence !== void 0 ? { maxDeliverySequence: envelope.maxDeliverySequence } : {}
732
+ ...envelope.maxDeliverySequence !== void 0 ? { maxDeliverySequence: envelope.maxDeliverySequence } : {},
733
+ ...envelope.retractionsJson ? { retractions: JSON.parse(envelope.retractionsJson) } : {}
663
734
  };
664
735
  case "acknowledgment":
665
736
  return {
@@ -877,6 +948,13 @@ function encodeEnvelope(envelope) {
877
948
  if (envelope.deliverySequence !== void 0) writer.uint32(256).int64(envelope.deliverySequence);
878
949
  if (envelope.serverMaxDeliverySequence !== void 0)
879
950
  writer.uint32(264).int64(envelope.serverMaxDeliverySequence);
951
+ if (envelope.acceptedScopeJson) writer.uint32(274).string(envelope.acceptedScopeJson);
952
+ if (envelope.acceptedDownlinkScopesJson)
953
+ writer.uint32(282).string(envelope.acceptedDownlinkScopesJson);
954
+ if (envelope.acceptedUplinkScopesJson)
955
+ writer.uint32(290).string(envelope.acceptedUplinkScopesJson);
956
+ if (envelope.retractionsJson) writer.uint32(298).string(envelope.retractionsJson);
957
+ if (envelope.scopeExitPolicy) writer.uint32(306).string(envelope.scopeExitPolicy);
880
958
  return writer.finish();
881
959
  }
882
960
  function decodeEnvelope(bytes) {
@@ -987,6 +1065,21 @@ function decodeEnvelope(bytes) {
987
1065
  case 33:
988
1066
  envelope.serverMaxDeliverySequence = longToNumber(reader.int64());
989
1067
  break;
1068
+ case 34:
1069
+ envelope.acceptedScopeJson = reader.string();
1070
+ break;
1071
+ case 35:
1072
+ envelope.acceptedDownlinkScopesJson = reader.string();
1073
+ break;
1074
+ case 36:
1075
+ envelope.acceptedUplinkScopesJson = reader.string();
1076
+ break;
1077
+ case 37:
1078
+ envelope.retractionsJson = reader.string();
1079
+ break;
1080
+ case 38:
1081
+ envelope.scopeExitPolicy = reader.string();
1082
+ break;
990
1083
  default:
991
1084
  reader.skipType(tag & 7);
992
1085
  }
@@ -2640,6 +2733,18 @@ var OutboundQueue = class {
2640
2733
  await this.storage.dequeue([opId]);
2641
2734
  return removed;
2642
2735
  }
2736
+ /** Remove every pending/in-flight operation for a record so authorization loss cannot upload it. */
2737
+ async rejectRecord(collection, recordId) {
2738
+ const matching = [...this.queue, ...[...this.inFlight.values()].flat()].filter(
2739
+ (op) => op.collection === collection && op.recordId === recordId
2740
+ );
2741
+ const removed = [];
2742
+ for (const operation of matching) {
2743
+ const rejected = await this.reject(operation.id);
2744
+ if (rejected) removed.push(rejected);
2745
+ }
2746
+ return removed;
2747
+ }
2643
2748
  /**
2644
2749
  * Remove operations by id from queue and persistent storage.
2645
2750
  * Used when ops were already sent during handshake delta exchange.
@@ -2659,6 +2764,8 @@ var OutboundQueue = class {
2659
2764
  var DEFAULT_BATCH_SIZE = 100;
2660
2765
  var DEFAULT_SCHEMA_VERSION = 1;
2661
2766
  var DEFAULT_OUTBOUND_ACK_TIMEOUT_MS = 3e4;
2767
+ var DEFAULT_OUTBOUND_RETRY_BASE_DELAY_MS = 250;
2768
+ var DEFAULT_OUTBOUND_RETRY_MAX_DELAY_MS = 3e4;
2662
2769
  var VALID_TRANSITIONS = {
2663
2770
  disconnected: ["connecting"],
2664
2771
  connecting: ["handshaking", "error", "disconnected"],
@@ -2694,6 +2801,8 @@ var SyncEngine = class {
2694
2801
  rejectedStorage;
2695
2802
  batchSize;
2696
2803
  outboundAckTimeoutMs;
2804
+ outboundRetryBaseDelayMs;
2805
+ outboundRetryMaxDelayMs;
2697
2806
  encryptor;
2698
2807
  awarenessManager;
2699
2808
  richtextDocChannel;
@@ -2709,11 +2818,19 @@ var SyncEngine = class {
2709
2818
  conflictCount = 0;
2710
2819
  currentBatch = null;
2711
2820
  currentBatchRequiresPartialAck = false;
2821
+ currentBatchRequiresRetryBackoff = false;
2822
+ outboundRetryAttempt = 0;
2823
+ outboundRetryTimer = null;
2712
2824
  reconnecting = false;
2713
2825
  schemaBlocked = false;
2714
2826
  clockBlocked = false;
2715
2827
  clockSkewMs = null;
2716
2828
  blobStorageEnabled = false;
2829
+ suspensionReason = null;
2830
+ authRejected = false;
2831
+ serverFrontier = null;
2832
+ hasInFlightDeliveryBatch = false;
2833
+ blockedFailure = null;
2717
2834
  outboundAckTimer = null;
2718
2835
  startPromise = null;
2719
2836
  stopPromise = null;
@@ -2732,8 +2849,12 @@ var SyncEngine = class {
2732
2849
  * with the server-accepted scope (server is authoritative).
2733
2850
  */
2734
2851
  activeScope;
2852
+ /** Server-authoritative upload authorization, separate from the downloaded view. */
2853
+ activeUplinkScope;
2854
+ hasDirectionalScopes = false;
2735
2855
  /** Live query subsets registered from reactive subscriptions */
2736
2856
  querySubsets = /* @__PURE__ */ new Map();
2857
+ staticQuerySubsets = [];
2737
2858
  querySubsetReconnectTimer = null;
2738
2859
  /** Resume cursor for paginated initial sync (persisted across reconnects) */
2739
2860
  resumeDeltaCursor = null;
@@ -2767,9 +2888,18 @@ var SyncEngine = class {
2767
2888
  this.emitter = options.emitter ?? null;
2768
2889
  this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
2769
2890
  this.outboundAckTimeoutMs = options.config.outboundAckTimeoutMs ?? DEFAULT_OUTBOUND_ACK_TIMEOUT_MS;
2891
+ this.outboundRetryBaseDelayMs = Math.max(
2892
+ 0,
2893
+ options.config.outboundRetryBaseDelayMs ?? DEFAULT_OUTBOUND_RETRY_BASE_DELAY_MS
2894
+ );
2895
+ this.outboundRetryMaxDelayMs = Math.max(
2896
+ this.outboundRetryBaseDelayMs,
2897
+ options.config.outboundRetryMaxDelayMs ?? DEFAULT_OUTBOUND_RETRY_MAX_DELAY_MS
2898
+ );
2770
2899
  this.encryptor = options.encryptor ?? null;
2771
2900
  this.syncState = options.syncState ?? null;
2772
2901
  this.activeScope = options.config.scopeMap;
2902
+ this.activeUplinkScope = options.config.scopeMap;
2773
2903
  const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
2774
2904
  this.outboundQueue = new OutboundQueue(queueStorage);
2775
2905
  this.rejectedStorage = options.rejectedStorage ?? new MemoryRejectedOperationStorage();
@@ -2827,6 +2957,22 @@ var SyncEngine = class {
2827
2957
  if (this.state !== "disconnected") {
2828
2958
  await this.stop();
2829
2959
  }
2960
+ if (this.authRejected) {
2961
+ this.emitter?.emit({
2962
+ type: "sync:suspended",
2963
+ reason: this.suspensionReason ?? "auth-rejected"
2964
+ });
2965
+ return;
2966
+ }
2967
+ if (this.config.authState) {
2968
+ const authState = await this.config.authState();
2969
+ if (authState.state === "loading" || authState.state === "signed-out") {
2970
+ this.suspensionReason = authState.state === "loading" ? "auth-loading" : "auth-required";
2971
+ this.emitter?.emit({ type: "sync:suspended", reason: this.suspensionReason });
2972
+ return;
2973
+ }
2974
+ }
2975
+ this.suspensionReason = null;
2830
2976
  await this.outboundQueue.initialize();
2831
2977
  if (this.syncState) {
2832
2978
  this.lastAckedServerVector = await this.syncState.loadLastAckedServerVector();
@@ -2876,6 +3022,7 @@ var SyncEngine = class {
2876
3022
  authToken,
2877
3023
  supportedWireFormats: ["json", "protobuf"],
2878
3024
  ...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
3025
+ ...this.config.scopeExit ? { scopeExitPolicy: this.config.scopeExit } : {},
2879
3026
  ...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
2880
3027
  ...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {},
2881
3028
  // Resume the server's gap-free delivery stream from our watermark. A server
@@ -2900,13 +3047,15 @@ var SyncEngine = class {
2900
3047
  return this.stopPromise;
2901
3048
  }
2902
3049
  async stopInternal() {
2903
- if (this.state === "disconnected") return;
2904
3050
  this.clearOutboundAckTimer();
3051
+ this.clearOutboundRetryTimer();
3052
+ if (this.state === "disconnected") return;
2905
3053
  this.awarenessManager.stopCleanupTimer();
2906
3054
  if (this.currentBatch) {
2907
3055
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
2908
3056
  this.currentBatch = null;
2909
3057
  this.currentBatchRequiresPartialAck = false;
3058
+ this.currentBatchRequiresRetryBackoff = false;
2910
3059
  }
2911
3060
  try {
2912
3061
  await this.transport.disconnect();
@@ -2956,14 +3105,30 @@ var SyncEngine = class {
2956
3105
  getStatus() {
2957
3106
  const pendingOperations = this.syncState ? this.cachedUnsyncedCount : this.outboundQueue.totalPending;
2958
3107
  const base = {
3108
+ phase: this.resolvePhase(),
3109
+ ...this.suspensionReason ? { reason: this.suspensionReason } : {},
2959
3110
  reconnecting: this.reconnecting,
2960
3111
  pendingOperations,
2961
3112
  lastSyncedAt: this.lastSyncedAt,
2962
3113
  lastSuccessfulPush: this.lastSuccessfulPush,
2963
3114
  lastSuccessfulPull: this.lastSuccessfulPull,
2964
3115
  conflicts: this.conflictCount,
2965
- clockSkewMs: this.clockSkewMs
3116
+ clockSkewMs: this.clockSkewMs,
3117
+ inFlightUploadOperations: this.currentBatch?.operations.length ?? 0,
3118
+ hasInFlightDeliveryBatch: this.hasInFlightDeliveryBatch,
3119
+ activeViewId: this.deliverySignature(),
3120
+ activeViewComplete: this.state === "streaming" && !this.hasInFlightDeliveryBatch && this.blockedFailure === null && (this.serverFrontier === null || this.deliveryWatermark >= this.serverFrontier),
3121
+ initialSync: {
3122
+ complete: this.deltaReceiveComplete && this.deltaSendComplete,
3123
+ receivedBatches: this.deltaBatchesReceived,
3124
+ totalBatches: this.initialSyncTotalBatches || null,
3125
+ progress: this.initialSyncTotalBatches > 0 ? Math.min(1, this.deltaBatchesReceived / this.initialSyncTotalBatches) : null
3126
+ },
3127
+ deliveryWatermark: this.deliveryWatermark,
3128
+ serverFrontier: this.serverFrontier,
3129
+ blockedFailure: this.blockedFailure
2966
3130
  };
3131
+ if (this.suspensionReason) return { ...base, status: "auth-required" };
2967
3132
  switch (this.state) {
2968
3133
  case "disconnected":
2969
3134
  if (this.clockBlocked) {
@@ -2986,6 +3151,26 @@ var SyncEngine = class {
2986
3151
  return { ...base, status: this.schemaBlocked ? "schema-mismatch" : "error" };
2987
3152
  }
2988
3153
  }
3154
+ resolvePhase() {
3155
+ if (this.suspensionReason) return "suspended";
3156
+ if (this.blockedFailure || this.schemaBlocked || this.clockBlocked) return "blocked";
3157
+ if (this.hasInFlightDeliveryBatch) return "applying";
3158
+ if (this.currentBatch) return "uploading";
3159
+ switch (this.state) {
3160
+ case "disconnected":
3161
+ return "offline";
3162
+ case "connecting":
3163
+ return "connecting";
3164
+ case "handshaking":
3165
+ return "handshaking";
3166
+ case "syncing":
3167
+ return "receiving";
3168
+ case "streaming":
3169
+ return "streaming";
3170
+ case "error":
3171
+ return "blocked";
3172
+ }
3173
+ }
2989
3174
  /**
2990
3175
  * True when the server rejected the client's schema version at handshake.
2991
3176
  * Sync stays blocked until the app schema is upgraded or sync config changes.
@@ -3038,8 +3223,25 @@ var SyncEngine = class {
3038
3223
  await this.refreshPendingCount();
3039
3224
  return this.getStatus().pendingOperations;
3040
3225
  }
3041
- emitApplyFailure(op, result, overrides) {
3226
+ emitApplyFailure(op, result, overrides, blocking = false) {
3042
3227
  const reason = defaultApplyFailureReason(result, overrides);
3228
+ if (blocking || reason.retriable) {
3229
+ const prior = this.blockedFailure;
3230
+ this.blockedFailure = {
3231
+ operationId: op.id,
3232
+ collection: op.collection,
3233
+ recordId: op.recordId,
3234
+ code: reason.code,
3235
+ message: reason.message,
3236
+ retriable: reason.retriable,
3237
+ firstSeenAt: prior?.operationId === op.id ? prior.firstSeenAt : Date.now(),
3238
+ retryCount: prior?.operationId === op.id ? prior.retryCount + 1 : 0
3239
+ };
3240
+ this.emitter?.emit({
3241
+ type: prior?.operationId === op.id ? "sync:apply-retrying" : "sync:apply-blocked",
3242
+ failure: this.blockedFailure
3243
+ });
3244
+ }
3043
3245
  this.emitter?.emit({
3044
3246
  type: "sync:apply-failed",
3045
3247
  operationId: op.id,
@@ -3056,11 +3258,18 @@ var SyncEngine = class {
3056
3258
  */
3057
3259
  async retryNow() {
3058
3260
  if (this.schemaBlocked) return;
3261
+ this.authRejected = false;
3262
+ this.suspensionReason = null;
3059
3263
  if (this.state === "disconnected" || this.state === "error") {
3060
3264
  this.reconnecting = false;
3061
3265
  await this.start();
3062
3266
  }
3063
3267
  }
3268
+ /** Wake a permanent auth suspension after the binding reports a new auth state. */
3269
+ notifyAuthChanged() {
3270
+ this.authRejected = false;
3271
+ this.suspensionReason = null;
3272
+ }
3064
3273
  /**
3065
3274
  * Atomically refresh the transport session. Overlapping callers share one
3066
3275
  * reconnect, in-flight outbound batches are returned to the queue by stop(), and the
@@ -3129,6 +3338,8 @@ var SyncEngine = class {
3129
3338
  updateScope(scopeMap) {
3130
3339
  const previousSignature = this.deliverySignature();
3131
3340
  this.activeScope = scopeMap;
3341
+ this.activeUplinkScope = scopeMap;
3342
+ this.hasDirectionalScopes = false;
3132
3343
  this.config.scopeMap = scopeMap;
3133
3344
  this.switchDeliveryView(previousSignature);
3134
3345
  }
@@ -3212,28 +3423,58 @@ var SyncEngine = class {
3212
3423
  getActiveScope() {
3213
3424
  return this.activeScope;
3214
3425
  }
3426
+ /** Get the server-accepted upload authorization scope. */
3427
+ getActiveUplinkScope() {
3428
+ return this.activeUplinkScope;
3429
+ }
3215
3430
  /**
3216
3431
  * Register a live query subset that narrows synced data for a collection.
3217
3432
  * Takes effect on the next connection; reconnects when already connected.
3218
3433
  */
3219
3434
  registerQuerySubset(subset) {
3435
+ if ((this.config.querySubsets?.mode ?? "reactive") !== "reactive") return () => {
3436
+ };
3220
3437
  const id = `query-${nextQuerySubsetId++}`;
3221
3438
  const previousSignature = this.deliverySignature();
3439
+ const previousEffective = this.getActiveQuerySubsets();
3222
3440
  this.querySubsets.set(id, subset);
3223
- this.switchDeliveryView(previousSignature);
3224
- this.scheduleQuerySubsetReconnect();
3441
+ this.switchDeliveryViewWithInheritance(previousSignature, previousEffective);
3442
+ if (this.deliverySignature() !== previousSignature) this.scheduleQuerySubsetReconnect();
3225
3443
  return () => {
3226
3444
  const sigBeforeRemove = this.deliverySignature();
3445
+ const effectiveBeforeRemove = this.getActiveQuerySubsets();
3227
3446
  this.querySubsets.delete(id);
3228
- this.switchDeliveryView(sigBeforeRemove);
3229
- this.scheduleQuerySubsetReconnect();
3447
+ this.switchDeliveryViewWithInheritance(sigBeforeRemove, effectiveBeforeRemove);
3448
+ if (this.deliverySignature() !== sigBeforeRemove) this.scheduleQuerySubsetReconnect();
3230
3449
  };
3231
3450
  }
3451
+ switchDeliveryViewWithInheritance(previousSignature, previousSubsets) {
3452
+ const nextSubsets = this.getActiveQuerySubsets();
3453
+ const previousWatermark = this.deliveryWatermark;
3454
+ this.switchDeliveryView(previousSignature);
3455
+ if (this.deliveryWatermark === 0 && querySubsetContains(previousSubsets, nextSubsets)) {
3456
+ this.deliveryWatermark = previousWatermark;
3457
+ this.setViewWatermark(this.deliverySignature(), previousWatermark);
3458
+ void this.persistDeliveryWatermark(previousWatermark);
3459
+ }
3460
+ }
3461
+ /** Atomically replace the manifest used by static query-subset mode. */
3462
+ setQuerySubsets(subsets) {
3463
+ if ((this.config.querySubsets?.mode ?? "reactive") !== "static")
3464
+ throw new Error('setQuerySubsets() requires sync.querySubsets.mode = "static"');
3465
+ const previousSignature = this.deliverySignature();
3466
+ const previousEffective = this.getActiveQuerySubsets();
3467
+ this.staticQuerySubsets = dedupeQuerySubsets(subsets);
3468
+ this.switchDeliveryViewWithInheritance(previousSignature, previousEffective);
3469
+ if (this.deliverySignature() !== previousSignature) this.scheduleQuerySubsetReconnect();
3470
+ }
3232
3471
  /**
3233
3472
  * Returns deduplicated active query subsets from registered subscriptions.
3234
3473
  */
3235
3474
  getActiveQuerySubsets() {
3236
- return dedupeQuerySubsets([...this.querySubsets.values()]);
3475
+ const mode = this.config.querySubsets?.mode ?? "reactive";
3476
+ if (mode === "disabled") return [];
3477
+ return mode === "static" ? this.staticQuerySubsets : dedupeQuerySubsets([...this.querySubsets.values()]);
3237
3478
  }
3238
3479
  /**
3239
3480
  * Get the awareness manager for collaborative presence.
@@ -3310,6 +3551,7 @@ var SyncEngine = class {
3310
3551
  }
3311
3552
  }
3312
3553
  handleMessageFailure(error) {
3554
+ this.hasInFlightDeliveryBatch = false;
3313
3555
  const reason = error instanceof Error ? error.message : "Message handling failed";
3314
3556
  this.handleTransportClose(reason);
3315
3557
  }
@@ -3383,11 +3625,21 @@ var SyncEngine = class {
3383
3625
  this.setViewWatermark(this.deliverySignature(), 0);
3384
3626
  await this.persistDeliveryWatermark(0);
3385
3627
  }
3628
+ this.serverFrontier = msg.serverMaxDeliverySequence ?? null;
3386
3629
  if (msg.selectedWireFormat) {
3387
3630
  this.setSerializerWireFormat(msg.selectedWireFormat);
3388
3631
  }
3389
- if (msg.acceptedScope) {
3390
- this.activeScope = msg.acceptedScope;
3632
+ if (msg.acceptedDownlinkScopes ?? msg.acceptedScope) {
3633
+ this.activeScope = msg.acceptedDownlinkScopes ?? msg.acceptedScope;
3634
+ }
3635
+ this.hasDirectionalScopes = msg.acceptedUplinkScopes !== void 0;
3636
+ this.activeUplinkScope = msg.acceptedUplinkScopes ?? msg.acceptedScope ?? this.activeScope;
3637
+ if (this.config.scopeExit === "retract" && this.activeScope && this.store.applyScopeNarrowing) {
3638
+ const narrowed = await this.store.applyScopeNarrowing(this.activeScope);
3639
+ for (const retraction of narrowed) {
3640
+ await this.applyScopeRetraction(retraction, false);
3641
+ }
3642
+ await this.refreshPendingCount();
3391
3643
  }
3392
3644
  this.emitter?.emit({ type: "sync:connected", nodeId: this.store.getNodeId() });
3393
3645
  this.metricsCollector.recordConnected();
@@ -3544,6 +3796,7 @@ var SyncEngine = class {
3544
3796
  this.sendDeliveryAck(msg.messageId, this.deliveryWatermark);
3545
3797
  return;
3546
3798
  }
3799
+ this.hasInFlightDeliveryBatch = true;
3547
3800
  }
3548
3801
  const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
3549
3802
  const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
@@ -3551,6 +3804,14 @@ var SyncEngine = class {
3551
3804
  const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
3552
3805
  const transforms = this.config.operationTransforms ?? [];
3553
3806
  let fullyApplied = true;
3807
+ for (const retraction of msg.retractions ?? []) {
3808
+ try {
3809
+ await this.applyScopeRetraction(retraction, true);
3810
+ } catch {
3811
+ fullyApplied = false;
3812
+ }
3813
+ }
3814
+ await this.refreshPendingCount();
3554
3815
  for (const op of inScopeOps) {
3555
3816
  const transformed = transforms.length > 0 ? applyOperationTransforms(op, targetSchemaVersion, transforms) : op;
3556
3817
  if (transformed === null) {
@@ -3564,17 +3825,22 @@ var SyncEngine = class {
3564
3825
  } catch (error) {
3565
3826
  fullyApplied = false;
3566
3827
  if (error instanceof ClockDriftError) {
3567
- this.emitApplyFailure(transformed, "rejected", {
3568
- code: APPLY_FAILURE_CODES.CLOCK_DRIFT,
3569
- message: error.message,
3570
- retriable: false
3571
- });
3828
+ this.emitApplyFailure(
3829
+ transformed,
3830
+ "rejected",
3831
+ {
3832
+ code: APPLY_FAILURE_CODES.CLOCK_DRIFT,
3833
+ message: error.message,
3834
+ retriable: false
3835
+ },
3836
+ true
3837
+ );
3572
3838
  continue;
3573
3839
  }
3574
3840
  const message = error instanceof Error ? error.message : "Apply failed";
3575
3841
  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;
3576
3842
  const retriable = code !== APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY;
3577
- this.emitApplyFailure(transformed, "rejected", { code, message, retriable });
3843
+ this.emitApplyFailure(transformed, "rejected", { code, message, retriable }, true);
3578
3844
  }
3579
3845
  }
3580
3846
  if (inScopeOps.length > 0) {
@@ -3607,7 +3873,13 @@ var SyncEngine = class {
3607
3873
  this.deliveryWatermark = msg.maxDeliverySequence;
3608
3874
  this.setViewWatermark(this.deliverySignature(), this.deliveryWatermark);
3609
3875
  await this.persistDeliveryWatermark(this.deliveryWatermark);
3876
+ if (this.blockedFailure && inScopeOps.some((op) => op.id === this.blockedFailure?.operationId)) {
3877
+ const recovered = this.blockedFailure;
3878
+ this.blockedFailure = null;
3879
+ this.emitter?.emit({ type: "sync:apply-recovered", failure: recovered });
3880
+ }
3610
3881
  }
3882
+ this.hasInFlightDeliveryBatch = false;
3611
3883
  if (this.state === "syncing") {
3612
3884
  this.deltaBatchesReceived++;
3613
3885
  const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
@@ -3647,6 +3919,7 @@ var SyncEngine = class {
3647
3919
  }
3648
3920
  async handleAcknowledgment(msg) {
3649
3921
  this.clearOutboundAckTimer();
3922
+ const requiresRetryBackoff = this.currentBatchRequiresRetryBackoff;
3650
3923
  if (this.state === "syncing" && this.config.strictHandshake) {
3651
3924
  this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
3652
3925
  this.markDeltaSendCompleteIfReady();
@@ -3662,6 +3935,7 @@ var SyncEngine = class {
3662
3935
  }
3663
3936
  this.currentBatch = null;
3664
3937
  this.currentBatchRequiresPartialAck = false;
3938
+ this.currentBatchRequiresRetryBackoff = false;
3665
3939
  const now = Date.now();
3666
3940
  this.lastSyncedAt = now;
3667
3941
  this.lastSuccessfulPush = now;
@@ -3669,7 +3943,14 @@ var SyncEngine = class {
3669
3943
  await this.advanceLastAckedForLocalNode(msg.lastSequenceNumber);
3670
3944
  await this.refreshPendingCount();
3671
3945
  if (this.state === "streaming" && this.outboundQueue.hasOperations) {
3672
- this.flushQueue();
3946
+ if (requiresRetryBackoff) {
3947
+ this.scheduleOutboundRetry();
3948
+ } else {
3949
+ this.outboundRetryAttempt = 0;
3950
+ this.flushQueue();
3951
+ }
3952
+ } else if (!requiresRetryBackoff) {
3953
+ this.outboundRetryAttempt = 0;
3673
3954
  }
3674
3955
  }
3675
3956
  handleError(msg) {
@@ -3678,10 +3959,14 @@ var SyncEngine = class {
3678
3959
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
3679
3960
  this.currentBatch = null;
3680
3961
  this.currentBatchRequiresPartialAck = false;
3962
+ this.currentBatchRequiresRetryBackoff = false;
3681
3963
  }
3682
3964
  this.transitionTo("error");
3683
- if (msg.code === "AUTH_FAILED") {
3965
+ if (msg.code === "AUTH_FAILED" || msg.code === "DEVICE_REVOKED") {
3966
+ this.authRejected = true;
3967
+ this.suspensionReason = msg.code === "DEVICE_REVOKED" ? "device-revoked" : "auth-rejected";
3684
3968
  this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
3969
+ this.emitter?.emit({ type: "sync:suspended", reason: this.suspensionReason });
3685
3970
  }
3686
3971
  if (msg.code === "INVALID_TIMESTAMP") {
3687
3972
  this.clockBlocked = true;
@@ -3704,9 +3989,39 @@ var SyncEngine = class {
3704
3989
  * the app can reconcile. Unlike {@link handleError}, this is a normal per-op
3705
3990
  * signal, so the connection stays up.
3706
3991
  */
3992
+ async applyScopeRetraction(retraction, applyToStore) {
3993
+ if (applyToStore) {
3994
+ if (!this.store.applyScopeRetraction) {
3995
+ throw new Error("The configured sync store does not support scope retractions");
3996
+ }
3997
+ await this.store.applyScopeRetraction(retraction.collection, retraction.recordId);
3998
+ }
3999
+ const quarantined = await this.outboundQueue.rejectRecord(
4000
+ retraction.collection,
4001
+ retraction.recordId
4002
+ );
4003
+ for (const operation of quarantined) {
4004
+ await this.rejectedStorage.record({
4005
+ operationId: operation.id,
4006
+ collection: operation.collection,
4007
+ recordId: operation.recordId,
4008
+ code: "SCOPE_RETRACTED",
4009
+ message: "Authorization changed before this operation could be uploaded.",
4010
+ retriable: false,
4011
+ rejectedAt: Date.now()
4012
+ });
4013
+ }
4014
+ this.emitter?.emit({
4015
+ type: "sync:scope-retracted",
4016
+ collection: retraction.collection,
4017
+ recordId: retraction.recordId,
4018
+ quarantinedOperationIds: quarantined.map((operation) => operation.id)
4019
+ });
4020
+ }
3707
4021
  async handleOperationRejected(msg) {
3708
4022
  if (msg.retriable) {
3709
4023
  this.currentBatchRequiresPartialAck = true;
4024
+ this.currentBatchRequiresRetryBackoff = true;
3710
4025
  await this.refreshPendingCount();
3711
4026
  this.emitter?.emit({
3712
4027
  type: "sync:operation-rejected",
@@ -3860,11 +4175,13 @@ var SyncEngine = class {
3860
4175
  }
3861
4176
  flushQueue() {
3862
4177
  if (this.currentBatch) return;
4178
+ if (this.outboundRetryTimer) return;
3863
4179
  if (!this.outboundQueue.hasOperations) return;
3864
4180
  const batch = this.outboundQueue.takeBatch(this.batchSize);
3865
4181
  if (!batch) return;
3866
4182
  this.currentBatch = batch;
3867
4183
  this.currentBatchRequiresPartialAck = false;
4184
+ this.currentBatchRequiresRetryBackoff = false;
3868
4185
  if (this.encryptor) {
3869
4186
  this.encryptor.encryptBatch(batch.operations).then(
3870
4187
  (encrypted) => {
@@ -3888,6 +4205,7 @@ var SyncEngine = class {
3888
4205
  this.outboundQueue.returnBatch(batch.batchId);
3889
4206
  this.currentBatch = null;
3890
4207
  this.currentBatchRequiresPartialAck = false;
4208
+ this.currentBatchRequiresRetryBackoff = false;
3891
4209
  this.emitter?.emit({
3892
4210
  type: "sync:disconnected",
3893
4211
  reason: err instanceof Error ? err.message : "Encryption failed"
@@ -3924,11 +4242,29 @@ var SyncEngine = class {
3924
4242
  clearTimeout(this.outboundAckTimer);
3925
4243
  this.outboundAckTimer = null;
3926
4244
  }
4245
+ scheduleOutboundRetry() {
4246
+ if (this.outboundRetryTimer || !this.outboundQueue.hasOperations) return;
4247
+ const delay = Math.min(
4248
+ this.outboundRetryMaxDelayMs,
4249
+ this.outboundRetryBaseDelayMs * 2 ** this.outboundRetryAttempt
4250
+ );
4251
+ this.outboundRetryAttempt += 1;
4252
+ this.outboundRetryTimer = setTimeout(() => {
4253
+ this.outboundRetryTimer = null;
4254
+ if (this.state === "streaming") this.flushQueue();
4255
+ }, delay);
4256
+ }
4257
+ clearOutboundRetryTimer() {
4258
+ if (!this.outboundRetryTimer) return;
4259
+ clearTimeout(this.outboundRetryTimer);
4260
+ this.outboundRetryTimer = null;
4261
+ }
3927
4262
  async handleOutboundAckTimeout(batchId) {
3928
4263
  if (!this.currentBatch || this.currentBatch.batchId !== batchId) return;
3929
4264
  this.outboundQueue.returnBatch(batchId);
3930
4265
  this.currentBatch = null;
3931
4266
  this.currentBatchRequiresPartialAck = false;
4267
+ this.currentBatchRequiresRetryBackoff = false;
3932
4268
  this.clearOutboundAckTimer();
3933
4269
  await this.refreshPendingCount();
3934
4270
  if (this.state === "disconnected") return;
@@ -3950,10 +4286,12 @@ var SyncEngine = class {
3950
4286
  }
3951
4287
  handleTransportClose(reason) {
3952
4288
  this.clearOutboundAckTimer();
4289
+ this.clearOutboundRetryTimer();
3953
4290
  if (this.currentBatch) {
3954
4291
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
3955
4292
  this.currentBatch = null;
3956
4293
  this.currentBatchRequiresPartialAck = false;
4294
+ this.currentBatchRequiresRetryBackoff = false;
3957
4295
  }
3958
4296
  if (this.schemaBlocked) {
3959
4297
  return;
@@ -3996,10 +4334,10 @@ var SyncEngine = class {
3996
4334
  return this.matchesScopeAndSubsets(op, fullRecord);
3997
4335
  }
3998
4336
  matchesScopeAndSubsets(op, fullRecord) {
3999
- if (!operationMatchesScope(op, this.activeScope, fullRecord)) {
4337
+ if (!operationMatchesScope(op, this.activeUplinkScope, fullRecord)) {
4000
4338
  return false;
4001
4339
  }
4002
- return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets(), fullRecord);
4340
+ return this.hasDirectionalScopes || operationMatchesQuerySubsets(op, this.getActiveQuerySubsets(), fullRecord);
4003
4341
  }
4004
4342
  async readRecordForBackfill(op) {
4005
4343
  if (!this.store.readRecordFields) {
@@ -4725,13 +5063,22 @@ function fromBase642(str) {
4725
5063
  // src/reactivity/sync-status-controller.ts
4726
5064
  var OFFLINE_SYNC_STATUS = Object.freeze({
4727
5065
  status: "offline",
5066
+ phase: "offline",
4728
5067
  reconnecting: false,
4729
5068
  pendingOperations: 0,
4730
5069
  lastSyncedAt: null,
4731
5070
  lastSuccessfulPush: null,
4732
5071
  lastSuccessfulPull: null,
4733
5072
  conflicts: 0,
4734
- clockSkewMs: null
5073
+ clockSkewMs: null,
5074
+ inFlightUploadOperations: 0,
5075
+ hasInFlightDeliveryBatch: false,
5076
+ activeViewId: "",
5077
+ activeViewComplete: false,
5078
+ initialSync: { complete: false, receivedBatches: 0, totalBatches: null, progress: null },
5079
+ deliveryWatermark: 0,
5080
+ serverFrontier: null,
5081
+ blockedFailure: null
4735
5082
  });
4736
5083
  var SYNC_STATUS_EVENT_TYPES = [
4737
5084
  "sync:connected",
@@ -4743,6 +5090,10 @@ var SYNC_STATUS_EVENT_TYPES = [
4743
5090
  "sync:received",
4744
5091
  "sync:acknowledged",
4745
5092
  "sync:apply-failed",
5093
+ "sync:suspended",
5094
+ "sync:apply-blocked",
5095
+ "sync:apply-retrying",
5096
+ "sync:apply-recovered",
4746
5097
  "sync:delivery-gap",
4747
5098
  "sync:diagnostics",
4748
5099
  "sync:initial-sync-progress"
@@ -4934,6 +5285,7 @@ export {
4934
5285
  isYjsDocUpdateMessage,
4935
5286
  operationMatchesQuerySubsets,
4936
5287
  operationMatchesScope,
5288
+ querySubsetContains,
4937
5289
  richtextDocKey,
4938
5290
  sliceOperationsAfterCursor,
4939
5291
  subscribeRemoteAwarenessStates,