@korajs/sync 1.0.0-beta.10 → 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.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
@@ -2818,6 +2880,11 @@ var SyncEngine = class {
2818
2880
  clockBlocked = false;
2819
2881
  clockSkewMs = null;
2820
2882
  blobStorageEnabled = false;
2883
+ suspensionReason = null;
2884
+ authRejected = false;
2885
+ serverFrontier = null;
2886
+ hasInFlightDeliveryBatch = false;
2887
+ blockedFailure = null;
2821
2888
  outboundAckTimer = null;
2822
2889
  startPromise = null;
2823
2890
  stopPromise = null;
@@ -2838,6 +2905,7 @@ var SyncEngine = class {
2838
2905
  activeScope;
2839
2906
  /** Live query subsets registered from reactive subscriptions */
2840
2907
  querySubsets = /* @__PURE__ */ new Map();
2908
+ staticQuerySubsets = [];
2841
2909
  querySubsetReconnectTimer = null;
2842
2910
  /** Resume cursor for paginated initial sync (persisted across reconnects) */
2843
2911
  resumeDeltaCursor = null;
@@ -2931,6 +2999,22 @@ var SyncEngine = class {
2931
2999
  if (this.state !== "disconnected") {
2932
3000
  await this.stop();
2933
3001
  }
3002
+ if (this.authRejected) {
3003
+ this.emitter?.emit({
3004
+ type: "sync:suspended",
3005
+ reason: this.suspensionReason ?? "auth-rejected"
3006
+ });
3007
+ return;
3008
+ }
3009
+ if (this.config.authState) {
3010
+ const authState = await this.config.authState();
3011
+ if (authState.state === "loading" || authState.state === "signed-out") {
3012
+ this.suspensionReason = authState.state === "loading" ? "auth-loading" : "auth-required";
3013
+ this.emitter?.emit({ type: "sync:suspended", reason: this.suspensionReason });
3014
+ return;
3015
+ }
3016
+ }
3017
+ this.suspensionReason = null;
2934
3018
  await this.outboundQueue.initialize();
2935
3019
  if (this.syncState) {
2936
3020
  this.lastAckedServerVector = await this.syncState.loadLastAckedServerVector();
@@ -3060,14 +3144,30 @@ var SyncEngine = class {
3060
3144
  getStatus() {
3061
3145
  const pendingOperations = this.syncState ? this.cachedUnsyncedCount : this.outboundQueue.totalPending;
3062
3146
  const base = {
3147
+ phase: this.resolvePhase(),
3148
+ ...this.suspensionReason ? { reason: this.suspensionReason } : {},
3063
3149
  reconnecting: this.reconnecting,
3064
3150
  pendingOperations,
3065
3151
  lastSyncedAt: this.lastSyncedAt,
3066
3152
  lastSuccessfulPush: this.lastSuccessfulPush,
3067
3153
  lastSuccessfulPull: this.lastSuccessfulPull,
3068
3154
  conflicts: this.conflictCount,
3069
- clockSkewMs: this.clockSkewMs
3155
+ clockSkewMs: this.clockSkewMs,
3156
+ inFlightUploadOperations: this.currentBatch?.operations.length ?? 0,
3157
+ hasInFlightDeliveryBatch: this.hasInFlightDeliveryBatch,
3158
+ activeViewId: this.deliverySignature(),
3159
+ activeViewComplete: this.state === "streaming" && !this.hasInFlightDeliveryBatch && this.blockedFailure === null && (this.serverFrontier === null || this.deliveryWatermark >= this.serverFrontier),
3160
+ initialSync: {
3161
+ complete: this.deltaReceiveComplete && this.deltaSendComplete,
3162
+ receivedBatches: this.deltaBatchesReceived,
3163
+ totalBatches: this.initialSyncTotalBatches || null,
3164
+ progress: this.initialSyncTotalBatches > 0 ? Math.min(1, this.deltaBatchesReceived / this.initialSyncTotalBatches) : null
3165
+ },
3166
+ deliveryWatermark: this.deliveryWatermark,
3167
+ serverFrontier: this.serverFrontier,
3168
+ blockedFailure: this.blockedFailure
3070
3169
  };
3170
+ if (this.suspensionReason) return { ...base, status: "auth-required" };
3071
3171
  switch (this.state) {
3072
3172
  case "disconnected":
3073
3173
  if (this.clockBlocked) {
@@ -3090,6 +3190,26 @@ var SyncEngine = class {
3090
3190
  return { ...base, status: this.schemaBlocked ? "schema-mismatch" : "error" };
3091
3191
  }
3092
3192
  }
3193
+ resolvePhase() {
3194
+ if (this.suspensionReason) return "suspended";
3195
+ if (this.blockedFailure || this.schemaBlocked || this.clockBlocked) return "blocked";
3196
+ if (this.hasInFlightDeliveryBatch) return "applying";
3197
+ if (this.currentBatch) return "uploading";
3198
+ switch (this.state) {
3199
+ case "disconnected":
3200
+ return "offline";
3201
+ case "connecting":
3202
+ return "connecting";
3203
+ case "handshaking":
3204
+ return "handshaking";
3205
+ case "syncing":
3206
+ return "receiving";
3207
+ case "streaming":
3208
+ return "streaming";
3209
+ case "error":
3210
+ return "blocked";
3211
+ }
3212
+ }
3093
3213
  /**
3094
3214
  * True when the server rejected the client's schema version at handshake.
3095
3215
  * Sync stays blocked until the app schema is upgraded or sync config changes.
@@ -3142,8 +3262,25 @@ var SyncEngine = class {
3142
3262
  await this.refreshPendingCount();
3143
3263
  return this.getStatus().pendingOperations;
3144
3264
  }
3145
- emitApplyFailure(op, result, overrides) {
3265
+ emitApplyFailure(op, result, overrides, blocking = false) {
3146
3266
  const reason = (0, import_core7.defaultApplyFailureReason)(result, overrides);
3267
+ if (blocking || reason.retriable) {
3268
+ const prior = this.blockedFailure;
3269
+ this.blockedFailure = {
3270
+ operationId: op.id,
3271
+ collection: op.collection,
3272
+ recordId: op.recordId,
3273
+ code: reason.code,
3274
+ message: reason.message,
3275
+ retriable: reason.retriable,
3276
+ firstSeenAt: prior?.operationId === op.id ? prior.firstSeenAt : Date.now(),
3277
+ retryCount: prior?.operationId === op.id ? prior.retryCount + 1 : 0
3278
+ };
3279
+ this.emitter?.emit({
3280
+ type: prior?.operationId === op.id ? "sync:apply-retrying" : "sync:apply-blocked",
3281
+ failure: this.blockedFailure
3282
+ });
3283
+ }
3147
3284
  this.emitter?.emit({
3148
3285
  type: "sync:apply-failed",
3149
3286
  operationId: op.id,
@@ -3160,11 +3297,18 @@ var SyncEngine = class {
3160
3297
  */
3161
3298
  async retryNow() {
3162
3299
  if (this.schemaBlocked) return;
3300
+ this.authRejected = false;
3301
+ this.suspensionReason = null;
3163
3302
  if (this.state === "disconnected" || this.state === "error") {
3164
3303
  this.reconnecting = false;
3165
3304
  await this.start();
3166
3305
  }
3167
3306
  }
3307
+ /** Wake a permanent auth suspension after the binding reports a new auth state. */
3308
+ notifyAuthChanged() {
3309
+ this.authRejected = false;
3310
+ this.suspensionReason = null;
3311
+ }
3168
3312
  /**
3169
3313
  * Atomically refresh the transport session. Overlapping callers share one
3170
3314
  * reconnect, in-flight outbound batches are returned to the queue by stop(), and the
@@ -3321,23 +3465,49 @@ var SyncEngine = class {
3321
3465
  * Takes effect on the next connection; reconnects when already connected.
3322
3466
  */
3323
3467
  registerQuerySubset(subset) {
3468
+ if ((this.config.querySubsets?.mode ?? "reactive") !== "reactive") return () => {
3469
+ };
3324
3470
  const id = `query-${nextQuerySubsetId++}`;
3325
3471
  const previousSignature = this.deliverySignature();
3472
+ const previousEffective = this.getActiveQuerySubsets();
3326
3473
  this.querySubsets.set(id, subset);
3327
- this.switchDeliveryView(previousSignature);
3328
- this.scheduleQuerySubsetReconnect();
3474
+ this.switchDeliveryViewWithInheritance(previousSignature, previousEffective);
3475
+ if (this.deliverySignature() !== previousSignature) this.scheduleQuerySubsetReconnect();
3329
3476
  return () => {
3330
3477
  const sigBeforeRemove = this.deliverySignature();
3478
+ const effectiveBeforeRemove = this.getActiveQuerySubsets();
3331
3479
  this.querySubsets.delete(id);
3332
- this.switchDeliveryView(sigBeforeRemove);
3333
- this.scheduleQuerySubsetReconnect();
3480
+ this.switchDeliveryViewWithInheritance(sigBeforeRemove, effectiveBeforeRemove);
3481
+ if (this.deliverySignature() !== sigBeforeRemove) this.scheduleQuerySubsetReconnect();
3334
3482
  };
3335
3483
  }
3484
+ switchDeliveryViewWithInheritance(previousSignature, previousSubsets) {
3485
+ const nextSubsets = this.getActiveQuerySubsets();
3486
+ const previousWatermark = this.deliveryWatermark;
3487
+ this.switchDeliveryView(previousSignature);
3488
+ if (this.deliveryWatermark === 0 && querySubsetContains(previousSubsets, nextSubsets)) {
3489
+ this.deliveryWatermark = previousWatermark;
3490
+ this.setViewWatermark(this.deliverySignature(), previousWatermark);
3491
+ void this.persistDeliveryWatermark(previousWatermark);
3492
+ }
3493
+ }
3494
+ /** Atomically replace the manifest used by static query-subset mode. */
3495
+ setQuerySubsets(subsets) {
3496
+ if ((this.config.querySubsets?.mode ?? "reactive") !== "static")
3497
+ throw new Error('setQuerySubsets() requires sync.querySubsets.mode = "static"');
3498
+ const previousSignature = this.deliverySignature();
3499
+ const previousEffective = this.getActiveQuerySubsets();
3500
+ this.staticQuerySubsets = dedupeQuerySubsets(subsets);
3501
+ this.switchDeliveryViewWithInheritance(previousSignature, previousEffective);
3502
+ if (this.deliverySignature() !== previousSignature) this.scheduleQuerySubsetReconnect();
3503
+ }
3336
3504
  /**
3337
3505
  * Returns deduplicated active query subsets from registered subscriptions.
3338
3506
  */
3339
3507
  getActiveQuerySubsets() {
3340
- return dedupeQuerySubsets([...this.querySubsets.values()]);
3508
+ const mode = this.config.querySubsets?.mode ?? "reactive";
3509
+ if (mode === "disabled") return [];
3510
+ return mode === "static" ? this.staticQuerySubsets : dedupeQuerySubsets([...this.querySubsets.values()]);
3341
3511
  }
3342
3512
  /**
3343
3513
  * Get the awareness manager for collaborative presence.
@@ -3414,6 +3584,7 @@ var SyncEngine = class {
3414
3584
  }
3415
3585
  }
3416
3586
  handleMessageFailure(error) {
3587
+ this.hasInFlightDeliveryBatch = false;
3417
3588
  const reason = error instanceof Error ? error.message : "Message handling failed";
3418
3589
  this.handleTransportClose(reason);
3419
3590
  }
@@ -3487,6 +3658,7 @@ var SyncEngine = class {
3487
3658
  this.setViewWatermark(this.deliverySignature(), 0);
3488
3659
  await this.persistDeliveryWatermark(0);
3489
3660
  }
3661
+ this.serverFrontier = msg.serverMaxDeliverySequence ?? null;
3490
3662
  if (msg.selectedWireFormat) {
3491
3663
  this.setSerializerWireFormat(msg.selectedWireFormat);
3492
3664
  }
@@ -3648,6 +3820,7 @@ var SyncEngine = class {
3648
3820
  this.sendDeliveryAck(msg.messageId, this.deliveryWatermark);
3649
3821
  return;
3650
3822
  }
3823
+ this.hasInFlightDeliveryBatch = true;
3651
3824
  }
3652
3825
  const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
3653
3826
  const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
@@ -3668,17 +3841,22 @@ var SyncEngine = class {
3668
3841
  } catch (error) {
3669
3842
  fullyApplied = false;
3670
3843
  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
- });
3844
+ this.emitApplyFailure(
3845
+ transformed,
3846
+ "rejected",
3847
+ {
3848
+ code: import_core7.APPLY_FAILURE_CODES.CLOCK_DRIFT,
3849
+ message: error.message,
3850
+ retriable: false
3851
+ },
3852
+ true
3853
+ );
3676
3854
  continue;
3677
3855
  }
3678
3856
  const message = error instanceof Error ? error.message : "Apply failed";
3679
3857
  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
3858
  const retriable = code !== import_core7.APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY;
3681
- this.emitApplyFailure(transformed, "rejected", { code, message, retriable });
3859
+ this.emitApplyFailure(transformed, "rejected", { code, message, retriable }, true);
3682
3860
  }
3683
3861
  }
3684
3862
  if (inScopeOps.length > 0) {
@@ -3711,7 +3889,13 @@ var SyncEngine = class {
3711
3889
  this.deliveryWatermark = msg.maxDeliverySequence;
3712
3890
  this.setViewWatermark(this.deliverySignature(), this.deliveryWatermark);
3713
3891
  await this.persistDeliveryWatermark(this.deliveryWatermark);
3892
+ if (this.blockedFailure && inScopeOps.some((op) => op.id === this.blockedFailure?.operationId)) {
3893
+ const recovered = this.blockedFailure;
3894
+ this.blockedFailure = null;
3895
+ this.emitter?.emit({ type: "sync:apply-recovered", failure: recovered });
3896
+ }
3714
3897
  }
3898
+ this.hasInFlightDeliveryBatch = false;
3715
3899
  if (this.state === "syncing") {
3716
3900
  this.deltaBatchesReceived++;
3717
3901
  const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
@@ -3784,8 +3968,11 @@ var SyncEngine = class {
3784
3968
  this.currentBatchRequiresPartialAck = false;
3785
3969
  }
3786
3970
  this.transitionTo("error");
3787
- if (msg.code === "AUTH_FAILED") {
3971
+ if (msg.code === "AUTH_FAILED" || msg.code === "DEVICE_REVOKED") {
3972
+ this.authRejected = true;
3973
+ this.suspensionReason = msg.code === "DEVICE_REVOKED" ? "device-revoked" : "auth-rejected";
3788
3974
  this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
3975
+ this.emitter?.emit({ type: "sync:suspended", reason: this.suspensionReason });
3789
3976
  }
3790
3977
  if (msg.code === "INVALID_TIMESTAMP") {
3791
3978
  this.clockBlocked = true;
@@ -4829,13 +5016,22 @@ function fromBase642(str) {
4829
5016
  // src/reactivity/sync-status-controller.ts
4830
5017
  var OFFLINE_SYNC_STATUS = Object.freeze({
4831
5018
  status: "offline",
5019
+ phase: "offline",
4832
5020
  reconnecting: false,
4833
5021
  pendingOperations: 0,
4834
5022
  lastSyncedAt: null,
4835
5023
  lastSuccessfulPush: null,
4836
5024
  lastSuccessfulPull: null,
4837
5025
  conflicts: 0,
4838
- clockSkewMs: null
5026
+ clockSkewMs: null,
5027
+ inFlightUploadOperations: 0,
5028
+ hasInFlightDeliveryBatch: false,
5029
+ activeViewId: "",
5030
+ activeViewComplete: false,
5031
+ initialSync: { complete: false, receivedBatches: 0, totalBatches: null, progress: null },
5032
+ deliveryWatermark: 0,
5033
+ serverFrontier: null,
5034
+ blockedFailure: null
4839
5035
  });
4840
5036
  var SYNC_STATUS_EVENT_TYPES = [
4841
5037
  "sync:connected",
@@ -4847,6 +5043,10 @@ var SYNC_STATUS_EVENT_TYPES = [
4847
5043
  "sync:received",
4848
5044
  "sync:acknowledged",
4849
5045
  "sync:apply-failed",
5046
+ "sync:suspended",
5047
+ "sync:apply-blocked",
5048
+ "sync:apply-retrying",
5049
+ "sync:apply-recovered",
4850
5050
  "sync:delivery-gap",
4851
5051
  "sync:diagnostics",
4852
5052
  "sync:initial-sync-progress"
@@ -5039,6 +5239,7 @@ function subscribeRemoteAwarenessStates(awareness, listener) {
5039
5239
  isYjsDocUpdateMessage,
5040
5240
  operationMatchesQuerySubsets,
5041
5241
  operationMatchesScope,
5242
+ querySubsetContains,
5042
5243
  richtextDocKey,
5043
5244
  sliceOperationsAfterCursor,
5044
5245
  subscribeRemoteAwarenessStates,