@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.cjs CHANGED
@@ -86,6 +86,7 @@ __export(index_exports, {
86
86
  isYjsDocUpdateMessage: () => isYjsDocUpdateMessage,
87
87
  operationMatchesQuerySubsets: () => operationMatchesQuerySubsets,
88
88
  operationMatchesScope: () => operationMatchesScope,
89
+ querySubsetContains: () => querySubsetContains,
89
90
  richtextDocKey: () => richtextDocKey,
90
91
  sliceOperationsAfterCursor: () => sliceOperationsAfterCursor,
91
92
  subscribeRemoteAwarenessStates: () => subscribeRemoteAwarenessStates,
@@ -112,7 +113,8 @@ var SYNC_STATUSES = [
112
113
  "offline",
113
114
  "clock-error",
114
115
  "error",
115
- "schema-mismatch"
116
+ "schema-mismatch",
117
+ "auth-required"
116
118
  ];
117
119
  var MemoryRejectedOperationStorage = class {
118
120
  rejected = /* @__PURE__ */ new Map();
@@ -160,12 +162,19 @@ function operationMatchesScope(op, scopeMap, fullRecord) {
160
162
  const snapshot = buildSnapshot(op, fullRecord ?? void 0);
161
163
  if (!snapshot) return false;
162
164
  for (const [field, expected] of Object.entries(collectionScope)) {
163
- if (snapshot[field] !== expected) {
165
+ if (!matchesPredicate(snapshot[field], expected)) {
164
166
  return false;
165
167
  }
166
168
  }
167
169
  return true;
168
170
  }
171
+ function matchesPredicate(actual, expected) {
172
+ if (expected && typeof expected === "object" && !Array.isArray(expected) && "$in" in expected) {
173
+ const values = expected.$in;
174
+ return Array.isArray(values) && values.some((value) => Object.is(actual, value));
175
+ }
176
+ return Object.is(actual, expected);
177
+ }
169
178
  function filterOperationsByScope(operations, scopeMap) {
170
179
  if (!scopeMap) return operations;
171
180
  return operations.filter((op) => operationMatchesScope(op, scopeMap));
@@ -222,24 +231,77 @@ function operationMatchesQuerySubsets(op, subsets, fullRecord) {
222
231
  if (collectionSubsets.length === 0) {
223
232
  return true;
224
233
  }
234
+ if (collectionSubsets.some((subset) => Object.keys(subset.where).length === 0)) {
235
+ return true;
236
+ }
225
237
  const snapshot = buildSnapshot2(op, fullRecord);
226
238
  if (!snapshot) {
227
239
  return false;
228
240
  }
229
241
  return collectionSubsets.some((subset) => recordMatchesWhere(snapshot, subset.where));
230
242
  }
243
+ function stableValue(value) {
244
+ if (Array.isArray(value)) return value.map(stableValue);
245
+ if (value && typeof value === "object") {
246
+ return Object.fromEntries(
247
+ Object.entries(value).filter(([, entry]) => entry !== void 0).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => [key, stableValue(entry)])
248
+ );
249
+ }
250
+ return value;
251
+ }
252
+ function whereContains(broad, narrow) {
253
+ return Object.entries(broad).every(
254
+ ([field, expected]) => JSON.stringify(stableValue(narrow[field])) === JSON.stringify(stableValue(expected))
255
+ );
256
+ }
231
257
  function dedupeQuerySubsets(subsets) {
232
- const seen = /* @__PURE__ */ new Set();
258
+ const normalized = subsets.map((subset) => ({
259
+ collection: subset.collection,
260
+ where: stableValue(subset.where)
261
+ }));
233
262
  const result = [];
234
- for (const subset of subsets) {
235
- const key = `${subset.collection}:${JSON.stringify(subset.where)}`;
236
- if (seen.has(key)) {
263
+ for (const candidate of normalized) {
264
+ if (normalized.some(
265
+ (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))
266
+ )) {
267
+ const duplicateAlreadyKept = result.some(
268
+ (item) => item.collection === candidate.collection && JSON.stringify(item.where) === JSON.stringify(candidate.where)
269
+ );
270
+ if (duplicateAlreadyKept || normalized.some(
271
+ (other) => other !== candidate && other.collection === candidate.collection && Object.keys(other.where).length < Object.keys(candidate.where).length && whereContains(other.where, candidate.where)
272
+ ))
273
+ continue;
274
+ }
275
+ if (!result.some(
276
+ (item) => item.collection === candidate.collection && JSON.stringify(item.where) === JSON.stringify(candidate.where)
277
+ ))
278
+ result.push(candidate);
279
+ }
280
+ return result.sort(
281
+ (a, b) => `${a.collection}:${JSON.stringify(a.where)}`.localeCompare(
282
+ `${b.collection}:${JSON.stringify(b.where)}`
283
+ )
284
+ );
285
+ }
286
+ function querySubsetContains(broad, narrow) {
287
+ const collections = /* @__PURE__ */ new Set([
288
+ ...broad.map((subset) => subset.collection),
289
+ ...narrow.map((subset) => subset.collection)
290
+ ]);
291
+ for (const collection of collections) {
292
+ const broadForCollection = broad.filter((subset) => subset.collection === collection);
293
+ const narrowForCollection = narrow.filter((subset) => subset.collection === collection);
294
+ if (narrowForCollection.length === 0) {
295
+ if (broadForCollection.length > 0) return false;
237
296
  continue;
238
297
  }
239
- seen.add(key);
240
- result.push(subset);
298
+ if (broadForCollection.length === 0) continue;
299
+ if (!narrowForCollection.every(
300
+ (candidate) => broadForCollection.some((other) => whereContains(other.where, candidate.where))
301
+ ))
302
+ return false;
241
303
  }
242
- return result;
304
+ return true;
243
305
  }
244
306
 
245
307
  // src/delta/delta-cursor.ts
@@ -622,7 +684,8 @@ function toProtoEnvelope(message) {
622
684
  schemaVersion: message.schemaVersion,
623
685
  authToken: message.authToken,
624
686
  supportedWireFormats: message.supportedWireFormats,
625
- ...message.lastDeliverySequence !== void 0 ? { lastDeliverySequence: message.lastDeliverySequence } : {}
687
+ ...message.lastDeliverySequence !== void 0 ? { lastDeliverySequence: message.lastDeliverySequence } : {},
688
+ scopeExitPolicy: message.scopeExitPolicy
626
689
  };
627
690
  case "handshake-response":
628
691
  return {
@@ -639,7 +702,10 @@ function toProtoEnvelope(message) {
639
702
  selectedWireFormat: message.selectedWireFormat,
640
703
  serverTime: message.serverTime,
641
704
  blobStorageEnabled: message.blobStorageEnabled,
642
- ...message.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: message.serverMaxDeliverySequence } : {}
705
+ ...message.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: message.serverMaxDeliverySequence } : {},
706
+ acceptedScopeJson: message.acceptedScope ? JSON.stringify(message.acceptedScope) : void 0,
707
+ acceptedDownlinkScopesJson: message.acceptedDownlinkScopes ? JSON.stringify(message.acceptedDownlinkScopes) : void 0,
708
+ acceptedUplinkScopesJson: message.acceptedUplinkScopes ? JSON.stringify(message.acceptedUplinkScopes) : void 0
643
709
  };
644
710
  case "operation-batch":
645
711
  return {
@@ -649,7 +715,8 @@ function toProtoEnvelope(message) {
649
715
  isFinal: message.isFinal,
650
716
  batchIndex: message.batchIndex,
651
717
  ...message.baseDeliverySequence !== void 0 ? { baseDeliverySequence: message.baseDeliverySequence } : {},
652
- ...message.maxDeliverySequence !== void 0 ? { maxDeliverySequence: message.maxDeliverySequence } : {}
718
+ ...message.maxDeliverySequence !== void 0 ? { maxDeliverySequence: message.maxDeliverySequence } : {},
719
+ retractionsJson: message.retractions ? JSON.stringify(message.retractions) : void 0
653
720
  };
654
721
  case "acknowledgment":
655
722
  return {
@@ -723,7 +790,8 @@ function fromProtoEnvelope(envelope) {
723
790
  supportedWireFormats: envelope.supportedWireFormats?.filter(
724
791
  (format) => format === "json" || format === "protobuf"
725
792
  ),
726
- ...envelope.lastDeliverySequence !== void 0 ? { lastDeliverySequence: envelope.lastDeliverySequence } : {}
793
+ ...envelope.lastDeliverySequence !== void 0 ? { lastDeliverySequence: envelope.lastDeliverySequence } : {},
794
+ ...envelope.scopeExitPolicy === "retain" || envelope.scopeExitPolicy === "retract" ? { scopeExitPolicy: envelope.scopeExitPolicy } : {}
727
795
  };
728
796
  case "handshake-response":
729
797
  return {
@@ -741,7 +809,10 @@ function fromProtoEnvelope(envelope) {
741
809
  // automatic timestamp rebase work over the protobuf wire, not just JSON.
742
810
  ...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {},
743
811
  ...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {},
744
- ...envelope.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: envelope.serverMaxDeliverySequence } : {}
812
+ ...envelope.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: envelope.serverMaxDeliverySequence } : {},
813
+ ...envelope.acceptedScopeJson ? { acceptedScope: JSON.parse(envelope.acceptedScopeJson) } : {},
814
+ ...envelope.acceptedDownlinkScopesJson ? { acceptedDownlinkScopes: JSON.parse(envelope.acceptedDownlinkScopesJson) } : {},
815
+ ...envelope.acceptedUplinkScopesJson ? { acceptedUplinkScopes: JSON.parse(envelope.acceptedUplinkScopesJson) } : {}
745
816
  };
746
817
  case "operation-batch":
747
818
  return {
@@ -751,7 +822,8 @@ function fromProtoEnvelope(envelope) {
751
822
  isFinal: envelope.isFinal ?? false,
752
823
  batchIndex: envelope.batchIndex ?? 0,
753
824
  ...envelope.baseDeliverySequence !== void 0 ? { baseDeliverySequence: envelope.baseDeliverySequence } : {},
754
- ...envelope.maxDeliverySequence !== void 0 ? { maxDeliverySequence: envelope.maxDeliverySequence } : {}
825
+ ...envelope.maxDeliverySequence !== void 0 ? { maxDeliverySequence: envelope.maxDeliverySequence } : {},
826
+ ...envelope.retractionsJson ? { retractions: JSON.parse(envelope.retractionsJson) } : {}
755
827
  };
756
828
  case "acknowledgment":
757
829
  return {
@@ -969,6 +1041,13 @@ function encodeEnvelope(envelope) {
969
1041
  if (envelope.deliverySequence !== void 0) writer.uint32(256).int64(envelope.deliverySequence);
970
1042
  if (envelope.serverMaxDeliverySequence !== void 0)
971
1043
  writer.uint32(264).int64(envelope.serverMaxDeliverySequence);
1044
+ if (envelope.acceptedScopeJson) writer.uint32(274).string(envelope.acceptedScopeJson);
1045
+ if (envelope.acceptedDownlinkScopesJson)
1046
+ writer.uint32(282).string(envelope.acceptedDownlinkScopesJson);
1047
+ if (envelope.acceptedUplinkScopesJson)
1048
+ writer.uint32(290).string(envelope.acceptedUplinkScopesJson);
1049
+ if (envelope.retractionsJson) writer.uint32(298).string(envelope.retractionsJson);
1050
+ if (envelope.scopeExitPolicy) writer.uint32(306).string(envelope.scopeExitPolicy);
972
1051
  return writer.finish();
973
1052
  }
974
1053
  function decodeEnvelope(bytes) {
@@ -1079,6 +1158,21 @@ function decodeEnvelope(bytes) {
1079
1158
  case 33:
1080
1159
  envelope.serverMaxDeliverySequence = longToNumber(reader.int64());
1081
1160
  break;
1161
+ case 34:
1162
+ envelope.acceptedScopeJson = reader.string();
1163
+ break;
1164
+ case 35:
1165
+ envelope.acceptedDownlinkScopesJson = reader.string();
1166
+ break;
1167
+ case 36:
1168
+ envelope.acceptedUplinkScopesJson = reader.string();
1169
+ break;
1170
+ case 37:
1171
+ envelope.retractionsJson = reader.string();
1172
+ break;
1173
+ case 38:
1174
+ envelope.scopeExitPolicy = reader.string();
1175
+ break;
1082
1176
  default:
1083
1177
  reader.skipType(tag & 7);
1084
1178
  }
@@ -2744,6 +2838,18 @@ var OutboundQueue = class {
2744
2838
  await this.storage.dequeue([opId]);
2745
2839
  return removed;
2746
2840
  }
2841
+ /** Remove every pending/in-flight operation for a record so authorization loss cannot upload it. */
2842
+ async rejectRecord(collection, recordId) {
2843
+ const matching = [...this.queue, ...[...this.inFlight.values()].flat()].filter(
2844
+ (op) => op.collection === collection && op.recordId === recordId
2845
+ );
2846
+ const removed = [];
2847
+ for (const operation of matching) {
2848
+ const rejected = await this.reject(operation.id);
2849
+ if (rejected) removed.push(rejected);
2850
+ }
2851
+ return removed;
2852
+ }
2747
2853
  /**
2748
2854
  * Remove operations by id from queue and persistent storage.
2749
2855
  * Used when ops were already sent during handshake delta exchange.
@@ -2763,6 +2869,8 @@ var OutboundQueue = class {
2763
2869
  var DEFAULT_BATCH_SIZE = 100;
2764
2870
  var DEFAULT_SCHEMA_VERSION = 1;
2765
2871
  var DEFAULT_OUTBOUND_ACK_TIMEOUT_MS = 3e4;
2872
+ var DEFAULT_OUTBOUND_RETRY_BASE_DELAY_MS = 250;
2873
+ var DEFAULT_OUTBOUND_RETRY_MAX_DELAY_MS = 3e4;
2766
2874
  var VALID_TRANSITIONS = {
2767
2875
  disconnected: ["connecting"],
2768
2876
  connecting: ["handshaking", "error", "disconnected"],
@@ -2798,6 +2906,8 @@ var SyncEngine = class {
2798
2906
  rejectedStorage;
2799
2907
  batchSize;
2800
2908
  outboundAckTimeoutMs;
2909
+ outboundRetryBaseDelayMs;
2910
+ outboundRetryMaxDelayMs;
2801
2911
  encryptor;
2802
2912
  awarenessManager;
2803
2913
  richtextDocChannel;
@@ -2813,11 +2923,19 @@ var SyncEngine = class {
2813
2923
  conflictCount = 0;
2814
2924
  currentBatch = null;
2815
2925
  currentBatchRequiresPartialAck = false;
2926
+ currentBatchRequiresRetryBackoff = false;
2927
+ outboundRetryAttempt = 0;
2928
+ outboundRetryTimer = null;
2816
2929
  reconnecting = false;
2817
2930
  schemaBlocked = false;
2818
2931
  clockBlocked = false;
2819
2932
  clockSkewMs = null;
2820
2933
  blobStorageEnabled = false;
2934
+ suspensionReason = null;
2935
+ authRejected = false;
2936
+ serverFrontier = null;
2937
+ hasInFlightDeliveryBatch = false;
2938
+ blockedFailure = null;
2821
2939
  outboundAckTimer = null;
2822
2940
  startPromise = null;
2823
2941
  stopPromise = null;
@@ -2836,8 +2954,12 @@ var SyncEngine = class {
2836
2954
  * with the server-accepted scope (server is authoritative).
2837
2955
  */
2838
2956
  activeScope;
2957
+ /** Server-authoritative upload authorization, separate from the downloaded view. */
2958
+ activeUplinkScope;
2959
+ hasDirectionalScopes = false;
2839
2960
  /** Live query subsets registered from reactive subscriptions */
2840
2961
  querySubsets = /* @__PURE__ */ new Map();
2962
+ staticQuerySubsets = [];
2841
2963
  querySubsetReconnectTimer = null;
2842
2964
  /** Resume cursor for paginated initial sync (persisted across reconnects) */
2843
2965
  resumeDeltaCursor = null;
@@ -2871,9 +2993,18 @@ var SyncEngine = class {
2871
2993
  this.emitter = options.emitter ?? null;
2872
2994
  this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
2873
2995
  this.outboundAckTimeoutMs = options.config.outboundAckTimeoutMs ?? DEFAULT_OUTBOUND_ACK_TIMEOUT_MS;
2996
+ this.outboundRetryBaseDelayMs = Math.max(
2997
+ 0,
2998
+ options.config.outboundRetryBaseDelayMs ?? DEFAULT_OUTBOUND_RETRY_BASE_DELAY_MS
2999
+ );
3000
+ this.outboundRetryMaxDelayMs = Math.max(
3001
+ this.outboundRetryBaseDelayMs,
3002
+ options.config.outboundRetryMaxDelayMs ?? DEFAULT_OUTBOUND_RETRY_MAX_DELAY_MS
3003
+ );
2874
3004
  this.encryptor = options.encryptor ?? null;
2875
3005
  this.syncState = options.syncState ?? null;
2876
3006
  this.activeScope = options.config.scopeMap;
3007
+ this.activeUplinkScope = options.config.scopeMap;
2877
3008
  const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
2878
3009
  this.outboundQueue = new OutboundQueue(queueStorage);
2879
3010
  this.rejectedStorage = options.rejectedStorage ?? new MemoryRejectedOperationStorage();
@@ -2931,6 +3062,22 @@ var SyncEngine = class {
2931
3062
  if (this.state !== "disconnected") {
2932
3063
  await this.stop();
2933
3064
  }
3065
+ if (this.authRejected) {
3066
+ this.emitter?.emit({
3067
+ type: "sync:suspended",
3068
+ reason: this.suspensionReason ?? "auth-rejected"
3069
+ });
3070
+ return;
3071
+ }
3072
+ if (this.config.authState) {
3073
+ const authState = await this.config.authState();
3074
+ if (authState.state === "loading" || authState.state === "signed-out") {
3075
+ this.suspensionReason = authState.state === "loading" ? "auth-loading" : "auth-required";
3076
+ this.emitter?.emit({ type: "sync:suspended", reason: this.suspensionReason });
3077
+ return;
3078
+ }
3079
+ }
3080
+ this.suspensionReason = null;
2934
3081
  await this.outboundQueue.initialize();
2935
3082
  if (this.syncState) {
2936
3083
  this.lastAckedServerVector = await this.syncState.loadLastAckedServerVector();
@@ -2980,6 +3127,7 @@ var SyncEngine = class {
2980
3127
  authToken,
2981
3128
  supportedWireFormats: ["json", "protobuf"],
2982
3129
  ...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
3130
+ ...this.config.scopeExit ? { scopeExitPolicy: this.config.scopeExit } : {},
2983
3131
  ...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
2984
3132
  ...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {},
2985
3133
  // Resume the server's gap-free delivery stream from our watermark. A server
@@ -3004,13 +3152,15 @@ var SyncEngine = class {
3004
3152
  return this.stopPromise;
3005
3153
  }
3006
3154
  async stopInternal() {
3007
- if (this.state === "disconnected") return;
3008
3155
  this.clearOutboundAckTimer();
3156
+ this.clearOutboundRetryTimer();
3157
+ if (this.state === "disconnected") return;
3009
3158
  this.awarenessManager.stopCleanupTimer();
3010
3159
  if (this.currentBatch) {
3011
3160
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
3012
3161
  this.currentBatch = null;
3013
3162
  this.currentBatchRequiresPartialAck = false;
3163
+ this.currentBatchRequiresRetryBackoff = false;
3014
3164
  }
3015
3165
  try {
3016
3166
  await this.transport.disconnect();
@@ -3060,14 +3210,30 @@ var SyncEngine = class {
3060
3210
  getStatus() {
3061
3211
  const pendingOperations = this.syncState ? this.cachedUnsyncedCount : this.outboundQueue.totalPending;
3062
3212
  const base = {
3213
+ phase: this.resolvePhase(),
3214
+ ...this.suspensionReason ? { reason: this.suspensionReason } : {},
3063
3215
  reconnecting: this.reconnecting,
3064
3216
  pendingOperations,
3065
3217
  lastSyncedAt: this.lastSyncedAt,
3066
3218
  lastSuccessfulPush: this.lastSuccessfulPush,
3067
3219
  lastSuccessfulPull: this.lastSuccessfulPull,
3068
3220
  conflicts: this.conflictCount,
3069
- clockSkewMs: this.clockSkewMs
3221
+ clockSkewMs: this.clockSkewMs,
3222
+ inFlightUploadOperations: this.currentBatch?.operations.length ?? 0,
3223
+ hasInFlightDeliveryBatch: this.hasInFlightDeliveryBatch,
3224
+ activeViewId: this.deliverySignature(),
3225
+ activeViewComplete: this.state === "streaming" && !this.hasInFlightDeliveryBatch && this.blockedFailure === null && (this.serverFrontier === null || this.deliveryWatermark >= this.serverFrontier),
3226
+ initialSync: {
3227
+ complete: this.deltaReceiveComplete && this.deltaSendComplete,
3228
+ receivedBatches: this.deltaBatchesReceived,
3229
+ totalBatches: this.initialSyncTotalBatches || null,
3230
+ progress: this.initialSyncTotalBatches > 0 ? Math.min(1, this.deltaBatchesReceived / this.initialSyncTotalBatches) : null
3231
+ },
3232
+ deliveryWatermark: this.deliveryWatermark,
3233
+ serverFrontier: this.serverFrontier,
3234
+ blockedFailure: this.blockedFailure
3070
3235
  };
3236
+ if (this.suspensionReason) return { ...base, status: "auth-required" };
3071
3237
  switch (this.state) {
3072
3238
  case "disconnected":
3073
3239
  if (this.clockBlocked) {
@@ -3090,6 +3256,26 @@ var SyncEngine = class {
3090
3256
  return { ...base, status: this.schemaBlocked ? "schema-mismatch" : "error" };
3091
3257
  }
3092
3258
  }
3259
+ resolvePhase() {
3260
+ if (this.suspensionReason) return "suspended";
3261
+ if (this.blockedFailure || this.schemaBlocked || this.clockBlocked) return "blocked";
3262
+ if (this.hasInFlightDeliveryBatch) return "applying";
3263
+ if (this.currentBatch) return "uploading";
3264
+ switch (this.state) {
3265
+ case "disconnected":
3266
+ return "offline";
3267
+ case "connecting":
3268
+ return "connecting";
3269
+ case "handshaking":
3270
+ return "handshaking";
3271
+ case "syncing":
3272
+ return "receiving";
3273
+ case "streaming":
3274
+ return "streaming";
3275
+ case "error":
3276
+ return "blocked";
3277
+ }
3278
+ }
3093
3279
  /**
3094
3280
  * True when the server rejected the client's schema version at handshake.
3095
3281
  * Sync stays blocked until the app schema is upgraded or sync config changes.
@@ -3142,8 +3328,25 @@ var SyncEngine = class {
3142
3328
  await this.refreshPendingCount();
3143
3329
  return this.getStatus().pendingOperations;
3144
3330
  }
3145
- emitApplyFailure(op, result, overrides) {
3331
+ emitApplyFailure(op, result, overrides, blocking = false) {
3146
3332
  const reason = (0, import_core7.defaultApplyFailureReason)(result, overrides);
3333
+ if (blocking || reason.retriable) {
3334
+ const prior = this.blockedFailure;
3335
+ this.blockedFailure = {
3336
+ operationId: op.id,
3337
+ collection: op.collection,
3338
+ recordId: op.recordId,
3339
+ code: reason.code,
3340
+ message: reason.message,
3341
+ retriable: reason.retriable,
3342
+ firstSeenAt: prior?.operationId === op.id ? prior.firstSeenAt : Date.now(),
3343
+ retryCount: prior?.operationId === op.id ? prior.retryCount + 1 : 0
3344
+ };
3345
+ this.emitter?.emit({
3346
+ type: prior?.operationId === op.id ? "sync:apply-retrying" : "sync:apply-blocked",
3347
+ failure: this.blockedFailure
3348
+ });
3349
+ }
3147
3350
  this.emitter?.emit({
3148
3351
  type: "sync:apply-failed",
3149
3352
  operationId: op.id,
@@ -3160,11 +3363,18 @@ var SyncEngine = class {
3160
3363
  */
3161
3364
  async retryNow() {
3162
3365
  if (this.schemaBlocked) return;
3366
+ this.authRejected = false;
3367
+ this.suspensionReason = null;
3163
3368
  if (this.state === "disconnected" || this.state === "error") {
3164
3369
  this.reconnecting = false;
3165
3370
  await this.start();
3166
3371
  }
3167
3372
  }
3373
+ /** Wake a permanent auth suspension after the binding reports a new auth state. */
3374
+ notifyAuthChanged() {
3375
+ this.authRejected = false;
3376
+ this.suspensionReason = null;
3377
+ }
3168
3378
  /**
3169
3379
  * Atomically refresh the transport session. Overlapping callers share one
3170
3380
  * reconnect, in-flight outbound batches are returned to the queue by stop(), and the
@@ -3233,6 +3443,8 @@ var SyncEngine = class {
3233
3443
  updateScope(scopeMap) {
3234
3444
  const previousSignature = this.deliverySignature();
3235
3445
  this.activeScope = scopeMap;
3446
+ this.activeUplinkScope = scopeMap;
3447
+ this.hasDirectionalScopes = false;
3236
3448
  this.config.scopeMap = scopeMap;
3237
3449
  this.switchDeliveryView(previousSignature);
3238
3450
  }
@@ -3316,28 +3528,58 @@ var SyncEngine = class {
3316
3528
  getActiveScope() {
3317
3529
  return this.activeScope;
3318
3530
  }
3531
+ /** Get the server-accepted upload authorization scope. */
3532
+ getActiveUplinkScope() {
3533
+ return this.activeUplinkScope;
3534
+ }
3319
3535
  /**
3320
3536
  * Register a live query subset that narrows synced data for a collection.
3321
3537
  * Takes effect on the next connection; reconnects when already connected.
3322
3538
  */
3323
3539
  registerQuerySubset(subset) {
3540
+ if ((this.config.querySubsets?.mode ?? "reactive") !== "reactive") return () => {
3541
+ };
3324
3542
  const id = `query-${nextQuerySubsetId++}`;
3325
3543
  const previousSignature = this.deliverySignature();
3544
+ const previousEffective = this.getActiveQuerySubsets();
3326
3545
  this.querySubsets.set(id, subset);
3327
- this.switchDeliveryView(previousSignature);
3328
- this.scheduleQuerySubsetReconnect();
3546
+ this.switchDeliveryViewWithInheritance(previousSignature, previousEffective);
3547
+ if (this.deliverySignature() !== previousSignature) this.scheduleQuerySubsetReconnect();
3329
3548
  return () => {
3330
3549
  const sigBeforeRemove = this.deliverySignature();
3550
+ const effectiveBeforeRemove = this.getActiveQuerySubsets();
3331
3551
  this.querySubsets.delete(id);
3332
- this.switchDeliveryView(sigBeforeRemove);
3333
- this.scheduleQuerySubsetReconnect();
3552
+ this.switchDeliveryViewWithInheritance(sigBeforeRemove, effectiveBeforeRemove);
3553
+ if (this.deliverySignature() !== sigBeforeRemove) this.scheduleQuerySubsetReconnect();
3334
3554
  };
3335
3555
  }
3556
+ switchDeliveryViewWithInheritance(previousSignature, previousSubsets) {
3557
+ const nextSubsets = this.getActiveQuerySubsets();
3558
+ const previousWatermark = this.deliveryWatermark;
3559
+ this.switchDeliveryView(previousSignature);
3560
+ if (this.deliveryWatermark === 0 && querySubsetContains(previousSubsets, nextSubsets)) {
3561
+ this.deliveryWatermark = previousWatermark;
3562
+ this.setViewWatermark(this.deliverySignature(), previousWatermark);
3563
+ void this.persistDeliveryWatermark(previousWatermark);
3564
+ }
3565
+ }
3566
+ /** Atomically replace the manifest used by static query-subset mode. */
3567
+ setQuerySubsets(subsets) {
3568
+ if ((this.config.querySubsets?.mode ?? "reactive") !== "static")
3569
+ throw new Error('setQuerySubsets() requires sync.querySubsets.mode = "static"');
3570
+ const previousSignature = this.deliverySignature();
3571
+ const previousEffective = this.getActiveQuerySubsets();
3572
+ this.staticQuerySubsets = dedupeQuerySubsets(subsets);
3573
+ this.switchDeliveryViewWithInheritance(previousSignature, previousEffective);
3574
+ if (this.deliverySignature() !== previousSignature) this.scheduleQuerySubsetReconnect();
3575
+ }
3336
3576
  /**
3337
3577
  * Returns deduplicated active query subsets from registered subscriptions.
3338
3578
  */
3339
3579
  getActiveQuerySubsets() {
3340
- return dedupeQuerySubsets([...this.querySubsets.values()]);
3580
+ const mode = this.config.querySubsets?.mode ?? "reactive";
3581
+ if (mode === "disabled") return [];
3582
+ return mode === "static" ? this.staticQuerySubsets : dedupeQuerySubsets([...this.querySubsets.values()]);
3341
3583
  }
3342
3584
  /**
3343
3585
  * Get the awareness manager for collaborative presence.
@@ -3414,6 +3656,7 @@ var SyncEngine = class {
3414
3656
  }
3415
3657
  }
3416
3658
  handleMessageFailure(error) {
3659
+ this.hasInFlightDeliveryBatch = false;
3417
3660
  const reason = error instanceof Error ? error.message : "Message handling failed";
3418
3661
  this.handleTransportClose(reason);
3419
3662
  }
@@ -3487,11 +3730,21 @@ var SyncEngine = class {
3487
3730
  this.setViewWatermark(this.deliverySignature(), 0);
3488
3731
  await this.persistDeliveryWatermark(0);
3489
3732
  }
3733
+ this.serverFrontier = msg.serverMaxDeliverySequence ?? null;
3490
3734
  if (msg.selectedWireFormat) {
3491
3735
  this.setSerializerWireFormat(msg.selectedWireFormat);
3492
3736
  }
3493
- if (msg.acceptedScope) {
3494
- this.activeScope = msg.acceptedScope;
3737
+ if (msg.acceptedDownlinkScopes ?? msg.acceptedScope) {
3738
+ this.activeScope = msg.acceptedDownlinkScopes ?? msg.acceptedScope;
3739
+ }
3740
+ this.hasDirectionalScopes = msg.acceptedUplinkScopes !== void 0;
3741
+ this.activeUplinkScope = msg.acceptedUplinkScopes ?? msg.acceptedScope ?? this.activeScope;
3742
+ if (this.config.scopeExit === "retract" && this.activeScope && this.store.applyScopeNarrowing) {
3743
+ const narrowed = await this.store.applyScopeNarrowing(this.activeScope);
3744
+ for (const retraction of narrowed) {
3745
+ await this.applyScopeRetraction(retraction, false);
3746
+ }
3747
+ await this.refreshPendingCount();
3495
3748
  }
3496
3749
  this.emitter?.emit({ type: "sync:connected", nodeId: this.store.getNodeId() });
3497
3750
  this.metricsCollector.recordConnected();
@@ -3648,6 +3901,7 @@ var SyncEngine = class {
3648
3901
  this.sendDeliveryAck(msg.messageId, this.deliveryWatermark);
3649
3902
  return;
3650
3903
  }
3904
+ this.hasInFlightDeliveryBatch = true;
3651
3905
  }
3652
3906
  const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
3653
3907
  const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
@@ -3655,6 +3909,14 @@ var SyncEngine = class {
3655
3909
  const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
3656
3910
  const transforms = this.config.operationTransforms ?? [];
3657
3911
  let fullyApplied = true;
3912
+ for (const retraction of msg.retractions ?? []) {
3913
+ try {
3914
+ await this.applyScopeRetraction(retraction, true);
3915
+ } catch {
3916
+ fullyApplied = false;
3917
+ }
3918
+ }
3919
+ await this.refreshPendingCount();
3658
3920
  for (const op of inScopeOps) {
3659
3921
  const transformed = transforms.length > 0 ? (0, import_core7.applyOperationTransforms)(op, targetSchemaVersion, transforms) : op;
3660
3922
  if (transformed === null) {
@@ -3668,17 +3930,22 @@ var SyncEngine = class {
3668
3930
  } catch (error) {
3669
3931
  fullyApplied = false;
3670
3932
  if (error instanceof import_core7.ClockDriftError) {
3671
- this.emitApplyFailure(transformed, "rejected", {
3672
- code: import_core7.APPLY_FAILURE_CODES.CLOCK_DRIFT,
3673
- message: error.message,
3674
- retriable: false
3675
- });
3933
+ this.emitApplyFailure(
3934
+ transformed,
3935
+ "rejected",
3936
+ {
3937
+ code: import_core7.APPLY_FAILURE_CODES.CLOCK_DRIFT,
3938
+ message: error.message,
3939
+ retriable: false
3940
+ },
3941
+ true
3942
+ );
3676
3943
  continue;
3677
3944
  }
3678
3945
  const message = error instanceof Error ? error.message : "Apply failed";
3679
3946
  const code = error instanceof import_core7.SyncError ? error.code : error instanceof import_core7.KoraError ? error.code : error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : import_core7.APPLY_FAILURE_CODES.APPLY_FAILED;
3680
3947
  const retriable = code !== import_core7.APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY;
3681
- this.emitApplyFailure(transformed, "rejected", { code, message, retriable });
3948
+ this.emitApplyFailure(transformed, "rejected", { code, message, retriable }, true);
3682
3949
  }
3683
3950
  }
3684
3951
  if (inScopeOps.length > 0) {
@@ -3711,7 +3978,13 @@ var SyncEngine = class {
3711
3978
  this.deliveryWatermark = msg.maxDeliverySequence;
3712
3979
  this.setViewWatermark(this.deliverySignature(), this.deliveryWatermark);
3713
3980
  await this.persistDeliveryWatermark(this.deliveryWatermark);
3981
+ if (this.blockedFailure && inScopeOps.some((op) => op.id === this.blockedFailure?.operationId)) {
3982
+ const recovered = this.blockedFailure;
3983
+ this.blockedFailure = null;
3984
+ this.emitter?.emit({ type: "sync:apply-recovered", failure: recovered });
3985
+ }
3714
3986
  }
3987
+ this.hasInFlightDeliveryBatch = false;
3715
3988
  if (this.state === "syncing") {
3716
3989
  this.deltaBatchesReceived++;
3717
3990
  const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
@@ -3751,6 +4024,7 @@ var SyncEngine = class {
3751
4024
  }
3752
4025
  async handleAcknowledgment(msg) {
3753
4026
  this.clearOutboundAckTimer();
4027
+ const requiresRetryBackoff = this.currentBatchRequiresRetryBackoff;
3754
4028
  if (this.state === "syncing" && this.config.strictHandshake) {
3755
4029
  this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
3756
4030
  this.markDeltaSendCompleteIfReady();
@@ -3766,6 +4040,7 @@ var SyncEngine = class {
3766
4040
  }
3767
4041
  this.currentBatch = null;
3768
4042
  this.currentBatchRequiresPartialAck = false;
4043
+ this.currentBatchRequiresRetryBackoff = false;
3769
4044
  const now = Date.now();
3770
4045
  this.lastSyncedAt = now;
3771
4046
  this.lastSuccessfulPush = now;
@@ -3773,7 +4048,14 @@ var SyncEngine = class {
3773
4048
  await this.advanceLastAckedForLocalNode(msg.lastSequenceNumber);
3774
4049
  await this.refreshPendingCount();
3775
4050
  if (this.state === "streaming" && this.outboundQueue.hasOperations) {
3776
- this.flushQueue();
4051
+ if (requiresRetryBackoff) {
4052
+ this.scheduleOutboundRetry();
4053
+ } else {
4054
+ this.outboundRetryAttempt = 0;
4055
+ this.flushQueue();
4056
+ }
4057
+ } else if (!requiresRetryBackoff) {
4058
+ this.outboundRetryAttempt = 0;
3777
4059
  }
3778
4060
  }
3779
4061
  handleError(msg) {
@@ -3782,10 +4064,14 @@ var SyncEngine = class {
3782
4064
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
3783
4065
  this.currentBatch = null;
3784
4066
  this.currentBatchRequiresPartialAck = false;
4067
+ this.currentBatchRequiresRetryBackoff = false;
3785
4068
  }
3786
4069
  this.transitionTo("error");
3787
- if (msg.code === "AUTH_FAILED") {
4070
+ if (msg.code === "AUTH_FAILED" || msg.code === "DEVICE_REVOKED") {
4071
+ this.authRejected = true;
4072
+ this.suspensionReason = msg.code === "DEVICE_REVOKED" ? "device-revoked" : "auth-rejected";
3788
4073
  this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
4074
+ this.emitter?.emit({ type: "sync:suspended", reason: this.suspensionReason });
3789
4075
  }
3790
4076
  if (msg.code === "INVALID_TIMESTAMP") {
3791
4077
  this.clockBlocked = true;
@@ -3808,9 +4094,39 @@ var SyncEngine = class {
3808
4094
  * the app can reconcile. Unlike {@link handleError}, this is a normal per-op
3809
4095
  * signal, so the connection stays up.
3810
4096
  */
4097
+ async applyScopeRetraction(retraction, applyToStore) {
4098
+ if (applyToStore) {
4099
+ if (!this.store.applyScopeRetraction) {
4100
+ throw new Error("The configured sync store does not support scope retractions");
4101
+ }
4102
+ await this.store.applyScopeRetraction(retraction.collection, retraction.recordId);
4103
+ }
4104
+ const quarantined = await this.outboundQueue.rejectRecord(
4105
+ retraction.collection,
4106
+ retraction.recordId
4107
+ );
4108
+ for (const operation of quarantined) {
4109
+ await this.rejectedStorage.record({
4110
+ operationId: operation.id,
4111
+ collection: operation.collection,
4112
+ recordId: operation.recordId,
4113
+ code: "SCOPE_RETRACTED",
4114
+ message: "Authorization changed before this operation could be uploaded.",
4115
+ retriable: false,
4116
+ rejectedAt: Date.now()
4117
+ });
4118
+ }
4119
+ this.emitter?.emit({
4120
+ type: "sync:scope-retracted",
4121
+ collection: retraction.collection,
4122
+ recordId: retraction.recordId,
4123
+ quarantinedOperationIds: quarantined.map((operation) => operation.id)
4124
+ });
4125
+ }
3811
4126
  async handleOperationRejected(msg) {
3812
4127
  if (msg.retriable) {
3813
4128
  this.currentBatchRequiresPartialAck = true;
4129
+ this.currentBatchRequiresRetryBackoff = true;
3814
4130
  await this.refreshPendingCount();
3815
4131
  this.emitter?.emit({
3816
4132
  type: "sync:operation-rejected",
@@ -3964,11 +4280,13 @@ var SyncEngine = class {
3964
4280
  }
3965
4281
  flushQueue() {
3966
4282
  if (this.currentBatch) return;
4283
+ if (this.outboundRetryTimer) return;
3967
4284
  if (!this.outboundQueue.hasOperations) return;
3968
4285
  const batch = this.outboundQueue.takeBatch(this.batchSize);
3969
4286
  if (!batch) return;
3970
4287
  this.currentBatch = batch;
3971
4288
  this.currentBatchRequiresPartialAck = false;
4289
+ this.currentBatchRequiresRetryBackoff = false;
3972
4290
  if (this.encryptor) {
3973
4291
  this.encryptor.encryptBatch(batch.operations).then(
3974
4292
  (encrypted) => {
@@ -3992,6 +4310,7 @@ var SyncEngine = class {
3992
4310
  this.outboundQueue.returnBatch(batch.batchId);
3993
4311
  this.currentBatch = null;
3994
4312
  this.currentBatchRequiresPartialAck = false;
4313
+ this.currentBatchRequiresRetryBackoff = false;
3995
4314
  this.emitter?.emit({
3996
4315
  type: "sync:disconnected",
3997
4316
  reason: err instanceof Error ? err.message : "Encryption failed"
@@ -4028,11 +4347,29 @@ var SyncEngine = class {
4028
4347
  clearTimeout(this.outboundAckTimer);
4029
4348
  this.outboundAckTimer = null;
4030
4349
  }
4350
+ scheduleOutboundRetry() {
4351
+ if (this.outboundRetryTimer || !this.outboundQueue.hasOperations) return;
4352
+ const delay = Math.min(
4353
+ this.outboundRetryMaxDelayMs,
4354
+ this.outboundRetryBaseDelayMs * 2 ** this.outboundRetryAttempt
4355
+ );
4356
+ this.outboundRetryAttempt += 1;
4357
+ this.outboundRetryTimer = setTimeout(() => {
4358
+ this.outboundRetryTimer = null;
4359
+ if (this.state === "streaming") this.flushQueue();
4360
+ }, delay);
4361
+ }
4362
+ clearOutboundRetryTimer() {
4363
+ if (!this.outboundRetryTimer) return;
4364
+ clearTimeout(this.outboundRetryTimer);
4365
+ this.outboundRetryTimer = null;
4366
+ }
4031
4367
  async handleOutboundAckTimeout(batchId) {
4032
4368
  if (!this.currentBatch || this.currentBatch.batchId !== batchId) return;
4033
4369
  this.outboundQueue.returnBatch(batchId);
4034
4370
  this.currentBatch = null;
4035
4371
  this.currentBatchRequiresPartialAck = false;
4372
+ this.currentBatchRequiresRetryBackoff = false;
4036
4373
  this.clearOutboundAckTimer();
4037
4374
  await this.refreshPendingCount();
4038
4375
  if (this.state === "disconnected") return;
@@ -4054,10 +4391,12 @@ var SyncEngine = class {
4054
4391
  }
4055
4392
  handleTransportClose(reason) {
4056
4393
  this.clearOutboundAckTimer();
4394
+ this.clearOutboundRetryTimer();
4057
4395
  if (this.currentBatch) {
4058
4396
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
4059
4397
  this.currentBatch = null;
4060
4398
  this.currentBatchRequiresPartialAck = false;
4399
+ this.currentBatchRequiresRetryBackoff = false;
4061
4400
  }
4062
4401
  if (this.schemaBlocked) {
4063
4402
  return;
@@ -4100,10 +4439,10 @@ var SyncEngine = class {
4100
4439
  return this.matchesScopeAndSubsets(op, fullRecord);
4101
4440
  }
4102
4441
  matchesScopeAndSubsets(op, fullRecord) {
4103
- if (!operationMatchesScope(op, this.activeScope, fullRecord)) {
4442
+ if (!operationMatchesScope(op, this.activeUplinkScope, fullRecord)) {
4104
4443
  return false;
4105
4444
  }
4106
- return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets(), fullRecord);
4445
+ return this.hasDirectionalScopes || operationMatchesQuerySubsets(op, this.getActiveQuerySubsets(), fullRecord);
4107
4446
  }
4108
4447
  async readRecordForBackfill(op) {
4109
4448
  if (!this.store.readRecordFields) {
@@ -4829,13 +5168,22 @@ function fromBase642(str) {
4829
5168
  // src/reactivity/sync-status-controller.ts
4830
5169
  var OFFLINE_SYNC_STATUS = Object.freeze({
4831
5170
  status: "offline",
5171
+ phase: "offline",
4832
5172
  reconnecting: false,
4833
5173
  pendingOperations: 0,
4834
5174
  lastSyncedAt: null,
4835
5175
  lastSuccessfulPush: null,
4836
5176
  lastSuccessfulPull: null,
4837
5177
  conflicts: 0,
4838
- clockSkewMs: null
5178
+ clockSkewMs: null,
5179
+ inFlightUploadOperations: 0,
5180
+ hasInFlightDeliveryBatch: false,
5181
+ activeViewId: "",
5182
+ activeViewComplete: false,
5183
+ initialSync: { complete: false, receivedBatches: 0, totalBatches: null, progress: null },
5184
+ deliveryWatermark: 0,
5185
+ serverFrontier: null,
5186
+ blockedFailure: null
4839
5187
  });
4840
5188
  var SYNC_STATUS_EVENT_TYPES = [
4841
5189
  "sync:connected",
@@ -4847,6 +5195,10 @@ var SYNC_STATUS_EVENT_TYPES = [
4847
5195
  "sync:received",
4848
5196
  "sync:acknowledged",
4849
5197
  "sync:apply-failed",
5198
+ "sync:suspended",
5199
+ "sync:apply-blocked",
5200
+ "sync:apply-retrying",
5201
+ "sync:apply-recovered",
4850
5202
  "sync:delivery-gap",
4851
5203
  "sync:diagnostics",
4852
5204
  "sync:initial-sync-progress"
@@ -5039,6 +5391,7 @@ function subscribeRemoteAwarenessStates(awareness, listener) {
5039
5391
  isYjsDocUpdateMessage,
5040
5392
  operationMatchesQuerySubsets,
5041
5393
  operationMatchesScope,
5394
+ querySubsetContains,
5042
5395
  richtextDocKey,
5043
5396
  sliceOperationsAfterCursor,
5044
5397
  subscribeRemoteAwarenessStates,