@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.cjs CHANGED
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  AwarenessManager: () => AwarenessManager,
34
+ BlobChunkChannel: () => BlobChunkChannel,
34
35
  ChaosTransport: () => ChaosTransport,
35
36
  ConnectionMonitor: () => ConnectionMonitor,
36
37
  DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD: () => DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD,
@@ -55,11 +56,13 @@ __export(index_exports, {
55
56
  WebSocketTransport: () => WebSocketTransport,
56
57
  createDeltaCursorFromBatch: () => createDeltaCursorFromBatch,
57
58
  createSyncStatusController: () => createSyncStatusController,
59
+ decodeBlobChunkBytes: () => decodeBlobChunkBytes,
58
60
  decodeDeltaCursor: () => decodeDeltaCursor,
59
61
  decodeYjsUpdate: () => decodeYjsUpdate,
60
62
  dedupeQuerySubsets: () => dedupeQuerySubsets,
61
63
  deriveKey: () => deriveKey,
62
64
  deriveVersionedKey: () => deriveVersionedKey,
65
+ encodeBlobChunkBytes: () => encodeBlobChunkBytes,
63
66
  encodeDeltaCursor: () => encodeDeltaCursor,
64
67
  encodeYjsUpdate: () => encodeYjsUpdate,
65
68
  filterOperationsByScope: () => filterOperationsByScope,
@@ -67,6 +70,9 @@ __export(index_exports, {
67
70
  getRemoteAwarenessStates: () => getRemoteAwarenessStates,
68
71
  isAcknowledgmentMessage: () => isAcknowledgmentMessage,
69
72
  isAwarenessUpdateMessage: () => isAwarenessUpdateMessage,
73
+ isBlobChunkPushMessage: () => isBlobChunkPushMessage,
74
+ isBlobChunkRequestMessage: () => isBlobChunkRequestMessage,
75
+ isBlobChunkResponseMessage: () => isBlobChunkResponseMessage,
70
76
  isClientSchemaVersionSupported: () => isClientSchemaVersionSupported,
71
77
  isEncryptedPayload: () => isEncryptedPayload,
72
78
  isErrorMessage: () => isErrorMessage,
@@ -101,6 +107,7 @@ var SYNC_STATUSES = [
101
107
  "syncing",
102
108
  "synced",
103
109
  "offline",
110
+ "clock-error",
104
111
  "error",
105
112
  "schema-mismatch"
106
113
  ];
@@ -305,6 +312,12 @@ function isSyncMessage(value) {
305
312
  return isAwarenessUpdateMessage(value);
306
313
  case "yjs-doc-update":
307
314
  return isYjsDocUpdateMessage(value);
315
+ case "blob-chunk-request":
316
+ return isBlobChunkRequestMessage(value);
317
+ case "blob-chunk-response":
318
+ return isBlobChunkResponseMessage(value);
319
+ case "blob-chunk-push":
320
+ return isBlobChunkPushMessage(value);
308
321
  default:
309
322
  return false;
310
323
  }
@@ -344,6 +357,21 @@ function isYjsDocUpdateMessage(value) {
344
357
  const msg = value;
345
358
  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";
346
359
  }
360
+ function isBlobChunkRequestMessage(value) {
361
+ if (typeof value !== "object" || value === null) return false;
362
+ const msg = value;
363
+ return msg.type === "blob-chunk-request" && typeof msg.messageId === "string" && typeof msg.requestId === "string" && typeof msg.hash === "string";
364
+ }
365
+ function isBlobChunkResponseMessage(value) {
366
+ if (typeof value !== "object" || value === null) return false;
367
+ const msg = value;
368
+ return msg.type === "blob-chunk-response" && typeof msg.messageId === "string" && typeof msg.requestId === "string" && (typeof msg.bytes === "string" || msg.bytes === null);
369
+ }
370
+ function isBlobChunkPushMessage(value) {
371
+ if (typeof value !== "object" || value === null) return false;
372
+ const msg = value;
373
+ return msg.type === "blob-chunk-push" && typeof msg.messageId === "string" && typeof msg.hash === "string" && typeof msg.bytes === "string";
374
+ }
347
375
 
348
376
  // src/protocol/serializer.ts
349
377
  var import_core2 = require("@korajs/core");
@@ -583,7 +611,9 @@ function toProtoEnvelope(message) {
583
611
  schemaVersion: message.schemaVersion,
584
612
  accepted: message.accepted,
585
613
  rejectReason: message.rejectReason,
586
- selectedWireFormat: message.selectedWireFormat
614
+ selectedWireFormat: message.selectedWireFormat,
615
+ serverTime: message.serverTime,
616
+ blobStorageEnabled: message.blobStorageEnabled
587
617
  };
588
618
  case "operation-batch":
589
619
  return {
@@ -614,6 +644,28 @@ function toProtoEnvelope(message) {
614
644
  type: message.type,
615
645
  messageId: message.messageId
616
646
  };
647
+ case "blob-chunk-request":
648
+ return {
649
+ type: message.type,
650
+ messageId: message.messageId,
651
+ requestId: message.requestId,
652
+ hash: message.hash
653
+ };
654
+ case "blob-chunk-response":
655
+ return {
656
+ type: message.type,
657
+ messageId: message.messageId,
658
+ requestId: message.requestId,
659
+ hasBytes: message.bytes !== null,
660
+ ...message.bytes !== null ? { chunkBytes: message.bytes } : {}
661
+ };
662
+ case "blob-chunk-push":
663
+ return {
664
+ type: message.type,
665
+ messageId: message.messageId,
666
+ hash: message.hash,
667
+ chunkBytes: message.bytes
668
+ };
617
669
  }
618
670
  }
619
671
  function fromProtoEnvelope(envelope) {
@@ -643,7 +695,11 @@ function fromProtoEnvelope(envelope) {
643
695
  schemaVersion: envelope.schemaVersion ?? 0,
644
696
  accepted: envelope.accepted ?? false,
645
697
  rejectReason: envelope.rejectReason,
646
- selectedWireFormat: envelope.selectedWireFormat === "json" || envelope.selectedWireFormat === "protobuf" ? envelope.selectedWireFormat : void 0
698
+ selectedWireFormat: envelope.selectedWireFormat === "json" || envelope.selectedWireFormat === "protobuf" ? envelope.selectedWireFormat : void 0,
699
+ // Preserve the server's wall-clock time so clock-skew detection and
700
+ // automatic timestamp rebase work over the protobuf wire, not just JSON.
701
+ ...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {},
702
+ ...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {}
647
703
  };
648
704
  case "operation-batch":
649
705
  return {
@@ -668,6 +724,27 @@ function fromProtoEnvelope(envelope) {
668
724
  message: envelope.errorMessage ?? "Unknown error",
669
725
  retriable: envelope.retriable ?? false
670
726
  };
727
+ case "blob-chunk-request":
728
+ return {
729
+ type: "blob-chunk-request",
730
+ messageId: envelope.messageId,
731
+ requestId: envelope.requestId ?? "",
732
+ hash: envelope.hash ?? ""
733
+ };
734
+ case "blob-chunk-response":
735
+ return {
736
+ type: "blob-chunk-response",
737
+ messageId: envelope.messageId,
738
+ requestId: envelope.requestId ?? "",
739
+ bytes: envelope.hasBytes ? envelope.chunkBytes ?? "" : null
740
+ };
741
+ case "blob-chunk-push":
742
+ return {
743
+ type: "blob-chunk-push",
744
+ messageId: envelope.messageId,
745
+ hash: envelope.hash ?? "",
746
+ bytes: envelope.chunkBytes ?? ""
747
+ };
671
748
  default:
672
749
  throw new import_core2.SyncError("Failed to decode sync message: unknown protobuf type", {
673
750
  type: envelope.type
@@ -812,6 +889,15 @@ function encodeEnvelope(envelope) {
812
889
  writer.uint32(138).string(envelope.errorMessage);
813
890
  }
814
891
  if (envelope.retriable !== void 0) writer.uint32(144).bool(envelope.retriable);
892
+ if (envelope.serverTime !== void 0) writer.uint32(152).int64(envelope.serverTime);
893
+ if (envelope.requestId && envelope.requestId.length > 0)
894
+ writer.uint32(162).string(envelope.requestId);
895
+ if (envelope.hash && envelope.hash.length > 0) writer.uint32(170).string(envelope.hash);
896
+ if (envelope.chunkBytes && envelope.chunkBytes.length > 0)
897
+ writer.uint32(178).string(envelope.chunkBytes);
898
+ if (envelope.hasBytes !== void 0) writer.uint32(184).bool(envelope.hasBytes);
899
+ if (envelope.blobStorageEnabled !== void 0)
900
+ writer.uint32(200).bool(envelope.blobStorageEnabled);
815
901
  return writer.finish();
816
902
  }
817
903
  function decodeEnvelope(bytes) {
@@ -880,6 +966,24 @@ function decodeEnvelope(bytes) {
880
966
  case 18:
881
967
  envelope.retriable = reader.bool();
882
968
  break;
969
+ case 19:
970
+ envelope.serverTime = longToNumber(reader.int64());
971
+ break;
972
+ case 20:
973
+ envelope.requestId = reader.string();
974
+ break;
975
+ case 21:
976
+ envelope.hash = reader.string();
977
+ break;
978
+ case 22:
979
+ envelope.chunkBytes = reader.string();
980
+ break;
981
+ case 23:
982
+ envelope.hasBytes = reader.bool();
983
+ break;
984
+ case 25:
985
+ envelope.blobStorageEnabled = reader.bool();
986
+ break;
883
987
  default:
884
988
  reader.skipType(tag & 7);
885
989
  }
@@ -1381,7 +1485,7 @@ var ChaosTransport = class {
1381
1485
  random;
1382
1486
  messageHandler = null;
1383
1487
  reorderBuffer = [];
1384
- timers = [];
1488
+ pending = [];
1385
1489
  constructor(inner, config) {
1386
1490
  this.inner = inner;
1387
1491
  this.dropRate = config?.dropRate ?? 0;
@@ -1396,10 +1500,14 @@ var ChaosTransport = class {
1396
1500
  }
1397
1501
  async disconnect() {
1398
1502
  this.flushReorderBuffer();
1399
- for (const timer of this.timers) {
1503
+ const flushed = this.pending;
1504
+ this.pending = [];
1505
+ for (const { timer } of flushed) {
1400
1506
  clearTimeout(timer);
1401
1507
  }
1402
- this.timers = [];
1508
+ for (const { message } of flushed) {
1509
+ this.deliverIncoming(message);
1510
+ }
1403
1511
  return this.inner.disconnect();
1404
1512
  }
1405
1513
  send(message) {
@@ -1436,9 +1544,10 @@ var ChaosTransport = class {
1436
1544
  if (this.maxLatency > 0) {
1437
1545
  const delay = Math.floor(this.random() * this.maxLatency);
1438
1546
  const timer = setTimeout(() => {
1547
+ this.pending = this.pending.filter((p) => p.timer !== timer);
1439
1548
  this.deliverIncoming(message);
1440
1549
  }, delay);
1441
- this.timers.push(timer);
1550
+ this.pending.push({ timer, message });
1442
1551
  return;
1443
1552
  }
1444
1553
  this.deliverIncoming(message);
@@ -1466,7 +1575,7 @@ var ChaosTransport = class {
1466
1575
  };
1467
1576
 
1468
1577
  // src/engine/sync-engine.ts
1469
- var import_core6 = require("@korajs/core");
1578
+ var import_core7 = require("@korajs/core");
1470
1579
  var import_internal2 = require("@korajs/core/internal");
1471
1580
 
1472
1581
  // src/awareness/awareness-manager.ts
@@ -1675,6 +1784,95 @@ var AwarenessManager = class {
1675
1784
  }
1676
1785
  };
1677
1786
 
1787
+ // src/blob/blob-chunk-channel.ts
1788
+ var import_core5 = require("@korajs/core");
1789
+ function encodeBlobChunkBytes(bytes) {
1790
+ let binary = "";
1791
+ for (let i = 0; i < bytes.length; i++) {
1792
+ binary += String.fromCharCode(bytes[i]);
1793
+ }
1794
+ return btoa(binary);
1795
+ }
1796
+ function decodeBlobChunkBytes(encoded) {
1797
+ const binary = atob(encoded);
1798
+ const out = new Uint8Array(binary.length);
1799
+ for (let i = 0; i < binary.length; i++) {
1800
+ out[i] = binary.charCodeAt(i);
1801
+ }
1802
+ return out;
1803
+ }
1804
+ var BlobChunkChannel = class {
1805
+ onSend;
1806
+ handlers = /* @__PURE__ */ new Set();
1807
+ constructor(options) {
1808
+ this.onSend = options?.onSend ?? null;
1809
+ }
1810
+ /**
1811
+ * Send a blob-chunk message to the peer. Response bytes are base64-encoded
1812
+ * onto the wire here; callers pass a `Uint8Array`.
1813
+ */
1814
+ send(message) {
1815
+ if (!this.onSend) {
1816
+ return;
1817
+ }
1818
+ if (message.type === "blob-chunk-request") {
1819
+ this.onSend({
1820
+ type: "blob-chunk-request",
1821
+ messageId: (0, import_core5.generateUUIDv7)(),
1822
+ requestId: message.requestId,
1823
+ hash: message.hash
1824
+ });
1825
+ return;
1826
+ }
1827
+ if (message.type === "blob-chunk-push") {
1828
+ this.onSend({
1829
+ type: "blob-chunk-push",
1830
+ messageId: (0, import_core5.generateUUIDv7)(),
1831
+ hash: message.hash,
1832
+ bytes: encodeBlobChunkBytes(message.bytes)
1833
+ });
1834
+ return;
1835
+ }
1836
+ this.onSend({
1837
+ type: "blob-chunk-response",
1838
+ messageId: (0, import_core5.generateUUIDv7)(),
1839
+ requestId: message.requestId,
1840
+ bytes: message.bytes === null ? null : encodeBlobChunkBytes(message.bytes)
1841
+ });
1842
+ }
1843
+ /**
1844
+ * Register a handler for inbound blob-chunk messages. Multiple handlers are
1845
+ * allowed; each receives every delivered message.
1846
+ */
1847
+ onMessage(handler) {
1848
+ this.handlers.add(handler);
1849
+ return () => {
1850
+ this.handlers.delete(handler);
1851
+ };
1852
+ }
1853
+ /**
1854
+ * Deliver an inbound wire message to registered handlers, decoding response
1855
+ * bytes from base64 back to a `Uint8Array` first.
1856
+ */
1857
+ deliver(message) {
1858
+ if (this.handlers.size === 0) {
1859
+ return;
1860
+ }
1861
+ const decoded = message.type === "blob-chunk-request" ? { type: "blob-chunk-request", requestId: message.requestId, hash: message.hash } : message.type === "blob-chunk-push" ? {
1862
+ type: "blob-chunk-push",
1863
+ hash: message.hash,
1864
+ bytes: decodeBlobChunkBytes(message.bytes)
1865
+ } : {
1866
+ type: "blob-chunk-response",
1867
+ requestId: message.requestId,
1868
+ bytes: message.bytes === null ? null : decodeBlobChunkBytes(message.bytes)
1869
+ };
1870
+ for (const handler of this.handlers) {
1871
+ handler(decoded);
1872
+ }
1873
+ }
1874
+ };
1875
+
1678
1876
  // src/diagnostics/bandwidth-estimator.ts
1679
1877
  var BandwidthEstimator = class {
1680
1878
  maxSamples;
@@ -2118,7 +2316,7 @@ var SyncMetricsCollector = class {
2118
2316
  };
2119
2317
 
2120
2318
  // src/richtext/richtext-doc-channel.ts
2121
- var import_core5 = require("@korajs/core");
2319
+ var import_core6 = require("@korajs/core");
2122
2320
 
2123
2321
  // src/richtext/doc-channel-wire.ts
2124
2322
  function encodeYjsUpdate(update) {
@@ -2197,7 +2395,7 @@ var RichtextDocChannel = class {
2197
2395
  }
2198
2396
  this.onSend({
2199
2397
  type: "yjs-doc-update",
2200
- messageId: (0, import_core5.generateUUIDv7)(),
2398
+ messageId: (0, import_core6.generateUUIDv7)(),
2201
2399
  collection,
2202
2400
  recordId,
2203
2401
  field,
@@ -2345,6 +2543,41 @@ var OutboundQueue = class {
2345
2543
  peek(count) {
2346
2544
  return this.queue.slice(0, count);
2347
2545
  }
2546
+ /**
2547
+ * All queued operations (not counting in-flight), in causal order.
2548
+ */
2549
+ getAll() {
2550
+ return [...this.queue];
2551
+ }
2552
+ /**
2553
+ * Atomically replace the entire queue with a new set of operations.
2554
+ *
2555
+ * Used after a timestamp rebase rewrote the queued operations under new
2556
+ * content-addressed ids: the old entries must vanish from memory AND from
2557
+ * persistent storage in one step, or a page refresh could resurrect
2558
+ * stale-stamped ops the server would reject. Resets the seen set to exactly
2559
+ * the new ids so the rewritten ops are not treated as duplicates.
2560
+ */
2561
+ async replaceAll(ops) {
2562
+ const removeIds = this.queue.map((op) => op.id);
2563
+ for (const batch of this.inFlight.values()) {
2564
+ for (const op of batch) {
2565
+ removeIds.push(op.id);
2566
+ }
2567
+ }
2568
+ this.inFlight.clear();
2569
+ this.queue = ops.length > 1 ? (0, import_internal.topologicalSort)([...ops]) : [...ops];
2570
+ this.seen.clear();
2571
+ for (const op of this.queue) {
2572
+ this.seen.add(op.id);
2573
+ }
2574
+ if (removeIds.length > 0) {
2575
+ await this.storage.dequeue(removeIds);
2576
+ }
2577
+ for (const op of this.queue) {
2578
+ await this.storage.enqueue(op);
2579
+ }
2580
+ }
2348
2581
  /**
2349
2582
  * Whether initialize() has been called.
2350
2583
  */
@@ -2394,6 +2627,7 @@ var SyncEngine = class {
2394
2627
  encryptor;
2395
2628
  awarenessManager;
2396
2629
  richtextDocChannel;
2630
+ blobChunkChannel;
2397
2631
  metricsCollector;
2398
2632
  syncState;
2399
2633
  remoteVector = /* @__PURE__ */ new Map();
@@ -2406,6 +2640,9 @@ var SyncEngine = class {
2406
2640
  currentBatch = null;
2407
2641
  reconnecting = false;
2408
2642
  schemaBlocked = false;
2643
+ clockBlocked = false;
2644
+ clockSkewMs = null;
2645
+ blobStorageEnabled = false;
2409
2646
  // Track delta exchange state
2410
2647
  deltaBatchesReceived = 0;
2411
2648
  deltaReceiveComplete = false;
@@ -2454,6 +2691,14 @@ var SyncEngine = class {
2454
2691
  this.transport.send(message);
2455
2692
  }
2456
2693
  });
2694
+ this.blobChunkChannel = new BlobChunkChannel({
2695
+ onSend: (message) => {
2696
+ if (this.state !== "streaming") {
2697
+ return;
2698
+ }
2699
+ this.transport.send(message);
2700
+ }
2701
+ });
2457
2702
  this.awarenessManager.onSend((message) => {
2458
2703
  if (this.state !== "streaming") return;
2459
2704
  const wireMessage = {
@@ -2470,7 +2715,7 @@ var SyncEngine = class {
2470
2715
  */
2471
2716
  async start() {
2472
2717
  if (this.state !== "disconnected") {
2473
- throw new import_core6.SyncError("Cannot start sync engine: not in disconnected state", {
2718
+ throw new import_core7.SyncError("Cannot start sync engine: not in disconnected state", {
2474
2719
  currentState: this.state
2475
2720
  });
2476
2721
  }
@@ -2487,7 +2732,7 @@ var SyncEngine = class {
2487
2732
  this.transport.onClose((reason) => this.handleTransportClose(reason));
2488
2733
  this.transport.onError((err) => this.handleTransportError(err));
2489
2734
  if (this.schemaBlocked) {
2490
- throw new import_core6.SyncError(
2735
+ throw new import_core7.SyncError(
2491
2736
  "Sync is blocked due to schema version mismatch. Upgrade the app schema or align sync.schemaVersion with the server.",
2492
2737
  { code: "SCHEMA_MISMATCH_BLOCKED" }
2493
2738
  );
@@ -2579,10 +2824,17 @@ var SyncEngine = class {
2579
2824
  lastSyncedAt: this.lastSyncedAt,
2580
2825
  lastSuccessfulPush: this.lastSuccessfulPush,
2581
2826
  lastSuccessfulPull: this.lastSuccessfulPull,
2582
- conflicts: this.conflictCount
2827
+ conflicts: this.conflictCount,
2828
+ clockSkewMs: this.clockSkewMs
2583
2829
  };
2584
2830
  switch (this.state) {
2585
2831
  case "disconnected":
2832
+ if (this.clockBlocked) {
2833
+ return { ...base, status: "clock-error" };
2834
+ }
2835
+ if (this.schemaBlocked) {
2836
+ return { ...base, status: "schema-mismatch" };
2837
+ }
2586
2838
  return { ...base, status: "offline" };
2587
2839
  case "connecting":
2588
2840
  case "handshaking":
@@ -2591,6 +2843,9 @@ var SyncEngine = class {
2591
2843
  case "streaming":
2592
2844
  return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
2593
2845
  case "error":
2846
+ if (this.clockBlocked) {
2847
+ return { ...base, status: "clock-error" };
2848
+ }
2594
2849
  return { ...base, status: this.schemaBlocked ? "schema-mismatch" : "error" };
2595
2850
  }
2596
2851
  }
@@ -2601,6 +2856,27 @@ var SyncEngine = class {
2601
2856
  isSchemaBlocked() {
2602
2857
  return this.schemaBlocked;
2603
2858
  }
2859
+ /**
2860
+ * True when sync is blocked because this device's clock is too far ahead.
2861
+ * Local writes continue to work and queue; only sync is paused.
2862
+ */
2863
+ isClockBlocked() {
2864
+ return this.clockBlocked;
2865
+ }
2866
+ /** serverTime - localTime measured at the last handshake, or null before first connect. */
2867
+ getClockSkewMs() {
2868
+ return this.clockSkewMs;
2869
+ }
2870
+ /**
2871
+ * Clears the clock block after the user corrects the device clock.
2872
+ * Moves the engine back to `disconnected` so `start()` can run again.
2873
+ */
2874
+ clearClockBlock() {
2875
+ this.clockBlocked = false;
2876
+ if (this.state === "error") {
2877
+ this.transitionTo("disconnected");
2878
+ }
2879
+ }
2604
2880
  /**
2605
2881
  * Clears schema-mismatch block after upgrading the local schema / sync config.
2606
2882
  * Moves the engine back to `disconnected` so `start()` can run again.
@@ -2626,7 +2902,7 @@ var SyncEngine = class {
2626
2902
  return this.getStatus().pendingOperations;
2627
2903
  }
2628
2904
  emitApplyFailure(op, result, overrides) {
2629
- const reason = (0, import_core6.defaultApplyFailureReason)(result, overrides);
2905
+ const reason = (0, import_core7.defaultApplyFailureReason)(result, overrides);
2630
2906
  this.emitter?.emit({
2631
2907
  type: "sync:apply-failed",
2632
2908
  operationId: op.id,
@@ -2736,6 +3012,31 @@ var SyncEngine = class {
2736
3012
  getRichtextDocChannel() {
2737
3013
  return this.richtextDocChannel;
2738
3014
  }
3015
+ /**
3016
+ * Side channel for out-of-band blob chunk transfer over the sync connection.
3017
+ * The app layer bridges this to a `ChunkMessagePort` to pull/serve blob bytes.
3018
+ */
3019
+ getBlobChunkChannel() {
3020
+ return this.blobChunkChannel;
3021
+ }
3022
+ /**
3023
+ * Whether the connected server persists blob bytes centrally (learned from the
3024
+ * handshake response). When true, the app uploads the bytes behind `blob`
3025
+ * fields so they stay available after the authoring device goes offline.
3026
+ */
3027
+ isBlobStorageEnabled() {
3028
+ return this.blobStorageEnabled;
3029
+ }
3030
+ /**
3031
+ * Upload a blob chunk (or manifest) to the server for central persistence.
3032
+ * A no-op unless connected and the server advertised blob storage.
3033
+ */
3034
+ uploadBlobChunk(hash, bytes) {
3035
+ if (this.state !== "streaming" || !this.blobStorageEnabled) {
3036
+ return;
3037
+ }
3038
+ this.blobChunkChannel.send({ type: "blob-chunk-push", hash, bytes });
3039
+ }
2739
3040
  // --- Private methods ---
2740
3041
  messageChain = Promise.resolve();
2741
3042
  enqueueMessage(message) {
@@ -2744,7 +3045,7 @@ var SyncEngine = class {
2744
3045
  async handleMessageAsync(message) {
2745
3046
  switch (message.type) {
2746
3047
  case "handshake-response":
2747
- this.handleHandshakeResponse(message);
3048
+ await this.handleHandshakeResponse(message);
2748
3049
  break;
2749
3050
  case "operation-batch":
2750
3051
  await this.handleOperationBatch(message);
@@ -2761,14 +3062,53 @@ var SyncEngine = class {
2761
3062
  case "yjs-doc-update":
2762
3063
  this.richtextDocChannel.deliver(message);
2763
3064
  break;
3065
+ case "blob-chunk-request":
3066
+ case "blob-chunk-response":
3067
+ case "blob-chunk-push":
3068
+ this.blobChunkChannel.deliver(message);
3069
+ break;
2764
3070
  }
2765
3071
  }
2766
3072
  handleMessageFailure(error) {
2767
3073
  const reason = error instanceof Error ? error.message : "Message handling failed";
2768
3074
  this.handleTransportClose(reason);
2769
3075
  }
2770
- handleHandshakeResponse(msg) {
3076
+ /**
3077
+ * Compares server wall-clock time from the handshake with local time.
3078
+ * Negative skew = this device's clock is fast (dangerous for LWW and rejected
3079
+ * by server ingest beyond 60s). Positive skew = slow (accepted but surfaced).
3080
+ * Zero developer work required: the result flows into sync status and events.
3081
+ */
3082
+ evaluateClockSkew(serverTime) {
3083
+ const skewMs = serverTime - Date.now();
3084
+ this.clockSkewMs = skewMs;
3085
+ const FAST_BLOCK_MS = 6e4;
3086
+ const SLOW_WARN_MS = 10 * 6e4;
3087
+ let severity = "info";
3088
+ if (skewMs < -FAST_BLOCK_MS) {
3089
+ severity = "fast-blocked";
3090
+ } else if (skewMs > SLOW_WARN_MS) {
3091
+ severity = "slow-warning";
3092
+ }
3093
+ this.emitter?.emit({ type: "sync:clock-skew", skewMs, severity, source: "handshake" });
3094
+ if (severity === "fast-blocked") {
3095
+ this.clockBlocked = true;
3096
+ } else {
3097
+ this.clockBlocked = false;
3098
+ }
3099
+ }
3100
+ async handleHandshakeResponse(msg) {
2771
3101
  if (this.state !== "handshaking") return;
3102
+ this.blobStorageEnabled = msg.blobStorageEnabled === true;
3103
+ if (typeof msg.serverTime === "number") {
3104
+ this.evaluateClockSkew(msg.serverTime);
3105
+ if (this.clockBlocked) {
3106
+ this.metricsCollector.updateStatus("error");
3107
+ this.transitionTo("error");
3108
+ void this.transport.disconnect();
3109
+ return;
3110
+ }
3111
+ }
2772
3112
  if (!msg.accepted) {
2773
3113
  const reason = msg.rejectReason ?? "Handshake rejected";
2774
3114
  if (isSchemaMismatchReject(msg.rejectReason)) {
@@ -2815,8 +3155,51 @@ var SyncEngine = class {
2815
3155
  this.deltaSentOpIds = [];
2816
3156
  this.pendingDeltaBatchAcks.clear();
2817
3157
  this.initialSyncTotalBatches = 0;
3158
+ if (typeof msg.serverTime === "number") {
3159
+ await this.maybeRebaseQueuedOperations(msg.serverTime);
3160
+ }
2818
3161
  this.sendDelta();
2819
3162
  }
3163
+ /**
3164
+ * Re-stamps queued (never-acknowledged) operations whose timestamps are far
3165
+ * enough in the future that the server would reject them, using the server's
3166
+ * own handshake time as the trusted "now". This is the automatic recovery
3167
+ * path after a user corrects a fast device clock: the queue drains
3168
+ * immediately instead of waiting for real time to catch up.
3169
+ */
3170
+ async maybeRebaseQueuedOperations(serverTime) {
3171
+ const rebase = this.store.rebaseUnsyncedOperations?.bind(this.store);
3172
+ if (!rebase) return;
3173
+ const queued = this.outboundQueue.getAll();
3174
+ if (queued.length === 0) return;
3175
+ let maxQueuedWallTime = Number.NEGATIVE_INFINITY;
3176
+ for (const op of queued) {
3177
+ if (op.timestamp.wallTime > maxQueuedWallTime) {
3178
+ maxQueuedWallTime = op.timestamp.wallTime;
3179
+ }
3180
+ }
3181
+ const SERVER_FUTURE_TOLERANCE_MS = 6e4;
3182
+ if (maxQueuedWallTime <= serverTime + SERVER_FUTURE_TOLERANCE_MS) return;
3183
+ try {
3184
+ const result = await rebase(
3185
+ queued.map((op) => op.id),
3186
+ serverTime
3187
+ );
3188
+ await this.outboundQueue.replaceAll(result.operations);
3189
+ this.emitter?.emit({
3190
+ type: "sync:clock-rebase",
3191
+ rebasedCount: result.rebasedCount,
3192
+ maxSkewMs: maxQueuedWallTime - serverTime
3193
+ });
3194
+ } catch (error) {
3195
+ this.emitter?.emit({
3196
+ type: "store:persistence-error",
3197
+ dbName: "kora-oplog",
3198
+ message: error instanceof Error ? error.message : "Timestamp rebase failed",
3199
+ code: "CLOCK_REBASE_FAILED"
3200
+ });
3201
+ }
3202
+ }
2820
3203
  async sendDelta() {
2821
3204
  const localVector = this.store.getVersionVector();
2822
3205
  const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
@@ -2901,7 +3284,7 @@ var SyncEngine = class {
2901
3284
  const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
2902
3285
  const transforms = this.config.operationTransforms ?? [];
2903
3286
  for (const op of inScopeOps) {
2904
- const transformed = transforms.length > 0 ? (0, import_core6.applyOperationTransforms)(op, targetSchemaVersion, transforms) : op;
3287
+ const transformed = transforms.length > 0 ? (0, import_core7.applyOperationTransforms)(op, targetSchemaVersion, transforms) : op;
2905
3288
  if (transformed === null) {
2906
3289
  continue;
2907
3290
  }
@@ -2911,20 +3294,20 @@ var SyncEngine = class {
2911
3294
  this.emitApplyFailure(transformed, result);
2912
3295
  }
2913
3296
  } catch (error) {
2914
- if (error instanceof import_core6.ClockDriftError) {
3297
+ if (error instanceof import_core7.ClockDriftError) {
2915
3298
  this.emitApplyFailure(transformed, "rejected", {
2916
- code: import_core6.APPLY_FAILURE_CODES.CLOCK_DRIFT,
3299
+ code: import_core7.APPLY_FAILURE_CODES.CLOCK_DRIFT,
2917
3300
  message: error.message,
2918
3301
  retriable: false
2919
3302
  });
2920
3303
  continue;
2921
3304
  }
2922
3305
  const message = error instanceof Error ? error.message : "Apply failed";
2923
- const code = error instanceof import_core6.SyncError ? error.code : error instanceof import_core6.KoraError ? error.code : error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : import_core6.APPLY_FAILURE_CODES.APPLY_FAILED;
3306
+ 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;
2924
3307
  this.emitApplyFailure(transformed, "rejected", {
2925
3308
  code,
2926
3309
  message,
2927
- retriable: code !== import_core6.APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY
3310
+ retriable: code !== import_core7.APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY
2928
3311
  });
2929
3312
  }
2930
3313
  }
@@ -2996,6 +3379,17 @@ var SyncEngine = class {
2996
3379
  if (msg.code === "AUTH_FAILED") {
2997
3380
  this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
2998
3381
  }
3382
+ if (msg.code === "INVALID_TIMESTAMP") {
3383
+ this.clockBlocked = true;
3384
+ this.emitter?.emit({
3385
+ type: "sync:clock-skew",
3386
+ skewMs: this.clockSkewMs ?? Number.NaN,
3387
+ severity: "fast-blocked",
3388
+ source: "server-reject"
3389
+ });
3390
+ this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
3391
+ return;
3392
+ }
2999
3393
  this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
3000
3394
  this.transitionTo("disconnected");
3001
3395
  }
@@ -3183,7 +3577,7 @@ var SyncEngine = class {
3183
3577
  transitionTo(newState) {
3184
3578
  const validTargets = VALID_TRANSITIONS[this.state];
3185
3579
  if (!validTargets.includes(newState)) {
3186
- throw new import_core6.SyncError(`Invalid sync state transition: ${this.state} \u2192 ${newState}`, {
3580
+ throw new import_core7.SyncError(`Invalid sync state transition: ${this.state} \u2192 ${newState}`, {
3187
3581
  from: this.state,
3188
3582
  to: newState
3189
3583
  });
@@ -3465,18 +3859,18 @@ var ReconnectionManager = class {
3465
3859
  };
3466
3860
 
3467
3861
  // src/encryption/sync-encryptor.ts
3468
- var import_core8 = require("@korajs/core");
3862
+ var import_core9 = require("@korajs/core");
3469
3863
 
3470
3864
  // src/encryption/key-derivation.ts
3471
- var import_core7 = require("@korajs/core");
3472
- var KeyDerivationError = class extends import_core7.SyncError {
3865
+ var import_core8 = require("@korajs/core");
3866
+ var KeyDerivationError = class extends import_core8.SyncError {
3473
3867
  constructor(message, context) {
3474
3868
  super(message, { ...context, errorType: "KEY_DERIVATION_ERROR" });
3475
3869
  this.name = "KeyDerivationError";
3476
3870
  }
3477
3871
  };
3478
3872
  var SALT_LENGTH = 32;
3479
- var PBKDF2_ITERATIONS = 6e5;
3873
+ var DEFAULT_PBKDF2_ITERATIONS = 6e5;
3480
3874
  var DERIVED_KEY_LENGTH = 256;
3481
3875
  function assertCryptoAvailable() {
3482
3876
  if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
@@ -3493,13 +3887,19 @@ function generateSalt() {
3493
3887
  }
3494
3888
  return globalThis.crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
3495
3889
  }
3496
- async function deriveKey(passphrase, salt) {
3890
+ async function deriveKey(passphrase, salt, iterations = DEFAULT_PBKDF2_ITERATIONS) {
3497
3891
  assertCryptoAvailable();
3498
3892
  if (passphrase.length === 0) {
3499
3893
  throw new KeyDerivationError(
3500
3894
  "Passphrase must not be empty. Provide a non-empty string for encryption key derivation."
3501
3895
  );
3502
3896
  }
3897
+ if (!Number.isInteger(iterations) || iterations < 1) {
3898
+ throw new KeyDerivationError(
3899
+ `PBKDF2 iteration count must be a positive integer, received: ${iterations}`,
3900
+ { iterations }
3901
+ );
3902
+ }
3503
3903
  const usedSalt = salt ?? generateSalt();
3504
3904
  try {
3505
3905
  const passphraseBytes = new TextEncoder().encode(passphrase);
@@ -3514,7 +3914,7 @@ async function deriveKey(passphrase, salt) {
3514
3914
  {
3515
3915
  name: "PBKDF2",
3516
3916
  salt: usedSalt,
3517
- iterations: PBKDF2_ITERATIONS,
3917
+ iterations,
3518
3918
  hash: "SHA-256"
3519
3919
  },
3520
3920
  baseKey,
@@ -3534,24 +3934,24 @@ async function deriveKey(passphrase, salt) {
3534
3934
  );
3535
3935
  }
3536
3936
  }
3537
- async function deriveVersionedKey(passphrase, version, salt) {
3937
+ async function deriveVersionedKey(passphrase, version, salt, iterations = DEFAULT_PBKDF2_ITERATIONS) {
3538
3938
  if (!Number.isInteger(version) || version < 1) {
3539
3939
  throw new KeyDerivationError(`Key version must be a positive integer, received: ${version}`, {
3540
3940
  version
3541
3941
  });
3542
3942
  }
3543
- const { key, salt: usedSalt } = await deriveKey(passphrase, salt);
3943
+ const { key, salt: usedSalt } = await deriveKey(passphrase, salt, iterations);
3544
3944
  return { version, key, salt: usedSalt };
3545
3945
  }
3546
3946
 
3547
3947
  // src/encryption/sync-encryptor.ts
3548
- var EncryptionError = class extends import_core8.SyncError {
3948
+ var EncryptionError = class extends import_core9.SyncError {
3549
3949
  constructor(message, context) {
3550
3950
  super(message, { ...context, errorType: "ENCRYPTION_ERROR" });
3551
3951
  this.name = "EncryptionError";
3552
3952
  }
3553
3953
  };
3554
- var DecryptionError = class extends import_core8.SyncError {
3954
+ var DecryptionError = class extends import_core9.SyncError {
3555
3955
  constructor(message, context) {
3556
3956
  super(message, { ...context, errorType: "DECRYPTION_ERROR" });
3557
3957
  this.name = "DecryptionError";
@@ -3580,11 +3980,13 @@ var SyncEncryptor = class _SyncEncryptor {
3580
3980
  * @param config - Encryption configuration with passphrase
3581
3981
  * @param salt - Optional salt for deterministic key derivation (mainly for testing).
3582
3982
  * If omitted, a random salt is generated.
3983
+ * @param iterations - Optional PBKDF2 iteration count. Defaults to the
3984
+ * production-strength value. Lower it only in tests.
3583
3985
  * @returns A configured SyncEncryptor instance
3584
3986
  * @throws {EncryptionError} If configuration is invalid
3585
3987
  * @throws {KeyDerivationError} If key derivation fails
3586
3988
  */
3587
- static async create(config, salt) {
3989
+ static async create(config, salt, iterations) {
3588
3990
  if (!config.enabled) {
3589
3991
  throw new EncryptionError(
3590
3992
  "Cannot create SyncEncryptor with encryption disabled. Set enabled: true in the encryption config."
@@ -3596,7 +3998,7 @@ var SyncEncryptor = class _SyncEncryptor {
3596
3998
  "Encryption key/passphrase must not be empty. Provide a non-empty string or key provider function."
3597
3999
  );
3598
4000
  }
3599
- const versionedKey = await deriveVersionedKey(passphrase, 1, salt);
4001
+ const versionedKey = await deriveVersionedKey(passphrase, 1, salt, iterations);
3600
4002
  const keys = /* @__PURE__ */ new Map();
3601
4003
  keys.set(1, versionedKey);
3602
4004
  return new _SyncEncryptor(keys, 1);
@@ -3897,10 +4299,12 @@ var OFFLINE_SYNC_STATUS = Object.freeze({
3897
4299
  lastSyncedAt: null,
3898
4300
  lastSuccessfulPush: null,
3899
4301
  lastSuccessfulPull: null,
3900
- conflicts: 0
4302
+ conflicts: 0,
4303
+ clockSkewMs: null
3901
4304
  });
3902
4305
  var SYNC_STATUS_EVENT_TYPES = [
3903
4306
  "sync:connected",
4307
+ "sync:clock-skew",
3904
4308
  "sync:disconnected",
3905
4309
  "sync:schema-mismatch",
3906
4310
  "sync:auth-failed",
@@ -4044,6 +4448,7 @@ function subscribeRemoteAwarenessStates(awareness, listener) {
4044
4448
  // Annotate the CommonJS export names for ESM import in node:
4045
4449
  0 && (module.exports = {
4046
4450
  AwarenessManager,
4451
+ BlobChunkChannel,
4047
4452
  ChaosTransport,
4048
4453
  ConnectionMonitor,
4049
4454
  DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD,
@@ -4068,11 +4473,13 @@ function subscribeRemoteAwarenessStates(awareness, listener) {
4068
4473
  WebSocketTransport,
4069
4474
  createDeltaCursorFromBatch,
4070
4475
  createSyncStatusController,
4476
+ decodeBlobChunkBytes,
4071
4477
  decodeDeltaCursor,
4072
4478
  decodeYjsUpdate,
4073
4479
  dedupeQuerySubsets,
4074
4480
  deriveKey,
4075
4481
  deriveVersionedKey,
4482
+ encodeBlobChunkBytes,
4076
4483
  encodeDeltaCursor,
4077
4484
  encodeYjsUpdate,
4078
4485
  filterOperationsByScope,
@@ -4080,6 +4487,9 @@ function subscribeRemoteAwarenessStates(awareness, listener) {
4080
4487
  getRemoteAwarenessStates,
4081
4488
  isAcknowledgmentMessage,
4082
4489
  isAwarenessUpdateMessage,
4490
+ isBlobChunkPushMessage,
4491
+ isBlobChunkRequestMessage,
4492
+ isBlobChunkResponseMessage,
4083
4493
  isClientSchemaVersionSupported,
4084
4494
  isEncryptedPayload,
4085
4495
  isErrorMessage,