@korajs/sync 0.6.1 → 1.0.0-beta.1

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
  ];
@@ -221,6 +222,12 @@ function isSyncMessage(value) {
221
222
  return isAwarenessUpdateMessage(value);
222
223
  case "yjs-doc-update":
223
224
  return isYjsDocUpdateMessage(value);
225
+ case "blob-chunk-request":
226
+ return isBlobChunkRequestMessage(value);
227
+ case "blob-chunk-response":
228
+ return isBlobChunkResponseMessage(value);
229
+ case "blob-chunk-push":
230
+ return isBlobChunkPushMessage(value);
224
231
  default:
225
232
  return false;
226
233
  }
@@ -260,6 +267,21 @@ function isYjsDocUpdateMessage(value) {
260
267
  const msg = value;
261
268
  return msg.type === "yjs-doc-update" && typeof msg.messageId === "string" && typeof msg.collection === "string" && typeof msg.recordId === "string" && typeof msg.field === "string" && typeof msg.update === "string";
262
269
  }
270
+ function isBlobChunkRequestMessage(value) {
271
+ if (typeof value !== "object" || value === null) return false;
272
+ const msg = value;
273
+ return msg.type === "blob-chunk-request" && typeof msg.messageId === "string" && typeof msg.requestId === "string" && typeof msg.hash === "string";
274
+ }
275
+ function isBlobChunkResponseMessage(value) {
276
+ if (typeof value !== "object" || value === null) return false;
277
+ const msg = value;
278
+ return msg.type === "blob-chunk-response" && typeof msg.messageId === "string" && typeof msg.requestId === "string" && (typeof msg.bytes === "string" || msg.bytes === null);
279
+ }
280
+ function isBlobChunkPushMessage(value) {
281
+ if (typeof value !== "object" || value === null) return false;
282
+ const msg = value;
283
+ return msg.type === "blob-chunk-push" && typeof msg.messageId === "string" && typeof msg.hash === "string" && typeof msg.bytes === "string";
284
+ }
263
285
 
264
286
  // src/protocol/serializer.ts
265
287
  import { SyncError } from "@korajs/core";
@@ -499,7 +521,9 @@ function toProtoEnvelope(message) {
499
521
  schemaVersion: message.schemaVersion,
500
522
  accepted: message.accepted,
501
523
  rejectReason: message.rejectReason,
502
- selectedWireFormat: message.selectedWireFormat
524
+ selectedWireFormat: message.selectedWireFormat,
525
+ serverTime: message.serverTime,
526
+ blobStorageEnabled: message.blobStorageEnabled
503
527
  };
504
528
  case "operation-batch":
505
529
  return {
@@ -530,6 +554,28 @@ function toProtoEnvelope(message) {
530
554
  type: message.type,
531
555
  messageId: message.messageId
532
556
  };
557
+ case "blob-chunk-request":
558
+ return {
559
+ type: message.type,
560
+ messageId: message.messageId,
561
+ requestId: message.requestId,
562
+ hash: message.hash
563
+ };
564
+ case "blob-chunk-response":
565
+ return {
566
+ type: message.type,
567
+ messageId: message.messageId,
568
+ requestId: message.requestId,
569
+ hasBytes: message.bytes !== null,
570
+ ...message.bytes !== null ? { chunkBytes: message.bytes } : {}
571
+ };
572
+ case "blob-chunk-push":
573
+ return {
574
+ type: message.type,
575
+ messageId: message.messageId,
576
+ hash: message.hash,
577
+ chunkBytes: message.bytes
578
+ };
533
579
  }
534
580
  }
535
581
  function fromProtoEnvelope(envelope) {
@@ -559,7 +605,11 @@ function fromProtoEnvelope(envelope) {
559
605
  schemaVersion: envelope.schemaVersion ?? 0,
560
606
  accepted: envelope.accepted ?? false,
561
607
  rejectReason: envelope.rejectReason,
562
- selectedWireFormat: envelope.selectedWireFormat === "json" || envelope.selectedWireFormat === "protobuf" ? envelope.selectedWireFormat : void 0
608
+ selectedWireFormat: envelope.selectedWireFormat === "json" || envelope.selectedWireFormat === "protobuf" ? envelope.selectedWireFormat : void 0,
609
+ // Preserve the server's wall-clock time so clock-skew detection and
610
+ // automatic timestamp rebase work over the protobuf wire, not just JSON.
611
+ ...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {},
612
+ ...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {}
563
613
  };
564
614
  case "operation-batch":
565
615
  return {
@@ -584,6 +634,27 @@ function fromProtoEnvelope(envelope) {
584
634
  message: envelope.errorMessage ?? "Unknown error",
585
635
  retriable: envelope.retriable ?? false
586
636
  };
637
+ case "blob-chunk-request":
638
+ return {
639
+ type: "blob-chunk-request",
640
+ messageId: envelope.messageId,
641
+ requestId: envelope.requestId ?? "",
642
+ hash: envelope.hash ?? ""
643
+ };
644
+ case "blob-chunk-response":
645
+ return {
646
+ type: "blob-chunk-response",
647
+ messageId: envelope.messageId,
648
+ requestId: envelope.requestId ?? "",
649
+ bytes: envelope.hasBytes ? envelope.chunkBytes ?? "" : null
650
+ };
651
+ case "blob-chunk-push":
652
+ return {
653
+ type: "blob-chunk-push",
654
+ messageId: envelope.messageId,
655
+ hash: envelope.hash ?? "",
656
+ bytes: envelope.chunkBytes ?? ""
657
+ };
587
658
  default:
588
659
  throw new SyncError("Failed to decode sync message: unknown protobuf type", {
589
660
  type: envelope.type
@@ -728,6 +799,15 @@ function encodeEnvelope(envelope) {
728
799
  writer.uint32(138).string(envelope.errorMessage);
729
800
  }
730
801
  if (envelope.retriable !== void 0) writer.uint32(144).bool(envelope.retriable);
802
+ if (envelope.serverTime !== void 0) writer.uint32(152).int64(envelope.serverTime);
803
+ if (envelope.requestId && envelope.requestId.length > 0)
804
+ writer.uint32(162).string(envelope.requestId);
805
+ if (envelope.hash && envelope.hash.length > 0) writer.uint32(170).string(envelope.hash);
806
+ if (envelope.chunkBytes && envelope.chunkBytes.length > 0)
807
+ writer.uint32(178).string(envelope.chunkBytes);
808
+ if (envelope.hasBytes !== void 0) writer.uint32(184).bool(envelope.hasBytes);
809
+ if (envelope.blobStorageEnabled !== void 0)
810
+ writer.uint32(200).bool(envelope.blobStorageEnabled);
731
811
  return writer.finish();
732
812
  }
733
813
  function decodeEnvelope(bytes) {
@@ -796,6 +876,24 @@ function decodeEnvelope(bytes) {
796
876
  case 18:
797
877
  envelope.retriable = reader.bool();
798
878
  break;
879
+ case 19:
880
+ envelope.serverTime = longToNumber(reader.int64());
881
+ break;
882
+ case 20:
883
+ envelope.requestId = reader.string();
884
+ break;
885
+ case 21:
886
+ envelope.hash = reader.string();
887
+ break;
888
+ case 22:
889
+ envelope.chunkBytes = reader.string();
890
+ break;
891
+ case 23:
892
+ envelope.hasBytes = reader.bool();
893
+ break;
894
+ case 25:
895
+ envelope.blobStorageEnabled = reader.bool();
896
+ break;
799
897
  default:
800
898
  reader.skipType(tag & 7);
801
899
  }
@@ -1297,7 +1395,7 @@ var ChaosTransport = class {
1297
1395
  random;
1298
1396
  messageHandler = null;
1299
1397
  reorderBuffer = [];
1300
- timers = [];
1398
+ pending = [];
1301
1399
  constructor(inner, config) {
1302
1400
  this.inner = inner;
1303
1401
  this.dropRate = config?.dropRate ?? 0;
@@ -1312,10 +1410,14 @@ var ChaosTransport = class {
1312
1410
  }
1313
1411
  async disconnect() {
1314
1412
  this.flushReorderBuffer();
1315
- for (const timer of this.timers) {
1413
+ const flushed = this.pending;
1414
+ this.pending = [];
1415
+ for (const { timer } of flushed) {
1316
1416
  clearTimeout(timer);
1317
1417
  }
1318
- this.timers = [];
1418
+ for (const { message } of flushed) {
1419
+ this.deliverIncoming(message);
1420
+ }
1319
1421
  return this.inner.disconnect();
1320
1422
  }
1321
1423
  send(message) {
@@ -1352,9 +1454,10 @@ var ChaosTransport = class {
1352
1454
  if (this.maxLatency > 0) {
1353
1455
  const delay = Math.floor(this.random() * this.maxLatency);
1354
1456
  const timer = setTimeout(() => {
1457
+ this.pending = this.pending.filter((p) => p.timer !== timer);
1355
1458
  this.deliverIncoming(message);
1356
1459
  }, delay);
1357
- this.timers.push(timer);
1460
+ this.pending.push({ timer, message });
1358
1461
  return;
1359
1462
  }
1360
1463
  this.deliverIncoming(message);
@@ -1598,6 +1701,95 @@ var AwarenessManager = class {
1598
1701
  }
1599
1702
  };
1600
1703
 
1704
+ // src/blob/blob-chunk-channel.ts
1705
+ import { generateUUIDv7 } from "@korajs/core";
1706
+ function encodeBlobChunkBytes(bytes) {
1707
+ let binary = "";
1708
+ for (let i = 0; i < bytes.length; i++) {
1709
+ binary += String.fromCharCode(bytes[i]);
1710
+ }
1711
+ return btoa(binary);
1712
+ }
1713
+ function decodeBlobChunkBytes(encoded) {
1714
+ const binary = atob(encoded);
1715
+ const out = new Uint8Array(binary.length);
1716
+ for (let i = 0; i < binary.length; i++) {
1717
+ out[i] = binary.charCodeAt(i);
1718
+ }
1719
+ return out;
1720
+ }
1721
+ var BlobChunkChannel = class {
1722
+ onSend;
1723
+ handlers = /* @__PURE__ */ new Set();
1724
+ constructor(options) {
1725
+ this.onSend = options?.onSend ?? null;
1726
+ }
1727
+ /**
1728
+ * Send a blob-chunk message to the peer. Response bytes are base64-encoded
1729
+ * onto the wire here; callers pass a `Uint8Array`.
1730
+ */
1731
+ send(message) {
1732
+ if (!this.onSend) {
1733
+ return;
1734
+ }
1735
+ if (message.type === "blob-chunk-request") {
1736
+ this.onSend({
1737
+ type: "blob-chunk-request",
1738
+ messageId: generateUUIDv7(),
1739
+ requestId: message.requestId,
1740
+ hash: message.hash
1741
+ });
1742
+ return;
1743
+ }
1744
+ if (message.type === "blob-chunk-push") {
1745
+ this.onSend({
1746
+ type: "blob-chunk-push",
1747
+ messageId: generateUUIDv7(),
1748
+ hash: message.hash,
1749
+ bytes: encodeBlobChunkBytes(message.bytes)
1750
+ });
1751
+ return;
1752
+ }
1753
+ this.onSend({
1754
+ type: "blob-chunk-response",
1755
+ messageId: generateUUIDv7(),
1756
+ requestId: message.requestId,
1757
+ bytes: message.bytes === null ? null : encodeBlobChunkBytes(message.bytes)
1758
+ });
1759
+ }
1760
+ /**
1761
+ * Register a handler for inbound blob-chunk messages. Multiple handlers are
1762
+ * allowed; each receives every delivered message.
1763
+ */
1764
+ onMessage(handler) {
1765
+ this.handlers.add(handler);
1766
+ return () => {
1767
+ this.handlers.delete(handler);
1768
+ };
1769
+ }
1770
+ /**
1771
+ * Deliver an inbound wire message to registered handlers, decoding response
1772
+ * bytes from base64 back to a `Uint8Array` first.
1773
+ */
1774
+ deliver(message) {
1775
+ if (this.handlers.size === 0) {
1776
+ return;
1777
+ }
1778
+ const decoded = message.type === "blob-chunk-request" ? { type: "blob-chunk-request", requestId: message.requestId, hash: message.hash } : message.type === "blob-chunk-push" ? {
1779
+ type: "blob-chunk-push",
1780
+ hash: message.hash,
1781
+ bytes: decodeBlobChunkBytes(message.bytes)
1782
+ } : {
1783
+ type: "blob-chunk-response",
1784
+ requestId: message.requestId,
1785
+ bytes: message.bytes === null ? null : decodeBlobChunkBytes(message.bytes)
1786
+ };
1787
+ for (const handler of this.handlers) {
1788
+ handler(decoded);
1789
+ }
1790
+ }
1791
+ };
1792
+
1601
1793
  // src/diagnostics/bandwidth-estimator.ts
1602
1794
  var BandwidthEstimator = class {
1603
1795
  maxSamples;
@@ -2041,7 +2233,7 @@ var SyncMetricsCollector = class {
2041
2233
  };
2042
2234
 
2043
2235
  // src/richtext/richtext-doc-channel.ts
2044
- import { generateUUIDv7 } from "@korajs/core";
2236
+ import { generateUUIDv7 as generateUUIDv72 } from "@korajs/core";
2045
2237
 
2046
2238
  // src/richtext/doc-channel-wire.ts
2047
2239
  function encodeYjsUpdate(update) {
@@ -2120,7 +2312,7 @@ var RichtextDocChannel = class {
2120
2312
  }
2121
2313
  this.onSend({
2122
2314
  type: "yjs-doc-update",
2123
- messageId: generateUUIDv7(),
2315
+ messageId: generateUUIDv72(),
2124
2316
  collection,
2125
2317
  recordId,
2126
2318
  field,
@@ -2249,6 +2441,41 @@ var OutboundQueue = class {
2249
2441
  peek(count) {
2250
2442
  return this.queue.slice(0, count);
2251
2443
  }
2444
+ /**
2445
+ * All queued operations (not counting in-flight), in causal order.
2446
+ */
2447
+ getAll() {
2448
+ return [...this.queue];
2449
+ }
2450
+ /**
2451
+ * Atomically replace the entire queue with a new set of operations.
2452
+ *
2453
+ * Used after a timestamp rebase rewrote the queued operations under new
2454
+ * content-addressed ids: the old entries must vanish from memory AND from
2455
+ * persistent storage in one step, or a page refresh could resurrect
2456
+ * stale-stamped ops the server would reject. Resets the seen set to exactly
2457
+ * the new ids so the rewritten ops are not treated as duplicates.
2458
+ */
2459
+ async replaceAll(ops) {
2460
+ const removeIds = this.queue.map((op) => op.id);
2461
+ for (const batch of this.inFlight.values()) {
2462
+ for (const op of batch) {
2463
+ removeIds.push(op.id);
2464
+ }
2465
+ }
2466
+ this.inFlight.clear();
2467
+ this.queue = ops.length > 1 ? topologicalSort([...ops]) : [...ops];
2468
+ this.seen.clear();
2469
+ for (const op of this.queue) {
2470
+ this.seen.add(op.id);
2471
+ }
2472
+ if (removeIds.length > 0) {
2473
+ await this.storage.dequeue(removeIds);
2474
+ }
2475
+ for (const op of this.queue) {
2476
+ await this.storage.enqueue(op);
2477
+ }
2478
+ }
2252
2479
  /**
2253
2480
  * Whether initialize() has been called.
2254
2481
  */
@@ -2298,6 +2525,7 @@ var SyncEngine = class {
2298
2525
  encryptor;
2299
2526
  awarenessManager;
2300
2527
  richtextDocChannel;
2528
+ blobChunkChannel;
2301
2529
  metricsCollector;
2302
2530
  syncState;
2303
2531
  remoteVector = /* @__PURE__ */ new Map();
@@ -2310,6 +2538,9 @@ var SyncEngine = class {
2310
2538
  currentBatch = null;
2311
2539
  reconnecting = false;
2312
2540
  schemaBlocked = false;
2541
+ clockBlocked = false;
2542
+ clockSkewMs = null;
2543
+ blobStorageEnabled = false;
2313
2544
  // Track delta exchange state
2314
2545
  deltaBatchesReceived = 0;
2315
2546
  deltaReceiveComplete = false;
@@ -2358,6 +2589,14 @@ var SyncEngine = class {
2358
2589
  this.transport.send(message);
2359
2590
  }
2360
2591
  });
2592
+ this.blobChunkChannel = new BlobChunkChannel({
2593
+ onSend: (message) => {
2594
+ if (this.state !== "streaming") {
2595
+ return;
2596
+ }
2597
+ this.transport.send(message);
2598
+ }
2599
+ });
2361
2600
  this.awarenessManager.onSend((message) => {
2362
2601
  if (this.state !== "streaming") return;
2363
2602
  const wireMessage = {
@@ -2483,10 +2722,17 @@ var SyncEngine = class {
2483
2722
  lastSyncedAt: this.lastSyncedAt,
2484
2723
  lastSuccessfulPush: this.lastSuccessfulPush,
2485
2724
  lastSuccessfulPull: this.lastSuccessfulPull,
2486
- conflicts: this.conflictCount
2725
+ conflicts: this.conflictCount,
2726
+ clockSkewMs: this.clockSkewMs
2487
2727
  };
2488
2728
  switch (this.state) {
2489
2729
  case "disconnected":
2730
+ if (this.clockBlocked) {
2731
+ return { ...base, status: "clock-error" };
2732
+ }
2733
+ if (this.schemaBlocked) {
2734
+ return { ...base, status: "schema-mismatch" };
2735
+ }
2490
2736
  return { ...base, status: "offline" };
2491
2737
  case "connecting":
2492
2738
  case "handshaking":
@@ -2495,6 +2741,9 @@ var SyncEngine = class {
2495
2741
  case "streaming":
2496
2742
  return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
2497
2743
  case "error":
2744
+ if (this.clockBlocked) {
2745
+ return { ...base, status: "clock-error" };
2746
+ }
2498
2747
  return { ...base, status: this.schemaBlocked ? "schema-mismatch" : "error" };
2499
2748
  }
2500
2749
  }
@@ -2505,6 +2754,27 @@ var SyncEngine = class {
2505
2754
  isSchemaBlocked() {
2506
2755
  return this.schemaBlocked;
2507
2756
  }
2757
+ /**
2758
+ * True when sync is blocked because this device's clock is too far ahead.
2759
+ * Local writes continue to work and queue; only sync is paused.
2760
+ */
2761
+ isClockBlocked() {
2762
+ return this.clockBlocked;
2763
+ }
2764
+ /** serverTime - localTime measured at the last handshake, or null before first connect. */
2765
+ getClockSkewMs() {
2766
+ return this.clockSkewMs;
2767
+ }
2768
+ /**
2769
+ * Clears the clock block after the user corrects the device clock.
2770
+ * Moves the engine back to `disconnected` so `start()` can run again.
2771
+ */
2772
+ clearClockBlock() {
2773
+ this.clockBlocked = false;
2774
+ if (this.state === "error") {
2775
+ this.transitionTo("disconnected");
2776
+ }
2777
+ }
2508
2778
  /**
2509
2779
  * Clears schema-mismatch block after upgrading the local schema / sync config.
2510
2780
  * Moves the engine back to `disconnected` so `start()` can run again.
@@ -2640,6 +2910,31 @@ var SyncEngine = class {
2640
2910
  getRichtextDocChannel() {
2641
2911
  return this.richtextDocChannel;
2642
2912
  }
2913
+ /**
2914
+ * Side channel for out-of-band blob chunk transfer over the sync connection.
2915
+ * The app layer bridges this to a `ChunkMessagePort` to pull/serve blob bytes.
2916
+ */
2917
+ getBlobChunkChannel() {
2918
+ return this.blobChunkChannel;
2919
+ }
2920
+ /**
2921
+ * Whether the connected server persists blob bytes centrally (learned from the
2922
+ * handshake response). When true, the app uploads the bytes behind `blob`
2923
+ * fields so they stay available after the authoring device goes offline.
2924
+ */
2925
+ isBlobStorageEnabled() {
2926
+ return this.blobStorageEnabled;
2927
+ }
2928
+ /**
2929
+ * Upload a blob chunk (or manifest) to the server for central persistence.
2930
+ * A no-op unless connected and the server advertised blob storage.
2931
+ */
2932
+ uploadBlobChunk(hash, bytes) {
2933
+ if (this.state !== "streaming" || !this.blobStorageEnabled) {
2934
+ return;
2935
+ }
2936
+ this.blobChunkChannel.send({ type: "blob-chunk-push", hash, bytes });
2937
+ }
2643
2938
  // --- Private methods ---
2644
2939
  messageChain = Promise.resolve();
2645
2940
  enqueueMessage(message) {
@@ -2648,7 +2943,7 @@ var SyncEngine = class {
2648
2943
  async handleMessageAsync(message) {
2649
2944
  switch (message.type) {
2650
2945
  case "handshake-response":
2651
- this.handleHandshakeResponse(message);
2946
+ await this.handleHandshakeResponse(message);
2652
2947
  break;
2653
2948
  case "operation-batch":
2654
2949
  await this.handleOperationBatch(message);
@@ -2665,14 +2960,53 @@ var SyncEngine = class {
2665
2960
  case "yjs-doc-update":
2666
2961
  this.richtextDocChannel.deliver(message);
2667
2962
  break;
2963
+ case "blob-chunk-request":
2964
+ case "blob-chunk-response":
2965
+ case "blob-chunk-push":
2966
+ this.blobChunkChannel.deliver(message);
2967
+ break;
2668
2968
  }
2669
2969
  }
2670
2970
  handleMessageFailure(error) {
2671
2971
  const reason = error instanceof Error ? error.message : "Message handling failed";
2672
2972
  this.handleTransportClose(reason);
2673
2973
  }
2674
- handleHandshakeResponse(msg) {
2974
+ /**
2975
+ * Compares server wall-clock time from the handshake with local time.
2976
+ * Negative skew = this device's clock is fast (dangerous for LWW and rejected
2977
+ * by server ingest beyond 60s). Positive skew = slow (accepted but surfaced).
2978
+ * Zero developer work required: the result flows into sync status and events.
2979
+ */
2980
+ evaluateClockSkew(serverTime) {
2981
+ const skewMs = serverTime - Date.now();
2982
+ this.clockSkewMs = skewMs;
2983
+ const FAST_BLOCK_MS = 6e4;
2984
+ const SLOW_WARN_MS = 10 * 6e4;
2985
+ let severity = "info";
2986
+ if (skewMs < -FAST_BLOCK_MS) {
2987
+ severity = "fast-blocked";
2988
+ } else if (skewMs > SLOW_WARN_MS) {
2989
+ severity = "slow-warning";
2990
+ }
2991
+ this.emitter?.emit({ type: "sync:clock-skew", skewMs, severity, source: "handshake" });
2992
+ if (severity === "fast-blocked") {
2993
+ this.clockBlocked = true;
2994
+ } else {
2995
+ this.clockBlocked = false;
2996
+ }
2997
+ }
2998
+ async handleHandshakeResponse(msg) {
2675
2999
  if (this.state !== "handshaking") return;
3000
+ this.blobStorageEnabled = msg.blobStorageEnabled === true;
3001
+ if (typeof msg.serverTime === "number") {
3002
+ this.evaluateClockSkew(msg.serverTime);
3003
+ if (this.clockBlocked) {
3004
+ this.metricsCollector.updateStatus("error");
3005
+ this.transitionTo("error");
3006
+ void this.transport.disconnect();
3007
+ return;
3008
+ }
3009
+ }
2676
3010
  if (!msg.accepted) {
2677
3011
  const reason = msg.rejectReason ?? "Handshake rejected";
2678
3012
  if (isSchemaMismatchReject(msg.rejectReason)) {
@@ -2719,8 +3053,51 @@ var SyncEngine = class {
2719
3053
  this.deltaSentOpIds = [];
2720
3054
  this.pendingDeltaBatchAcks.clear();
2721
3055
  this.initialSyncTotalBatches = 0;
3056
+ if (typeof msg.serverTime === "number") {
3057
+ await this.maybeRebaseQueuedOperations(msg.serverTime);
3058
+ }
2722
3059
  this.sendDelta();
2723
3060
  }
3061
+ /**
3062
+ * Re-stamps queued (never-acknowledged) operations whose timestamps are far
3063
+ * enough in the future that the server would reject them, using the server's
3064
+ * own handshake time as the trusted "now". This is the automatic recovery
3065
+ * path after a user corrects a fast device clock: the queue drains
3066
+ * immediately instead of waiting for real time to catch up.
3067
+ */
3068
+ async maybeRebaseQueuedOperations(serverTime) {
3069
+ const rebase = this.store.rebaseUnsyncedOperations?.bind(this.store);
3070
+ if (!rebase) return;
3071
+ const queued = this.outboundQueue.getAll();
3072
+ if (queued.length === 0) return;
3073
+ let maxQueuedWallTime = Number.NEGATIVE_INFINITY;
3074
+ for (const op of queued) {
3075
+ if (op.timestamp.wallTime > maxQueuedWallTime) {
3076
+ maxQueuedWallTime = op.timestamp.wallTime;
3077
+ }
3078
+ }
3079
+ const SERVER_FUTURE_TOLERANCE_MS = 6e4;
3080
+ if (maxQueuedWallTime <= serverTime + SERVER_FUTURE_TOLERANCE_MS) return;
3081
+ try {
3082
+ const result = await rebase(
3083
+ queued.map((op) => op.id),
3084
+ serverTime
3085
+ );
3086
+ await this.outboundQueue.replaceAll(result.operations);
3087
+ this.emitter?.emit({
3088
+ type: "sync:clock-rebase",
3089
+ rebasedCount: result.rebasedCount,
3090
+ maxSkewMs: maxQueuedWallTime - serverTime
3091
+ });
3092
+ } catch (error) {
3093
+ this.emitter?.emit({
3094
+ type: "store:persistence-error",
3095
+ dbName: "kora-oplog",
3096
+ message: error instanceof Error ? error.message : "Timestamp rebase failed",
3097
+ code: "CLOCK_REBASE_FAILED"
3098
+ });
3099
+ }
3100
+ }
2724
3101
  async sendDelta() {
2725
3102
  const localVector = this.store.getVersionVector();
2726
3103
  const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
@@ -2900,6 +3277,17 @@ var SyncEngine = class {
2900
3277
  if (msg.code === "AUTH_FAILED") {
2901
3278
  this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
2902
3279
  }
3280
+ if (msg.code === "INVALID_TIMESTAMP") {
3281
+ this.clockBlocked = true;
3282
+ this.emitter?.emit({
3283
+ type: "sync:clock-skew",
3284
+ skewMs: this.clockSkewMs ?? Number.NaN,
3285
+ severity: "fast-blocked",
3286
+ source: "server-reject"
3287
+ });
3288
+ this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
3289
+ return;
3290
+ }
2903
3291
  this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
2904
3292
  this.transitionTo("disconnected");
2905
3293
  }
@@ -3380,7 +3768,7 @@ var KeyDerivationError = class extends SyncError5 {
3380
3768
  }
3381
3769
  };
3382
3770
  var SALT_LENGTH = 32;
3383
- var PBKDF2_ITERATIONS = 6e5;
3771
+ var DEFAULT_PBKDF2_ITERATIONS = 6e5;
3384
3772
  var DERIVED_KEY_LENGTH = 256;
3385
3773
  function assertCryptoAvailable() {
3386
3774
  if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
@@ -3397,13 +3785,19 @@ function generateSalt() {
3397
3785
  }
3398
3786
  return globalThis.crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
3399
3787
  }
3400
- async function deriveKey(passphrase, salt) {
3788
+ async function deriveKey(passphrase, salt, iterations = DEFAULT_PBKDF2_ITERATIONS) {
3401
3789
  assertCryptoAvailable();
3402
3790
  if (passphrase.length === 0) {
3403
3791
  throw new KeyDerivationError(
3404
3792
  "Passphrase must not be empty. Provide a non-empty string for encryption key derivation."
3405
3793
  );
3406
3794
  }
3795
+ if (!Number.isInteger(iterations) || iterations < 1) {
3796
+ throw new KeyDerivationError(
3797
+ `PBKDF2 iteration count must be a positive integer, received: ${iterations}`,
3798
+ { iterations }
3799
+ );
3800
+ }
3407
3801
  const usedSalt = salt ?? generateSalt();
3408
3802
  try {
3409
3803
  const passphraseBytes = new TextEncoder().encode(passphrase);
@@ -3418,7 +3812,7 @@ async function deriveKey(passphrase, salt) {
3418
3812
  {
3419
3813
  name: "PBKDF2",
3420
3814
  salt: usedSalt,
3421
- iterations: PBKDF2_ITERATIONS,
3815
+ iterations,
3422
3816
  hash: "SHA-256"
3423
3817
  },
3424
3818
  baseKey,
@@ -3438,13 +3832,13 @@ async function deriveKey(passphrase, salt) {
3438
3832
  );
3439
3833
  }
3440
3834
  }
3441
- async function deriveVersionedKey(passphrase, version, salt) {
3835
+ async function deriveVersionedKey(passphrase, version, salt, iterations = DEFAULT_PBKDF2_ITERATIONS) {
3442
3836
  if (!Number.isInteger(version) || version < 1) {
3443
3837
  throw new KeyDerivationError(`Key version must be a positive integer, received: ${version}`, {
3444
3838
  version
3445
3839
  });
3446
3840
  }
3447
- const { key, salt: usedSalt } = await deriveKey(passphrase, salt);
3841
+ const { key, salt: usedSalt } = await deriveKey(passphrase, salt, iterations);
3448
3842
  return { version, key, salt: usedSalt };
3449
3843
  }
3450
3844
 
@@ -3484,11 +3878,13 @@ var SyncEncryptor = class _SyncEncryptor {
3484
3878
  * @param config - Encryption configuration with passphrase
3485
3879
  * @param salt - Optional salt for deterministic key derivation (mainly for testing).
3486
3880
  * If omitted, a random salt is generated.
3881
+ * @param iterations - Optional PBKDF2 iteration count. Defaults to the
3882
+ * production-strength value. Lower it only in tests.
3487
3883
  * @returns A configured SyncEncryptor instance
3488
3884
  * @throws {EncryptionError} If configuration is invalid
3489
3885
  * @throws {KeyDerivationError} If key derivation fails
3490
3886
  */
3491
- static async create(config, salt) {
3887
+ static async create(config, salt, iterations) {
3492
3888
  if (!config.enabled) {
3493
3889
  throw new EncryptionError(
3494
3890
  "Cannot create SyncEncryptor with encryption disabled. Set enabled: true in the encryption config."
@@ -3500,7 +3896,7 @@ var SyncEncryptor = class _SyncEncryptor {
3500
3896
  "Encryption key/passphrase must not be empty. Provide a non-empty string or key provider function."
3501
3897
  );
3502
3898
  }
3503
- const versionedKey = await deriveVersionedKey(passphrase, 1, salt);
3899
+ const versionedKey = await deriveVersionedKey(passphrase, 1, salt, iterations);
3504
3900
  const keys = /* @__PURE__ */ new Map();
3505
3901
  keys.set(1, versionedKey);
3506
3902
  return new _SyncEncryptor(keys, 1);
@@ -3801,10 +4197,12 @@ var OFFLINE_SYNC_STATUS = Object.freeze({
3801
4197
  lastSyncedAt: null,
3802
4198
  lastSuccessfulPush: null,
3803
4199
  lastSuccessfulPull: null,
3804
- conflicts: 0
4200
+ conflicts: 0,
4201
+ clockSkewMs: null
3805
4202
  });
3806
4203
  var SYNC_STATUS_EVENT_TYPES = [
3807
4204
  "sync:connected",
4205
+ "sync:clock-skew",
3808
4206
  "sync:disconnected",
3809
4207
  "sync:schema-mismatch",
3810
4208
  "sync:auth-failed",
@@ -3947,6 +4345,7 @@ function subscribeRemoteAwarenessStates(awareness, listener) {
3947
4345
  }
3948
4346
  export {
3949
4347
  AwarenessManager,
4348
+ BlobChunkChannel,
3950
4349
  ChaosTransport,
3951
4350
  ConnectionMonitor,
3952
4351
  DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD,
@@ -3971,11 +4370,13 @@ export {
3971
4370
  WebSocketTransport,
3972
4371
  createDeltaCursorFromBatch,
3973
4372
  createSyncStatusController,
4373
+ decodeBlobChunkBytes,
3974
4374
  decodeDeltaCursor,
3975
4375
  decodeYjsUpdate,
3976
4376
  dedupeQuerySubsets,
3977
4377
  deriveKey,
3978
4378
  deriveVersionedKey,
4379
+ encodeBlobChunkBytes,
3979
4380
  encodeDeltaCursor,
3980
4381
  encodeYjsUpdate,
3981
4382
  filterOperationsByScope,
@@ -3983,6 +4384,9 @@ export {
3983
4384
  getRemoteAwarenessStates,
3984
4385
  isAcknowledgmentMessage,
3985
4386
  isAwarenessUpdateMessage,
4387
+ isBlobChunkPushMessage,
4388
+ isBlobChunkRequestMessage,
4389
+ isBlobChunkResponseMessage,
3986
4390
  isClientSchemaVersionSupported,
3987
4391
  isEncryptedPayload,
3988
4392
  isErrorMessage,