@korajs/sync 0.6.0 → 1.0.0-beta.0

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
@@ -17,6 +17,7 @@ var SYNC_STATUSES = [
17
17
  "syncing",
18
18
  "synced",
19
19
  "offline",
20
+ "clock-error",
20
21
  "error",
21
22
  "schema-mismatch"
22
23
  ];
@@ -263,7 +264,7 @@ function isYjsDocUpdateMessage(value) {
263
264
 
264
265
  // src/protocol/serializer.ts
265
266
  import { SyncError } from "@korajs/core";
266
- import protobuf from "protobufjs/minimal";
267
+ import protobuf from "protobufjs/minimal.js";
267
268
  var { Reader, Writer } = protobuf;
268
269
  function versionVectorToWire(vector) {
269
270
  const wire = {};
@@ -499,7 +500,8 @@ function toProtoEnvelope(message) {
499
500
  schemaVersion: message.schemaVersion,
500
501
  accepted: message.accepted,
501
502
  rejectReason: message.rejectReason,
502
- selectedWireFormat: message.selectedWireFormat
503
+ selectedWireFormat: message.selectedWireFormat,
504
+ serverTime: message.serverTime
503
505
  };
504
506
  case "operation-batch":
505
507
  return {
@@ -559,7 +561,10 @@ function fromProtoEnvelope(envelope) {
559
561
  schemaVersion: envelope.schemaVersion ?? 0,
560
562
  accepted: envelope.accepted ?? false,
561
563
  rejectReason: envelope.rejectReason,
562
- selectedWireFormat: envelope.selectedWireFormat === "json" || envelope.selectedWireFormat === "protobuf" ? envelope.selectedWireFormat : void 0
564
+ selectedWireFormat: envelope.selectedWireFormat === "json" || envelope.selectedWireFormat === "protobuf" ? envelope.selectedWireFormat : void 0,
565
+ // Preserve the server's wall-clock time so clock-skew detection and
566
+ // automatic timestamp rebase work over the protobuf wire, not just JSON.
567
+ ...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {}
563
568
  };
564
569
  case "operation-batch":
565
570
  return {
@@ -728,6 +733,7 @@ function encodeEnvelope(envelope) {
728
733
  writer.uint32(138).string(envelope.errorMessage);
729
734
  }
730
735
  if (envelope.retriable !== void 0) writer.uint32(144).bool(envelope.retriable);
736
+ if (envelope.serverTime !== void 0) writer.uint32(152).int64(envelope.serverTime);
731
737
  return writer.finish();
732
738
  }
733
739
  function decodeEnvelope(bytes) {
@@ -796,6 +802,9 @@ function decodeEnvelope(bytes) {
796
802
  case 18:
797
803
  envelope.retriable = reader.bool();
798
804
  break;
805
+ case 19:
806
+ envelope.serverTime = longToNumber(reader.int64());
807
+ break;
799
808
  default:
800
809
  reader.skipType(tag & 7);
801
810
  }
@@ -1297,7 +1306,7 @@ var ChaosTransport = class {
1297
1306
  random;
1298
1307
  messageHandler = null;
1299
1308
  reorderBuffer = [];
1300
- timers = [];
1309
+ pending = [];
1301
1310
  constructor(inner, config) {
1302
1311
  this.inner = inner;
1303
1312
  this.dropRate = config?.dropRate ?? 0;
@@ -1312,10 +1321,14 @@ var ChaosTransport = class {
1312
1321
  }
1313
1322
  async disconnect() {
1314
1323
  this.flushReorderBuffer();
1315
- for (const timer of this.timers) {
1324
+ const flushed = this.pending;
1325
+ this.pending = [];
1326
+ for (const { timer } of flushed) {
1316
1327
  clearTimeout(timer);
1317
1328
  }
1318
- this.timers = [];
1329
+ for (const { message } of flushed) {
1330
+ this.deliverIncoming(message);
1331
+ }
1319
1332
  return this.inner.disconnect();
1320
1333
  }
1321
1334
  send(message) {
@@ -1352,9 +1365,10 @@ var ChaosTransport = class {
1352
1365
  if (this.maxLatency > 0) {
1353
1366
  const delay = Math.floor(this.random() * this.maxLatency);
1354
1367
  const timer = setTimeout(() => {
1368
+ this.pending = this.pending.filter((p) => p.timer !== timer);
1355
1369
  this.deliverIncoming(message);
1356
1370
  }, delay);
1357
- this.timers.push(timer);
1371
+ this.pending.push({ timer, message });
1358
1372
  return;
1359
1373
  }
1360
1374
  this.deliverIncoming(message);
@@ -2249,6 +2263,41 @@ var OutboundQueue = class {
2249
2263
  peek(count) {
2250
2264
  return this.queue.slice(0, count);
2251
2265
  }
2266
+ /**
2267
+ * All queued operations (not counting in-flight), in causal order.
2268
+ */
2269
+ getAll() {
2270
+ return [...this.queue];
2271
+ }
2272
+ /**
2273
+ * Atomically replace the entire queue with a new set of operations.
2274
+ *
2275
+ * Used after a timestamp rebase rewrote the queued operations under new
2276
+ * content-addressed ids: the old entries must vanish from memory AND from
2277
+ * persistent storage in one step, or a page refresh could resurrect
2278
+ * stale-stamped ops the server would reject. Resets the seen set to exactly
2279
+ * the new ids so the rewritten ops are not treated as duplicates.
2280
+ */
2281
+ async replaceAll(ops) {
2282
+ const removeIds = this.queue.map((op) => op.id);
2283
+ for (const batch of this.inFlight.values()) {
2284
+ for (const op of batch) {
2285
+ removeIds.push(op.id);
2286
+ }
2287
+ }
2288
+ this.inFlight.clear();
2289
+ this.queue = ops.length > 1 ? topologicalSort([...ops]) : [...ops];
2290
+ this.seen.clear();
2291
+ for (const op of this.queue) {
2292
+ this.seen.add(op.id);
2293
+ }
2294
+ if (removeIds.length > 0) {
2295
+ await this.storage.dequeue(removeIds);
2296
+ }
2297
+ for (const op of this.queue) {
2298
+ await this.storage.enqueue(op);
2299
+ }
2300
+ }
2252
2301
  /**
2253
2302
  * Whether initialize() has been called.
2254
2303
  */
@@ -2310,6 +2359,8 @@ var SyncEngine = class {
2310
2359
  currentBatch = null;
2311
2360
  reconnecting = false;
2312
2361
  schemaBlocked = false;
2362
+ clockBlocked = false;
2363
+ clockSkewMs = null;
2313
2364
  // Track delta exchange state
2314
2365
  deltaBatchesReceived = 0;
2315
2366
  deltaReceiveComplete = false;
@@ -2483,10 +2534,17 @@ var SyncEngine = class {
2483
2534
  lastSyncedAt: this.lastSyncedAt,
2484
2535
  lastSuccessfulPush: this.lastSuccessfulPush,
2485
2536
  lastSuccessfulPull: this.lastSuccessfulPull,
2486
- conflicts: this.conflictCount
2537
+ conflicts: this.conflictCount,
2538
+ clockSkewMs: this.clockSkewMs
2487
2539
  };
2488
2540
  switch (this.state) {
2489
2541
  case "disconnected":
2542
+ if (this.clockBlocked) {
2543
+ return { ...base, status: "clock-error" };
2544
+ }
2545
+ if (this.schemaBlocked) {
2546
+ return { ...base, status: "schema-mismatch" };
2547
+ }
2490
2548
  return { ...base, status: "offline" };
2491
2549
  case "connecting":
2492
2550
  case "handshaking":
@@ -2495,6 +2553,9 @@ var SyncEngine = class {
2495
2553
  case "streaming":
2496
2554
  return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
2497
2555
  case "error":
2556
+ if (this.clockBlocked) {
2557
+ return { ...base, status: "clock-error" };
2558
+ }
2498
2559
  return { ...base, status: this.schemaBlocked ? "schema-mismatch" : "error" };
2499
2560
  }
2500
2561
  }
@@ -2505,6 +2566,27 @@ var SyncEngine = class {
2505
2566
  isSchemaBlocked() {
2506
2567
  return this.schemaBlocked;
2507
2568
  }
2569
+ /**
2570
+ * True when sync is blocked because this device's clock is too far ahead.
2571
+ * Local writes continue to work and queue; only sync is paused.
2572
+ */
2573
+ isClockBlocked() {
2574
+ return this.clockBlocked;
2575
+ }
2576
+ /** serverTime - localTime measured at the last handshake, or null before first connect. */
2577
+ getClockSkewMs() {
2578
+ return this.clockSkewMs;
2579
+ }
2580
+ /**
2581
+ * Clears the clock block after the user corrects the device clock.
2582
+ * Moves the engine back to `disconnected` so `start()` can run again.
2583
+ */
2584
+ clearClockBlock() {
2585
+ this.clockBlocked = false;
2586
+ if (this.state === "error") {
2587
+ this.transitionTo("disconnected");
2588
+ }
2589
+ }
2508
2590
  /**
2509
2591
  * Clears schema-mismatch block after upgrading the local schema / sync config.
2510
2592
  * Moves the engine back to `disconnected` so `start()` can run again.
@@ -2648,7 +2730,7 @@ var SyncEngine = class {
2648
2730
  async handleMessageAsync(message) {
2649
2731
  switch (message.type) {
2650
2732
  case "handshake-response":
2651
- this.handleHandshakeResponse(message);
2733
+ await this.handleHandshakeResponse(message);
2652
2734
  break;
2653
2735
  case "operation-batch":
2654
2736
  await this.handleOperationBatch(message);
@@ -2671,8 +2753,41 @@ var SyncEngine = class {
2671
2753
  const reason = error instanceof Error ? error.message : "Message handling failed";
2672
2754
  this.handleTransportClose(reason);
2673
2755
  }
2674
- handleHandshakeResponse(msg) {
2756
+ /**
2757
+ * Compares server wall-clock time from the handshake with local time.
2758
+ * Negative skew = this device's clock is fast (dangerous for LWW and rejected
2759
+ * by server ingest beyond 60s). Positive skew = slow (accepted but surfaced).
2760
+ * Zero developer work required: the result flows into sync status and events.
2761
+ */
2762
+ evaluateClockSkew(serverTime) {
2763
+ const skewMs = serverTime - Date.now();
2764
+ this.clockSkewMs = skewMs;
2765
+ const FAST_BLOCK_MS = 6e4;
2766
+ const SLOW_WARN_MS = 10 * 6e4;
2767
+ let severity = "info";
2768
+ if (skewMs < -FAST_BLOCK_MS) {
2769
+ severity = "fast-blocked";
2770
+ } else if (skewMs > SLOW_WARN_MS) {
2771
+ severity = "slow-warning";
2772
+ }
2773
+ this.emitter?.emit({ type: "sync:clock-skew", skewMs, severity, source: "handshake" });
2774
+ if (severity === "fast-blocked") {
2775
+ this.clockBlocked = true;
2776
+ } else {
2777
+ this.clockBlocked = false;
2778
+ }
2779
+ }
2780
+ async handleHandshakeResponse(msg) {
2675
2781
  if (this.state !== "handshaking") return;
2782
+ if (typeof msg.serverTime === "number") {
2783
+ this.evaluateClockSkew(msg.serverTime);
2784
+ if (this.clockBlocked) {
2785
+ this.metricsCollector.updateStatus("error");
2786
+ this.transitionTo("error");
2787
+ void this.transport.disconnect();
2788
+ return;
2789
+ }
2790
+ }
2676
2791
  if (!msg.accepted) {
2677
2792
  const reason = msg.rejectReason ?? "Handshake rejected";
2678
2793
  if (isSchemaMismatchReject(msg.rejectReason)) {
@@ -2719,8 +2834,51 @@ var SyncEngine = class {
2719
2834
  this.deltaSentOpIds = [];
2720
2835
  this.pendingDeltaBatchAcks.clear();
2721
2836
  this.initialSyncTotalBatches = 0;
2837
+ if (typeof msg.serverTime === "number") {
2838
+ await this.maybeRebaseQueuedOperations(msg.serverTime);
2839
+ }
2722
2840
  this.sendDelta();
2723
2841
  }
2842
+ /**
2843
+ * Re-stamps queued (never-acknowledged) operations whose timestamps are far
2844
+ * enough in the future that the server would reject them, using the server's
2845
+ * own handshake time as the trusted "now". This is the automatic recovery
2846
+ * path after a user corrects a fast device clock: the queue drains
2847
+ * immediately instead of waiting for real time to catch up.
2848
+ */
2849
+ async maybeRebaseQueuedOperations(serverTime) {
2850
+ const rebase = this.store.rebaseUnsyncedOperations?.bind(this.store);
2851
+ if (!rebase) return;
2852
+ const queued = this.outboundQueue.getAll();
2853
+ if (queued.length === 0) return;
2854
+ let maxQueuedWallTime = Number.NEGATIVE_INFINITY;
2855
+ for (const op of queued) {
2856
+ if (op.timestamp.wallTime > maxQueuedWallTime) {
2857
+ maxQueuedWallTime = op.timestamp.wallTime;
2858
+ }
2859
+ }
2860
+ const SERVER_FUTURE_TOLERANCE_MS = 6e4;
2861
+ if (maxQueuedWallTime <= serverTime + SERVER_FUTURE_TOLERANCE_MS) return;
2862
+ try {
2863
+ const result = await rebase(
2864
+ queued.map((op) => op.id),
2865
+ serverTime
2866
+ );
2867
+ await this.outboundQueue.replaceAll(result.operations);
2868
+ this.emitter?.emit({
2869
+ type: "sync:clock-rebase",
2870
+ rebasedCount: result.rebasedCount,
2871
+ maxSkewMs: maxQueuedWallTime - serverTime
2872
+ });
2873
+ } catch (error) {
2874
+ this.emitter?.emit({
2875
+ type: "store:persistence-error",
2876
+ dbName: "kora-oplog",
2877
+ message: error instanceof Error ? error.message : "Timestamp rebase failed",
2878
+ code: "CLOCK_REBASE_FAILED"
2879
+ });
2880
+ }
2881
+ }
2724
2882
  async sendDelta() {
2725
2883
  const localVector = this.store.getVersionVector();
2726
2884
  const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
@@ -2900,6 +3058,17 @@ var SyncEngine = class {
2900
3058
  if (msg.code === "AUTH_FAILED") {
2901
3059
  this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
2902
3060
  }
3061
+ if (msg.code === "INVALID_TIMESTAMP") {
3062
+ this.clockBlocked = true;
3063
+ this.emitter?.emit({
3064
+ type: "sync:clock-skew",
3065
+ skewMs: this.clockSkewMs ?? Number.NaN,
3066
+ severity: "fast-blocked",
3067
+ source: "server-reject"
3068
+ });
3069
+ this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
3070
+ return;
3071
+ }
2903
3072
  this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
2904
3073
  this.transitionTo("disconnected");
2905
3074
  }
@@ -3380,7 +3549,7 @@ var KeyDerivationError = class extends SyncError5 {
3380
3549
  }
3381
3550
  };
3382
3551
  var SALT_LENGTH = 32;
3383
- var PBKDF2_ITERATIONS = 6e5;
3552
+ var DEFAULT_PBKDF2_ITERATIONS = 6e5;
3384
3553
  var DERIVED_KEY_LENGTH = 256;
3385
3554
  function assertCryptoAvailable() {
3386
3555
  if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
@@ -3397,13 +3566,19 @@ function generateSalt() {
3397
3566
  }
3398
3567
  return globalThis.crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
3399
3568
  }
3400
- async function deriveKey(passphrase, salt) {
3569
+ async function deriveKey(passphrase, salt, iterations = DEFAULT_PBKDF2_ITERATIONS) {
3401
3570
  assertCryptoAvailable();
3402
3571
  if (passphrase.length === 0) {
3403
3572
  throw new KeyDerivationError(
3404
3573
  "Passphrase must not be empty. Provide a non-empty string for encryption key derivation."
3405
3574
  );
3406
3575
  }
3576
+ if (!Number.isInteger(iterations) || iterations < 1) {
3577
+ throw new KeyDerivationError(
3578
+ `PBKDF2 iteration count must be a positive integer, received: ${iterations}`,
3579
+ { iterations }
3580
+ );
3581
+ }
3407
3582
  const usedSalt = salt ?? generateSalt();
3408
3583
  try {
3409
3584
  const passphraseBytes = new TextEncoder().encode(passphrase);
@@ -3418,7 +3593,7 @@ async function deriveKey(passphrase, salt) {
3418
3593
  {
3419
3594
  name: "PBKDF2",
3420
3595
  salt: usedSalt,
3421
- iterations: PBKDF2_ITERATIONS,
3596
+ iterations,
3422
3597
  hash: "SHA-256"
3423
3598
  },
3424
3599
  baseKey,
@@ -3438,13 +3613,13 @@ async function deriveKey(passphrase, salt) {
3438
3613
  );
3439
3614
  }
3440
3615
  }
3441
- async function deriveVersionedKey(passphrase, version, salt) {
3616
+ async function deriveVersionedKey(passphrase, version, salt, iterations = DEFAULT_PBKDF2_ITERATIONS) {
3442
3617
  if (!Number.isInteger(version) || version < 1) {
3443
3618
  throw new KeyDerivationError(`Key version must be a positive integer, received: ${version}`, {
3444
3619
  version
3445
3620
  });
3446
3621
  }
3447
- const { key, salt: usedSalt } = await deriveKey(passphrase, salt);
3622
+ const { key, salt: usedSalt } = await deriveKey(passphrase, salt, iterations);
3448
3623
  return { version, key, salt: usedSalt };
3449
3624
  }
3450
3625
 
@@ -3484,11 +3659,13 @@ var SyncEncryptor = class _SyncEncryptor {
3484
3659
  * @param config - Encryption configuration with passphrase
3485
3660
  * @param salt - Optional salt for deterministic key derivation (mainly for testing).
3486
3661
  * If omitted, a random salt is generated.
3662
+ * @param iterations - Optional PBKDF2 iteration count. Defaults to the
3663
+ * production-strength value. Lower it only in tests.
3487
3664
  * @returns A configured SyncEncryptor instance
3488
3665
  * @throws {EncryptionError} If configuration is invalid
3489
3666
  * @throws {KeyDerivationError} If key derivation fails
3490
3667
  */
3491
- static async create(config, salt) {
3668
+ static async create(config, salt, iterations) {
3492
3669
  if (!config.enabled) {
3493
3670
  throw new EncryptionError(
3494
3671
  "Cannot create SyncEncryptor with encryption disabled. Set enabled: true in the encryption config."
@@ -3500,7 +3677,7 @@ var SyncEncryptor = class _SyncEncryptor {
3500
3677
  "Encryption key/passphrase must not be empty. Provide a non-empty string or key provider function."
3501
3678
  );
3502
3679
  }
3503
- const versionedKey = await deriveVersionedKey(passphrase, 1, salt);
3680
+ const versionedKey = await deriveVersionedKey(passphrase, 1, salt, iterations);
3504
3681
  const keys = /* @__PURE__ */ new Map();
3505
3682
  keys.set(1, versionedKey);
3506
3683
  return new _SyncEncryptor(keys, 1);
@@ -3801,10 +3978,12 @@ var OFFLINE_SYNC_STATUS = Object.freeze({
3801
3978
  lastSyncedAt: null,
3802
3979
  lastSuccessfulPush: null,
3803
3980
  lastSuccessfulPull: null,
3804
- conflicts: 0
3981
+ conflicts: 0,
3982
+ clockSkewMs: null
3805
3983
  });
3806
3984
  var SYNC_STATUS_EVENT_TYPES = [
3807
3985
  "sync:connected",
3986
+ "sync:clock-skew",
3808
3987
  "sync:disconnected",
3809
3988
  "sync:schema-mismatch",
3810
3989
  "sync:auth-failed",