@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/LICENSE +21 -0
- package/dist/index.cjs +444 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +154 -7
- package/dist/index.d.ts +154 -7
- package/dist/index.js +423 -19
- package/dist/index.js.map +1 -1
- package/dist/internal.d.cts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/{transport-BrdBj7rH.d.cts → transport-CZLMdZ0k.d.cts} +70 -3
- package/dist/{transport-BrdBj7rH.d.ts → transport-CZLMdZ0k.d.ts} +70 -3
- package/package.json +4 -3
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
|
-
|
|
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
|
-
|
|
1413
|
+
const flushed = this.pending;
|
|
1414
|
+
this.pending = [];
|
|
1415
|
+
for (const { timer } of flushed) {
|
|
1316
1416
|
clearTimeout(timer);
|
|
1317
1417
|
}
|
|
1318
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|