@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dr. Obed Ehoneah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.cjs CHANGED
@@ -101,6 +101,7 @@ var SYNC_STATUSES = [
101
101
  "syncing",
102
102
  "synced",
103
103
  "offline",
104
+ "clock-error",
104
105
  "error",
105
106
  "schema-mismatch"
106
107
  ];
@@ -347,7 +348,7 @@ function isYjsDocUpdateMessage(value) {
347
348
 
348
349
  // src/protocol/serializer.ts
349
350
  var import_core2 = require("@korajs/core");
350
- var import_minimal = __toESM(require("protobufjs/minimal"), 1);
351
+ var import_minimal = __toESM(require("protobufjs/minimal.js"), 1);
351
352
  var { Reader, Writer } = import_minimal.default;
352
353
  function versionVectorToWire(vector) {
353
354
  const wire = {};
@@ -583,7 +584,8 @@ function toProtoEnvelope(message) {
583
584
  schemaVersion: message.schemaVersion,
584
585
  accepted: message.accepted,
585
586
  rejectReason: message.rejectReason,
586
- selectedWireFormat: message.selectedWireFormat
587
+ selectedWireFormat: message.selectedWireFormat,
588
+ serverTime: message.serverTime
587
589
  };
588
590
  case "operation-batch":
589
591
  return {
@@ -643,7 +645,10 @@ function fromProtoEnvelope(envelope) {
643
645
  schemaVersion: envelope.schemaVersion ?? 0,
644
646
  accepted: envelope.accepted ?? false,
645
647
  rejectReason: envelope.rejectReason,
646
- selectedWireFormat: envelope.selectedWireFormat === "json" || envelope.selectedWireFormat === "protobuf" ? envelope.selectedWireFormat : void 0
648
+ selectedWireFormat: envelope.selectedWireFormat === "json" || envelope.selectedWireFormat === "protobuf" ? envelope.selectedWireFormat : void 0,
649
+ // Preserve the server's wall-clock time so clock-skew detection and
650
+ // automatic timestamp rebase work over the protobuf wire, not just JSON.
651
+ ...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {}
647
652
  };
648
653
  case "operation-batch":
649
654
  return {
@@ -812,6 +817,7 @@ function encodeEnvelope(envelope) {
812
817
  writer.uint32(138).string(envelope.errorMessage);
813
818
  }
814
819
  if (envelope.retriable !== void 0) writer.uint32(144).bool(envelope.retriable);
820
+ if (envelope.serverTime !== void 0) writer.uint32(152).int64(envelope.serverTime);
815
821
  return writer.finish();
816
822
  }
817
823
  function decodeEnvelope(bytes) {
@@ -880,6 +886,9 @@ function decodeEnvelope(bytes) {
880
886
  case 18:
881
887
  envelope.retriable = reader.bool();
882
888
  break;
889
+ case 19:
890
+ envelope.serverTime = longToNumber(reader.int64());
891
+ break;
883
892
  default:
884
893
  reader.skipType(tag & 7);
885
894
  }
@@ -1381,7 +1390,7 @@ var ChaosTransport = class {
1381
1390
  random;
1382
1391
  messageHandler = null;
1383
1392
  reorderBuffer = [];
1384
- timers = [];
1393
+ pending = [];
1385
1394
  constructor(inner, config) {
1386
1395
  this.inner = inner;
1387
1396
  this.dropRate = config?.dropRate ?? 0;
@@ -1396,10 +1405,14 @@ var ChaosTransport = class {
1396
1405
  }
1397
1406
  async disconnect() {
1398
1407
  this.flushReorderBuffer();
1399
- for (const timer of this.timers) {
1408
+ const flushed = this.pending;
1409
+ this.pending = [];
1410
+ for (const { timer } of flushed) {
1400
1411
  clearTimeout(timer);
1401
1412
  }
1402
- this.timers = [];
1413
+ for (const { message } of flushed) {
1414
+ this.deliverIncoming(message);
1415
+ }
1403
1416
  return this.inner.disconnect();
1404
1417
  }
1405
1418
  send(message) {
@@ -1436,9 +1449,10 @@ var ChaosTransport = class {
1436
1449
  if (this.maxLatency > 0) {
1437
1450
  const delay = Math.floor(this.random() * this.maxLatency);
1438
1451
  const timer = setTimeout(() => {
1452
+ this.pending = this.pending.filter((p) => p.timer !== timer);
1439
1453
  this.deliverIncoming(message);
1440
1454
  }, delay);
1441
- this.timers.push(timer);
1455
+ this.pending.push({ timer, message });
1442
1456
  return;
1443
1457
  }
1444
1458
  this.deliverIncoming(message);
@@ -2345,6 +2359,41 @@ var OutboundQueue = class {
2345
2359
  peek(count) {
2346
2360
  return this.queue.slice(0, count);
2347
2361
  }
2362
+ /**
2363
+ * All queued operations (not counting in-flight), in causal order.
2364
+ */
2365
+ getAll() {
2366
+ return [...this.queue];
2367
+ }
2368
+ /**
2369
+ * Atomically replace the entire queue with a new set of operations.
2370
+ *
2371
+ * Used after a timestamp rebase rewrote the queued operations under new
2372
+ * content-addressed ids: the old entries must vanish from memory AND from
2373
+ * persistent storage in one step, or a page refresh could resurrect
2374
+ * stale-stamped ops the server would reject. Resets the seen set to exactly
2375
+ * the new ids so the rewritten ops are not treated as duplicates.
2376
+ */
2377
+ async replaceAll(ops) {
2378
+ const removeIds = this.queue.map((op) => op.id);
2379
+ for (const batch of this.inFlight.values()) {
2380
+ for (const op of batch) {
2381
+ removeIds.push(op.id);
2382
+ }
2383
+ }
2384
+ this.inFlight.clear();
2385
+ this.queue = ops.length > 1 ? (0, import_internal.topologicalSort)([...ops]) : [...ops];
2386
+ this.seen.clear();
2387
+ for (const op of this.queue) {
2388
+ this.seen.add(op.id);
2389
+ }
2390
+ if (removeIds.length > 0) {
2391
+ await this.storage.dequeue(removeIds);
2392
+ }
2393
+ for (const op of this.queue) {
2394
+ await this.storage.enqueue(op);
2395
+ }
2396
+ }
2348
2397
  /**
2349
2398
  * Whether initialize() has been called.
2350
2399
  */
@@ -2406,6 +2455,8 @@ var SyncEngine = class {
2406
2455
  currentBatch = null;
2407
2456
  reconnecting = false;
2408
2457
  schemaBlocked = false;
2458
+ clockBlocked = false;
2459
+ clockSkewMs = null;
2409
2460
  // Track delta exchange state
2410
2461
  deltaBatchesReceived = 0;
2411
2462
  deltaReceiveComplete = false;
@@ -2579,10 +2630,17 @@ var SyncEngine = class {
2579
2630
  lastSyncedAt: this.lastSyncedAt,
2580
2631
  lastSuccessfulPush: this.lastSuccessfulPush,
2581
2632
  lastSuccessfulPull: this.lastSuccessfulPull,
2582
- conflicts: this.conflictCount
2633
+ conflicts: this.conflictCount,
2634
+ clockSkewMs: this.clockSkewMs
2583
2635
  };
2584
2636
  switch (this.state) {
2585
2637
  case "disconnected":
2638
+ if (this.clockBlocked) {
2639
+ return { ...base, status: "clock-error" };
2640
+ }
2641
+ if (this.schemaBlocked) {
2642
+ return { ...base, status: "schema-mismatch" };
2643
+ }
2586
2644
  return { ...base, status: "offline" };
2587
2645
  case "connecting":
2588
2646
  case "handshaking":
@@ -2591,6 +2649,9 @@ var SyncEngine = class {
2591
2649
  case "streaming":
2592
2650
  return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
2593
2651
  case "error":
2652
+ if (this.clockBlocked) {
2653
+ return { ...base, status: "clock-error" };
2654
+ }
2594
2655
  return { ...base, status: this.schemaBlocked ? "schema-mismatch" : "error" };
2595
2656
  }
2596
2657
  }
@@ -2601,6 +2662,27 @@ var SyncEngine = class {
2601
2662
  isSchemaBlocked() {
2602
2663
  return this.schemaBlocked;
2603
2664
  }
2665
+ /**
2666
+ * True when sync is blocked because this device's clock is too far ahead.
2667
+ * Local writes continue to work and queue; only sync is paused.
2668
+ */
2669
+ isClockBlocked() {
2670
+ return this.clockBlocked;
2671
+ }
2672
+ /** serverTime - localTime measured at the last handshake, or null before first connect. */
2673
+ getClockSkewMs() {
2674
+ return this.clockSkewMs;
2675
+ }
2676
+ /**
2677
+ * Clears the clock block after the user corrects the device clock.
2678
+ * Moves the engine back to `disconnected` so `start()` can run again.
2679
+ */
2680
+ clearClockBlock() {
2681
+ this.clockBlocked = false;
2682
+ if (this.state === "error") {
2683
+ this.transitionTo("disconnected");
2684
+ }
2685
+ }
2604
2686
  /**
2605
2687
  * Clears schema-mismatch block after upgrading the local schema / sync config.
2606
2688
  * Moves the engine back to `disconnected` so `start()` can run again.
@@ -2744,7 +2826,7 @@ var SyncEngine = class {
2744
2826
  async handleMessageAsync(message) {
2745
2827
  switch (message.type) {
2746
2828
  case "handshake-response":
2747
- this.handleHandshakeResponse(message);
2829
+ await this.handleHandshakeResponse(message);
2748
2830
  break;
2749
2831
  case "operation-batch":
2750
2832
  await this.handleOperationBatch(message);
@@ -2767,8 +2849,41 @@ var SyncEngine = class {
2767
2849
  const reason = error instanceof Error ? error.message : "Message handling failed";
2768
2850
  this.handleTransportClose(reason);
2769
2851
  }
2770
- handleHandshakeResponse(msg) {
2852
+ /**
2853
+ * Compares server wall-clock time from the handshake with local time.
2854
+ * Negative skew = this device's clock is fast (dangerous for LWW and rejected
2855
+ * by server ingest beyond 60s). Positive skew = slow (accepted but surfaced).
2856
+ * Zero developer work required: the result flows into sync status and events.
2857
+ */
2858
+ evaluateClockSkew(serverTime) {
2859
+ const skewMs = serverTime - Date.now();
2860
+ this.clockSkewMs = skewMs;
2861
+ const FAST_BLOCK_MS = 6e4;
2862
+ const SLOW_WARN_MS = 10 * 6e4;
2863
+ let severity = "info";
2864
+ if (skewMs < -FAST_BLOCK_MS) {
2865
+ severity = "fast-blocked";
2866
+ } else if (skewMs > SLOW_WARN_MS) {
2867
+ severity = "slow-warning";
2868
+ }
2869
+ this.emitter?.emit({ type: "sync:clock-skew", skewMs, severity, source: "handshake" });
2870
+ if (severity === "fast-blocked") {
2871
+ this.clockBlocked = true;
2872
+ } else {
2873
+ this.clockBlocked = false;
2874
+ }
2875
+ }
2876
+ async handleHandshakeResponse(msg) {
2771
2877
  if (this.state !== "handshaking") return;
2878
+ if (typeof msg.serverTime === "number") {
2879
+ this.evaluateClockSkew(msg.serverTime);
2880
+ if (this.clockBlocked) {
2881
+ this.metricsCollector.updateStatus("error");
2882
+ this.transitionTo("error");
2883
+ void this.transport.disconnect();
2884
+ return;
2885
+ }
2886
+ }
2772
2887
  if (!msg.accepted) {
2773
2888
  const reason = msg.rejectReason ?? "Handshake rejected";
2774
2889
  if (isSchemaMismatchReject(msg.rejectReason)) {
@@ -2815,8 +2930,51 @@ var SyncEngine = class {
2815
2930
  this.deltaSentOpIds = [];
2816
2931
  this.pendingDeltaBatchAcks.clear();
2817
2932
  this.initialSyncTotalBatches = 0;
2933
+ if (typeof msg.serverTime === "number") {
2934
+ await this.maybeRebaseQueuedOperations(msg.serverTime);
2935
+ }
2818
2936
  this.sendDelta();
2819
2937
  }
2938
+ /**
2939
+ * Re-stamps queued (never-acknowledged) operations whose timestamps are far
2940
+ * enough in the future that the server would reject them, using the server's
2941
+ * own handshake time as the trusted "now". This is the automatic recovery
2942
+ * path after a user corrects a fast device clock: the queue drains
2943
+ * immediately instead of waiting for real time to catch up.
2944
+ */
2945
+ async maybeRebaseQueuedOperations(serverTime) {
2946
+ const rebase = this.store.rebaseUnsyncedOperations?.bind(this.store);
2947
+ if (!rebase) return;
2948
+ const queued = this.outboundQueue.getAll();
2949
+ if (queued.length === 0) return;
2950
+ let maxQueuedWallTime = Number.NEGATIVE_INFINITY;
2951
+ for (const op of queued) {
2952
+ if (op.timestamp.wallTime > maxQueuedWallTime) {
2953
+ maxQueuedWallTime = op.timestamp.wallTime;
2954
+ }
2955
+ }
2956
+ const SERVER_FUTURE_TOLERANCE_MS = 6e4;
2957
+ if (maxQueuedWallTime <= serverTime + SERVER_FUTURE_TOLERANCE_MS) return;
2958
+ try {
2959
+ const result = await rebase(
2960
+ queued.map((op) => op.id),
2961
+ serverTime
2962
+ );
2963
+ await this.outboundQueue.replaceAll(result.operations);
2964
+ this.emitter?.emit({
2965
+ type: "sync:clock-rebase",
2966
+ rebasedCount: result.rebasedCount,
2967
+ maxSkewMs: maxQueuedWallTime - serverTime
2968
+ });
2969
+ } catch (error) {
2970
+ this.emitter?.emit({
2971
+ type: "store:persistence-error",
2972
+ dbName: "kora-oplog",
2973
+ message: error instanceof Error ? error.message : "Timestamp rebase failed",
2974
+ code: "CLOCK_REBASE_FAILED"
2975
+ });
2976
+ }
2977
+ }
2820
2978
  async sendDelta() {
2821
2979
  const localVector = this.store.getVersionVector();
2822
2980
  const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
@@ -2996,6 +3154,17 @@ var SyncEngine = class {
2996
3154
  if (msg.code === "AUTH_FAILED") {
2997
3155
  this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
2998
3156
  }
3157
+ if (msg.code === "INVALID_TIMESTAMP") {
3158
+ this.clockBlocked = true;
3159
+ this.emitter?.emit({
3160
+ type: "sync:clock-skew",
3161
+ skewMs: this.clockSkewMs ?? Number.NaN,
3162
+ severity: "fast-blocked",
3163
+ source: "server-reject"
3164
+ });
3165
+ this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
3166
+ return;
3167
+ }
2999
3168
  this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
3000
3169
  this.transitionTo("disconnected");
3001
3170
  }
@@ -3476,7 +3645,7 @@ var KeyDerivationError = class extends import_core7.SyncError {
3476
3645
  }
3477
3646
  };
3478
3647
  var SALT_LENGTH = 32;
3479
- var PBKDF2_ITERATIONS = 6e5;
3648
+ var DEFAULT_PBKDF2_ITERATIONS = 6e5;
3480
3649
  var DERIVED_KEY_LENGTH = 256;
3481
3650
  function assertCryptoAvailable() {
3482
3651
  if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
@@ -3493,13 +3662,19 @@ function generateSalt() {
3493
3662
  }
3494
3663
  return globalThis.crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
3495
3664
  }
3496
- async function deriveKey(passphrase, salt) {
3665
+ async function deriveKey(passphrase, salt, iterations = DEFAULT_PBKDF2_ITERATIONS) {
3497
3666
  assertCryptoAvailable();
3498
3667
  if (passphrase.length === 0) {
3499
3668
  throw new KeyDerivationError(
3500
3669
  "Passphrase must not be empty. Provide a non-empty string for encryption key derivation."
3501
3670
  );
3502
3671
  }
3672
+ if (!Number.isInteger(iterations) || iterations < 1) {
3673
+ throw new KeyDerivationError(
3674
+ `PBKDF2 iteration count must be a positive integer, received: ${iterations}`,
3675
+ { iterations }
3676
+ );
3677
+ }
3503
3678
  const usedSalt = salt ?? generateSalt();
3504
3679
  try {
3505
3680
  const passphraseBytes = new TextEncoder().encode(passphrase);
@@ -3514,7 +3689,7 @@ async function deriveKey(passphrase, salt) {
3514
3689
  {
3515
3690
  name: "PBKDF2",
3516
3691
  salt: usedSalt,
3517
- iterations: PBKDF2_ITERATIONS,
3692
+ iterations,
3518
3693
  hash: "SHA-256"
3519
3694
  },
3520
3695
  baseKey,
@@ -3534,13 +3709,13 @@ async function deriveKey(passphrase, salt) {
3534
3709
  );
3535
3710
  }
3536
3711
  }
3537
- async function deriveVersionedKey(passphrase, version, salt) {
3712
+ async function deriveVersionedKey(passphrase, version, salt, iterations = DEFAULT_PBKDF2_ITERATIONS) {
3538
3713
  if (!Number.isInteger(version) || version < 1) {
3539
3714
  throw new KeyDerivationError(`Key version must be a positive integer, received: ${version}`, {
3540
3715
  version
3541
3716
  });
3542
3717
  }
3543
- const { key, salt: usedSalt } = await deriveKey(passphrase, salt);
3718
+ const { key, salt: usedSalt } = await deriveKey(passphrase, salt, iterations);
3544
3719
  return { version, key, salt: usedSalt };
3545
3720
  }
3546
3721
 
@@ -3580,11 +3755,13 @@ var SyncEncryptor = class _SyncEncryptor {
3580
3755
  * @param config - Encryption configuration with passphrase
3581
3756
  * @param salt - Optional salt for deterministic key derivation (mainly for testing).
3582
3757
  * If omitted, a random salt is generated.
3758
+ * @param iterations - Optional PBKDF2 iteration count. Defaults to the
3759
+ * production-strength value. Lower it only in tests.
3583
3760
  * @returns A configured SyncEncryptor instance
3584
3761
  * @throws {EncryptionError} If configuration is invalid
3585
3762
  * @throws {KeyDerivationError} If key derivation fails
3586
3763
  */
3587
- static async create(config, salt) {
3764
+ static async create(config, salt, iterations) {
3588
3765
  if (!config.enabled) {
3589
3766
  throw new EncryptionError(
3590
3767
  "Cannot create SyncEncryptor with encryption disabled. Set enabled: true in the encryption config."
@@ -3596,7 +3773,7 @@ var SyncEncryptor = class _SyncEncryptor {
3596
3773
  "Encryption key/passphrase must not be empty. Provide a non-empty string or key provider function."
3597
3774
  );
3598
3775
  }
3599
- const versionedKey = await deriveVersionedKey(passphrase, 1, salt);
3776
+ const versionedKey = await deriveVersionedKey(passphrase, 1, salt, iterations);
3600
3777
  const keys = /* @__PURE__ */ new Map();
3601
3778
  keys.set(1, versionedKey);
3602
3779
  return new _SyncEncryptor(keys, 1);
@@ -3897,10 +4074,12 @@ var OFFLINE_SYNC_STATUS = Object.freeze({
3897
4074
  lastSyncedAt: null,
3898
4075
  lastSuccessfulPush: null,
3899
4076
  lastSuccessfulPull: null,
3900
- conflicts: 0
4077
+ conflicts: 0,
4078
+ clockSkewMs: null
3901
4079
  });
3902
4080
  var SYNC_STATUS_EVENT_TYPES = [
3903
4081
  "sync:connected",
4082
+ "sync:clock-skew",
3904
4083
  "sync:disconnected",
3905
4084
  "sync:schema-mismatch",
3906
4085
  "sync:auth-failed",