@korajs/sync 1.0.0-beta.0 → 1.0.0-beta.10
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 +837 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +228 -3
- package/dist/index.d.ts +228 -3
- package/dist/index.js +816 -55
- package/dist/index.js.map +1 -1
- package/dist/internal.d.cts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/{transport-BMesMB6s.d.cts → transport-B2GQerqJ.d.cts} +200 -3
- package/dist/{transport-BMesMB6s.d.ts → transport-B2GQerqJ.d.ts} +200 -3
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ var SYNC_STATES = [
|
|
|
14
14
|
];
|
|
15
15
|
var SYNC_STATUSES = [
|
|
16
16
|
"connected",
|
|
17
|
+
"reconnecting",
|
|
17
18
|
"syncing",
|
|
18
19
|
"synced",
|
|
19
20
|
"offline",
|
|
@@ -21,6 +22,20 @@ var SYNC_STATUSES = [
|
|
|
21
22
|
"error",
|
|
22
23
|
"schema-mismatch"
|
|
23
24
|
];
|
|
25
|
+
var MemoryRejectedOperationStorage = class {
|
|
26
|
+
rejected = /* @__PURE__ */ new Map();
|
|
27
|
+
async record(rejected) {
|
|
28
|
+
this.rejected.set(rejected.operationId, rejected);
|
|
29
|
+
}
|
|
30
|
+
async list() {
|
|
31
|
+
return [...this.rejected.values()];
|
|
32
|
+
}
|
|
33
|
+
async remove(operationIds) {
|
|
34
|
+
for (const id of operationIds) {
|
|
35
|
+
this.rejected.delete(id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
24
39
|
var ScopeViolationError = class extends KoraError {
|
|
25
40
|
constructor(operationId, collection, scope, message) {
|
|
26
41
|
super(
|
|
@@ -218,10 +233,18 @@ function isSyncMessage(value) {
|
|
|
218
233
|
return isAcknowledgmentMessage(value);
|
|
219
234
|
case "error":
|
|
220
235
|
return isErrorMessage(value);
|
|
236
|
+
case "operation-rejected":
|
|
237
|
+
return isOperationRejectedMessage(value);
|
|
221
238
|
case "awareness-update":
|
|
222
239
|
return isAwarenessUpdateMessage(value);
|
|
223
240
|
case "yjs-doc-update":
|
|
224
241
|
return isYjsDocUpdateMessage(value);
|
|
242
|
+
case "blob-chunk-request":
|
|
243
|
+
return isBlobChunkRequestMessage(value);
|
|
244
|
+
case "blob-chunk-response":
|
|
245
|
+
return isBlobChunkResponseMessage(value);
|
|
246
|
+
case "blob-chunk-push":
|
|
247
|
+
return isBlobChunkPushMessage(value);
|
|
225
248
|
default:
|
|
226
249
|
return false;
|
|
227
250
|
}
|
|
@@ -251,6 +274,11 @@ function isErrorMessage(value) {
|
|
|
251
274
|
const msg = value;
|
|
252
275
|
return msg.type === "error" && typeof msg.messageId === "string" && typeof msg.code === "string" && typeof msg.message === "string" && typeof msg.retriable === "boolean";
|
|
253
276
|
}
|
|
277
|
+
function isOperationRejectedMessage(value) {
|
|
278
|
+
if (typeof value !== "object" || value === null) return false;
|
|
279
|
+
const msg = value;
|
|
280
|
+
return msg.type === "operation-rejected" && typeof msg.messageId === "string" && typeof msg.operationId === "string" && typeof msg.collection === "string" && typeof msg.recordId === "string" && typeof msg.code === "string" && typeof msg.message === "string" && typeof msg.retriable === "boolean";
|
|
281
|
+
}
|
|
254
282
|
function isAwarenessUpdateMessage(value) {
|
|
255
283
|
if (typeof value !== "object" || value === null) return false;
|
|
256
284
|
const msg = value;
|
|
@@ -261,6 +289,21 @@ function isYjsDocUpdateMessage(value) {
|
|
|
261
289
|
const msg = value;
|
|
262
290
|
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";
|
|
263
291
|
}
|
|
292
|
+
function isBlobChunkRequestMessage(value) {
|
|
293
|
+
if (typeof value !== "object" || value === null) return false;
|
|
294
|
+
const msg = value;
|
|
295
|
+
return msg.type === "blob-chunk-request" && typeof msg.messageId === "string" && typeof msg.requestId === "string" && typeof msg.hash === "string";
|
|
296
|
+
}
|
|
297
|
+
function isBlobChunkResponseMessage(value) {
|
|
298
|
+
if (typeof value !== "object" || value === null) return false;
|
|
299
|
+
const msg = value;
|
|
300
|
+
return msg.type === "blob-chunk-response" && typeof msg.messageId === "string" && typeof msg.requestId === "string" && (typeof msg.bytes === "string" || msg.bytes === null);
|
|
301
|
+
}
|
|
302
|
+
function isBlobChunkPushMessage(value) {
|
|
303
|
+
if (typeof value !== "object" || value === null) return false;
|
|
304
|
+
const msg = value;
|
|
305
|
+
return msg.type === "blob-chunk-push" && typeof msg.messageId === "string" && typeof msg.hash === "string" && typeof msg.bytes === "string";
|
|
306
|
+
}
|
|
264
307
|
|
|
265
308
|
// src/protocol/serializer.ts
|
|
266
309
|
import { SyncError } from "@korajs/core";
|
|
@@ -486,7 +529,8 @@ function toProtoEnvelope(message) {
|
|
|
486
529
|
})),
|
|
487
530
|
schemaVersion: message.schemaVersion,
|
|
488
531
|
authToken: message.authToken,
|
|
489
|
-
supportedWireFormats: message.supportedWireFormats
|
|
532
|
+
supportedWireFormats: message.supportedWireFormats,
|
|
533
|
+
...message.lastDeliverySequence !== void 0 ? { lastDeliverySequence: message.lastDeliverySequence } : {}
|
|
490
534
|
};
|
|
491
535
|
case "handshake-response":
|
|
492
536
|
return {
|
|
@@ -501,7 +545,9 @@ function toProtoEnvelope(message) {
|
|
|
501
545
|
accepted: message.accepted,
|
|
502
546
|
rejectReason: message.rejectReason,
|
|
503
547
|
selectedWireFormat: message.selectedWireFormat,
|
|
504
|
-
serverTime: message.serverTime
|
|
548
|
+
serverTime: message.serverTime,
|
|
549
|
+
blobStorageEnabled: message.blobStorageEnabled,
|
|
550
|
+
...message.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: message.serverMaxDeliverySequence } : {}
|
|
505
551
|
};
|
|
506
552
|
case "operation-batch":
|
|
507
553
|
return {
|
|
@@ -509,14 +555,17 @@ function toProtoEnvelope(message) {
|
|
|
509
555
|
messageId: message.messageId,
|
|
510
556
|
operations: message.operations.map(serializeProtoOperation),
|
|
511
557
|
isFinal: message.isFinal,
|
|
512
|
-
batchIndex: message.batchIndex
|
|
558
|
+
batchIndex: message.batchIndex,
|
|
559
|
+
...message.baseDeliverySequence !== void 0 ? { baseDeliverySequence: message.baseDeliverySequence } : {},
|
|
560
|
+
...message.maxDeliverySequence !== void 0 ? { maxDeliverySequence: message.maxDeliverySequence } : {}
|
|
513
561
|
};
|
|
514
562
|
case "acknowledgment":
|
|
515
563
|
return {
|
|
516
564
|
type: message.type,
|
|
517
565
|
messageId: message.messageId,
|
|
518
566
|
acknowledgedMessageId: message.acknowledgedMessageId,
|
|
519
|
-
lastSequenceNumber: message.lastSequenceNumber
|
|
567
|
+
lastSequenceNumber: message.lastSequenceNumber,
|
|
568
|
+
...message.deliverySequence !== void 0 ? { deliverySequence: message.deliverySequence } : {}
|
|
520
569
|
};
|
|
521
570
|
case "error":
|
|
522
571
|
return {
|
|
@@ -526,12 +575,45 @@ function toProtoEnvelope(message) {
|
|
|
526
575
|
errorMessage: message.message,
|
|
527
576
|
retriable: message.retriable
|
|
528
577
|
};
|
|
578
|
+
case "operation-rejected":
|
|
579
|
+
return {
|
|
580
|
+
type: message.type,
|
|
581
|
+
messageId: message.messageId,
|
|
582
|
+
operationId: message.operationId,
|
|
583
|
+
collection: message.collection,
|
|
584
|
+
recordId: message.recordId,
|
|
585
|
+
errorCode: message.code,
|
|
586
|
+
errorMessage: message.message,
|
|
587
|
+
retriable: message.retriable
|
|
588
|
+
};
|
|
529
589
|
case "awareness-update":
|
|
530
590
|
case "yjs-doc-update":
|
|
531
591
|
return {
|
|
532
592
|
type: message.type,
|
|
533
593
|
messageId: message.messageId
|
|
534
594
|
};
|
|
595
|
+
case "blob-chunk-request":
|
|
596
|
+
return {
|
|
597
|
+
type: message.type,
|
|
598
|
+
messageId: message.messageId,
|
|
599
|
+
requestId: message.requestId,
|
|
600
|
+
hash: message.hash
|
|
601
|
+
};
|
|
602
|
+
case "blob-chunk-response":
|
|
603
|
+
return {
|
|
604
|
+
type: message.type,
|
|
605
|
+
messageId: message.messageId,
|
|
606
|
+
requestId: message.requestId,
|
|
607
|
+
hasBytes: message.bytes !== null,
|
|
608
|
+
...message.bytes !== null ? { chunkBytes: message.bytes } : {}
|
|
609
|
+
};
|
|
610
|
+
case "blob-chunk-push":
|
|
611
|
+
return {
|
|
612
|
+
type: message.type,
|
|
613
|
+
messageId: message.messageId,
|
|
614
|
+
hash: message.hash,
|
|
615
|
+
chunkBytes: message.bytes
|
|
616
|
+
};
|
|
535
617
|
}
|
|
536
618
|
}
|
|
537
619
|
function fromProtoEnvelope(envelope) {
|
|
@@ -548,7 +630,8 @@ function fromProtoEnvelope(envelope) {
|
|
|
548
630
|
authToken: envelope.authToken,
|
|
549
631
|
supportedWireFormats: envelope.supportedWireFormats?.filter(
|
|
550
632
|
(format) => format === "json" || format === "protobuf"
|
|
551
|
-
)
|
|
633
|
+
),
|
|
634
|
+
...envelope.lastDeliverySequence !== void 0 ? { lastDeliverySequence: envelope.lastDeliverySequence } : {}
|
|
552
635
|
};
|
|
553
636
|
case "handshake-response":
|
|
554
637
|
return {
|
|
@@ -564,7 +647,9 @@ function fromProtoEnvelope(envelope) {
|
|
|
564
647
|
selectedWireFormat: envelope.selectedWireFormat === "json" || envelope.selectedWireFormat === "protobuf" ? envelope.selectedWireFormat : void 0,
|
|
565
648
|
// Preserve the server's wall-clock time so clock-skew detection and
|
|
566
649
|
// automatic timestamp rebase work over the protobuf wire, not just JSON.
|
|
567
|
-
...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {}
|
|
650
|
+
...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {},
|
|
651
|
+
...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {},
|
|
652
|
+
...envelope.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: envelope.serverMaxDeliverySequence } : {}
|
|
568
653
|
};
|
|
569
654
|
case "operation-batch":
|
|
570
655
|
return {
|
|
@@ -572,14 +657,17 @@ function fromProtoEnvelope(envelope) {
|
|
|
572
657
|
messageId: envelope.messageId,
|
|
573
658
|
operations: (envelope.operations ?? []).map(deserializeProtoOperation),
|
|
574
659
|
isFinal: envelope.isFinal ?? false,
|
|
575
|
-
batchIndex: envelope.batchIndex ?? 0
|
|
660
|
+
batchIndex: envelope.batchIndex ?? 0,
|
|
661
|
+
...envelope.baseDeliverySequence !== void 0 ? { baseDeliverySequence: envelope.baseDeliverySequence } : {},
|
|
662
|
+
...envelope.maxDeliverySequence !== void 0 ? { maxDeliverySequence: envelope.maxDeliverySequence } : {}
|
|
576
663
|
};
|
|
577
664
|
case "acknowledgment":
|
|
578
665
|
return {
|
|
579
666
|
type: "acknowledgment",
|
|
580
667
|
messageId: envelope.messageId,
|
|
581
668
|
acknowledgedMessageId: envelope.acknowledgedMessageId ?? "",
|
|
582
|
-
lastSequenceNumber: envelope.lastSequenceNumber ?? 0
|
|
669
|
+
lastSequenceNumber: envelope.lastSequenceNumber ?? 0,
|
|
670
|
+
...envelope.deliverySequence !== void 0 ? { deliverySequence: envelope.deliverySequence } : {}
|
|
583
671
|
};
|
|
584
672
|
case "error":
|
|
585
673
|
return {
|
|
@@ -589,6 +677,38 @@ function fromProtoEnvelope(envelope) {
|
|
|
589
677
|
message: envelope.errorMessage ?? "Unknown error",
|
|
590
678
|
retriable: envelope.retriable ?? false
|
|
591
679
|
};
|
|
680
|
+
case "operation-rejected":
|
|
681
|
+
return {
|
|
682
|
+
type: "operation-rejected",
|
|
683
|
+
messageId: envelope.messageId,
|
|
684
|
+
operationId: envelope.operationId ?? "",
|
|
685
|
+
collection: envelope.collection ?? "",
|
|
686
|
+
recordId: envelope.recordId ?? "",
|
|
687
|
+
code: envelope.errorCode ?? "REJECTED",
|
|
688
|
+
message: envelope.errorMessage ?? "",
|
|
689
|
+
retriable: envelope.retriable ?? false
|
|
690
|
+
};
|
|
691
|
+
case "blob-chunk-request":
|
|
692
|
+
return {
|
|
693
|
+
type: "blob-chunk-request",
|
|
694
|
+
messageId: envelope.messageId,
|
|
695
|
+
requestId: envelope.requestId ?? "",
|
|
696
|
+
hash: envelope.hash ?? ""
|
|
697
|
+
};
|
|
698
|
+
case "blob-chunk-response":
|
|
699
|
+
return {
|
|
700
|
+
type: "blob-chunk-response",
|
|
701
|
+
messageId: envelope.messageId,
|
|
702
|
+
requestId: envelope.requestId ?? "",
|
|
703
|
+
bytes: envelope.hasBytes ? envelope.chunkBytes ?? "" : null
|
|
704
|
+
};
|
|
705
|
+
case "blob-chunk-push":
|
|
706
|
+
return {
|
|
707
|
+
type: "blob-chunk-push",
|
|
708
|
+
messageId: envelope.messageId,
|
|
709
|
+
hash: envelope.hash ?? "",
|
|
710
|
+
bytes: envelope.chunkBytes ?? ""
|
|
711
|
+
};
|
|
592
712
|
default:
|
|
593
713
|
throw new SyncError("Failed to decode sync message: unknown protobuf type", {
|
|
594
714
|
type: envelope.type
|
|
@@ -734,6 +854,29 @@ function encodeEnvelope(envelope) {
|
|
|
734
854
|
}
|
|
735
855
|
if (envelope.retriable !== void 0) writer.uint32(144).bool(envelope.retriable);
|
|
736
856
|
if (envelope.serverTime !== void 0) writer.uint32(152).int64(envelope.serverTime);
|
|
857
|
+
if (envelope.requestId && envelope.requestId.length > 0)
|
|
858
|
+
writer.uint32(162).string(envelope.requestId);
|
|
859
|
+
if (envelope.hash && envelope.hash.length > 0) writer.uint32(170).string(envelope.hash);
|
|
860
|
+
if (envelope.chunkBytes && envelope.chunkBytes.length > 0)
|
|
861
|
+
writer.uint32(178).string(envelope.chunkBytes);
|
|
862
|
+
if (envelope.hasBytes !== void 0) writer.uint32(184).bool(envelope.hasBytes);
|
|
863
|
+
if (envelope.blobStorageEnabled !== void 0)
|
|
864
|
+
writer.uint32(200).bool(envelope.blobStorageEnabled);
|
|
865
|
+
if (envelope.operationId && envelope.operationId.length > 0)
|
|
866
|
+
writer.uint32(210).string(envelope.operationId);
|
|
867
|
+
if (envelope.collection && envelope.collection.length > 0)
|
|
868
|
+
writer.uint32(218).string(envelope.collection);
|
|
869
|
+
if (envelope.recordId && envelope.recordId.length > 0)
|
|
870
|
+
writer.uint32(226).string(envelope.recordId);
|
|
871
|
+
if (envelope.lastDeliverySequence !== void 0)
|
|
872
|
+
writer.uint32(232).int64(envelope.lastDeliverySequence);
|
|
873
|
+
if (envelope.baseDeliverySequence !== void 0)
|
|
874
|
+
writer.uint32(240).int64(envelope.baseDeliverySequence);
|
|
875
|
+
if (envelope.maxDeliverySequence !== void 0)
|
|
876
|
+
writer.uint32(248).int64(envelope.maxDeliverySequence);
|
|
877
|
+
if (envelope.deliverySequence !== void 0) writer.uint32(256).int64(envelope.deliverySequence);
|
|
878
|
+
if (envelope.serverMaxDeliverySequence !== void 0)
|
|
879
|
+
writer.uint32(264).int64(envelope.serverMaxDeliverySequence);
|
|
737
880
|
return writer.finish();
|
|
738
881
|
}
|
|
739
882
|
function decodeEnvelope(bytes) {
|
|
@@ -805,6 +948,45 @@ function decodeEnvelope(bytes) {
|
|
|
805
948
|
case 19:
|
|
806
949
|
envelope.serverTime = longToNumber(reader.int64());
|
|
807
950
|
break;
|
|
951
|
+
case 20:
|
|
952
|
+
envelope.requestId = reader.string();
|
|
953
|
+
break;
|
|
954
|
+
case 21:
|
|
955
|
+
envelope.hash = reader.string();
|
|
956
|
+
break;
|
|
957
|
+
case 22:
|
|
958
|
+
envelope.chunkBytes = reader.string();
|
|
959
|
+
break;
|
|
960
|
+
case 23:
|
|
961
|
+
envelope.hasBytes = reader.bool();
|
|
962
|
+
break;
|
|
963
|
+
case 25:
|
|
964
|
+
envelope.blobStorageEnabled = reader.bool();
|
|
965
|
+
break;
|
|
966
|
+
case 26:
|
|
967
|
+
envelope.operationId = reader.string();
|
|
968
|
+
break;
|
|
969
|
+
case 27:
|
|
970
|
+
envelope.collection = reader.string();
|
|
971
|
+
break;
|
|
972
|
+
case 28:
|
|
973
|
+
envelope.recordId = reader.string();
|
|
974
|
+
break;
|
|
975
|
+
case 29:
|
|
976
|
+
envelope.lastDeliverySequence = longToNumber(reader.int64());
|
|
977
|
+
break;
|
|
978
|
+
case 30:
|
|
979
|
+
envelope.baseDeliverySequence = longToNumber(reader.int64());
|
|
980
|
+
break;
|
|
981
|
+
case 31:
|
|
982
|
+
envelope.maxDeliverySequence = longToNumber(reader.int64());
|
|
983
|
+
break;
|
|
984
|
+
case 32:
|
|
985
|
+
envelope.deliverySequence = longToNumber(reader.int64());
|
|
986
|
+
break;
|
|
987
|
+
case 33:
|
|
988
|
+
envelope.serverMaxDeliverySequence = longToNumber(reader.int64());
|
|
989
|
+
break;
|
|
808
990
|
default:
|
|
809
991
|
reader.skipType(tag & 7);
|
|
810
992
|
}
|
|
@@ -1304,6 +1486,7 @@ var ChaosTransport = class {
|
|
|
1304
1486
|
reorderRate;
|
|
1305
1487
|
maxLatency;
|
|
1306
1488
|
random;
|
|
1489
|
+
dropPredicate;
|
|
1307
1490
|
messageHandler = null;
|
|
1308
1491
|
reorderBuffer = [];
|
|
1309
1492
|
pending = [];
|
|
@@ -1314,6 +1497,7 @@ var ChaosTransport = class {
|
|
|
1314
1497
|
this.reorderRate = config?.reorderRate ?? 0;
|
|
1315
1498
|
this.maxLatency = config?.maxLatency ?? 0;
|
|
1316
1499
|
this.random = config?.randomSource ?? Math.random;
|
|
1500
|
+
this.dropPredicate = config?.dropPredicate ?? null;
|
|
1317
1501
|
}
|
|
1318
1502
|
async connect(url, options) {
|
|
1319
1503
|
this.inner.onMessage((msg) => this.handleIncoming(msg));
|
|
@@ -1332,6 +1516,9 @@ var ChaosTransport = class {
|
|
|
1332
1516
|
return this.inner.disconnect();
|
|
1333
1517
|
}
|
|
1334
1518
|
send(message) {
|
|
1519
|
+
if (this.dropPredicate?.(message, "outgoing")) {
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1335
1522
|
if (this.random() < this.dropRate) {
|
|
1336
1523
|
return;
|
|
1337
1524
|
}
|
|
@@ -1359,6 +1546,9 @@ var ChaosTransport = class {
|
|
|
1359
1546
|
}
|
|
1360
1547
|
handleIncoming(message) {
|
|
1361
1548
|
if (!this.messageHandler) return;
|
|
1549
|
+
if (this.dropPredicate?.(message, "incoming")) {
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1362
1552
|
if (this.random() < this.dropRate) {
|
|
1363
1553
|
return;
|
|
1364
1554
|
}
|
|
@@ -1612,6 +1802,95 @@ var AwarenessManager = class {
|
|
|
1612
1802
|
}
|
|
1613
1803
|
};
|
|
1614
1804
|
|
|
1805
|
+
// src/blob/blob-chunk-channel.ts
|
|
1806
|
+
import { generateUUIDv7 } from "@korajs/core";
|
|
1807
|
+
function encodeBlobChunkBytes(bytes) {
|
|
1808
|
+
let binary = "";
|
|
1809
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1810
|
+
binary += String.fromCharCode(bytes[i]);
|
|
1811
|
+
}
|
|
1812
|
+
return btoa(binary);
|
|
1813
|
+
}
|
|
1814
|
+
function decodeBlobChunkBytes(encoded) {
|
|
1815
|
+
const binary = atob(encoded);
|
|
1816
|
+
const out = new Uint8Array(binary.length);
|
|
1817
|
+
for (let i = 0; i < binary.length; i++) {
|
|
1818
|
+
out[i] = binary.charCodeAt(i);
|
|
1819
|
+
}
|
|
1820
|
+
return out;
|
|
1821
|
+
}
|
|
1822
|
+
var BlobChunkChannel = class {
|
|
1823
|
+
onSend;
|
|
1824
|
+
handlers = /* @__PURE__ */ new Set();
|
|
1825
|
+
constructor(options) {
|
|
1826
|
+
this.onSend = options?.onSend ?? null;
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* Send a blob-chunk message to the peer. Response bytes are base64-encoded
|
|
1830
|
+
* onto the wire here; callers pass a `Uint8Array`.
|
|
1831
|
+
*/
|
|
1832
|
+
send(message) {
|
|
1833
|
+
if (!this.onSend) {
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1836
|
+
if (message.type === "blob-chunk-request") {
|
|
1837
|
+
this.onSend({
|
|
1838
|
+
type: "blob-chunk-request",
|
|
1839
|
+
messageId: generateUUIDv7(),
|
|
1840
|
+
requestId: message.requestId,
|
|
1841
|
+
hash: message.hash
|
|
1842
|
+
});
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
if (message.type === "blob-chunk-push") {
|
|
1846
|
+
this.onSend({
|
|
1847
|
+
type: "blob-chunk-push",
|
|
1848
|
+
messageId: generateUUIDv7(),
|
|
1849
|
+
hash: message.hash,
|
|
1850
|
+
bytes: encodeBlobChunkBytes(message.bytes)
|
|
1851
|
+
});
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
this.onSend({
|
|
1855
|
+
type: "blob-chunk-response",
|
|
1856
|
+
messageId: generateUUIDv7(),
|
|
1857
|
+
requestId: message.requestId,
|
|
1858
|
+
bytes: message.bytes === null ? null : encodeBlobChunkBytes(message.bytes)
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* Register a handler for inbound blob-chunk messages. Multiple handlers are
|
|
1863
|
+
* allowed; each receives every delivered message.
|
|
1864
|
+
*/
|
|
1865
|
+
onMessage(handler) {
|
|
1866
|
+
this.handlers.add(handler);
|
|
1867
|
+
return () => {
|
|
1868
|
+
this.handlers.delete(handler);
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
/**
|
|
1872
|
+
* Deliver an inbound wire message to registered handlers, decoding response
|
|
1873
|
+
* bytes from base64 back to a `Uint8Array` first.
|
|
1874
|
+
*/
|
|
1875
|
+
deliver(message) {
|
|
1876
|
+
if (this.handlers.size === 0) {
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
const decoded = message.type === "blob-chunk-request" ? { type: "blob-chunk-request", requestId: message.requestId, hash: message.hash } : message.type === "blob-chunk-push" ? {
|
|
1880
|
+
type: "blob-chunk-push",
|
|
1881
|
+
hash: message.hash,
|
|
1882
|
+
bytes: decodeBlobChunkBytes(message.bytes)
|
|
1883
|
+
} : {
|
|
1884
|
+
type: "blob-chunk-response",
|
|
1885
|
+
requestId: message.requestId,
|
|
1886
|
+
bytes: message.bytes === null ? null : decodeBlobChunkBytes(message.bytes)
|
|
1887
|
+
};
|
|
1888
|
+
for (const handler of this.handlers) {
|
|
1889
|
+
handler(decoded);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
};
|
|
1893
|
+
|
|
1615
1894
|
// src/diagnostics/bandwidth-estimator.ts
|
|
1616
1895
|
var BandwidthEstimator = class {
|
|
1617
1896
|
maxSamples;
|
|
@@ -2055,7 +2334,7 @@ var SyncMetricsCollector = class {
|
|
|
2055
2334
|
};
|
|
2056
2335
|
|
|
2057
2336
|
// src/richtext/richtext-doc-channel.ts
|
|
2058
|
-
import { generateUUIDv7 } from "@korajs/core";
|
|
2337
|
+
import { generateUUIDv7 as generateUUIDv72 } from "@korajs/core";
|
|
2059
2338
|
|
|
2060
2339
|
// src/richtext/doc-channel-wire.ts
|
|
2061
2340
|
function encodeYjsUpdate(update) {
|
|
@@ -2134,7 +2413,7 @@ var RichtextDocChannel = class {
|
|
|
2134
2413
|
}
|
|
2135
2414
|
this.onSend({
|
|
2136
2415
|
type: "yjs-doc-update",
|
|
2137
|
-
messageId:
|
|
2416
|
+
messageId: generateUUIDv72(),
|
|
2138
2417
|
collection,
|
|
2139
2418
|
recordId,
|
|
2140
2419
|
field,
|
|
@@ -2222,6 +2501,32 @@ var OutboundQueue = class {
|
|
|
2222
2501
|
const ids = ops.map((op) => op.id);
|
|
2223
2502
|
await this.storage.dequeue(ids);
|
|
2224
2503
|
}
|
|
2504
|
+
/**
|
|
2505
|
+
* Acknowledge only operations in an in-flight batch whose local sequence number is
|
|
2506
|
+
* covered by the server's ack. Any later operations return to the front of the
|
|
2507
|
+
* queue for retry. This is required when the server accepts a prefix but rejects a
|
|
2508
|
+
* later operation retriably (for example a stale-scope push after auth changed).
|
|
2509
|
+
*/
|
|
2510
|
+
async acknowledgeThrough(batchId, lastSequenceNumber) {
|
|
2511
|
+
const ops = this.inFlight.get(batchId);
|
|
2512
|
+
if (!ops) return;
|
|
2513
|
+
this.inFlight.delete(batchId);
|
|
2514
|
+
const acknowledged = ops.filter((op) => op.sequenceNumber <= lastSequenceNumber);
|
|
2515
|
+
const retry = ops.filter((op) => op.sequenceNumber > lastSequenceNumber);
|
|
2516
|
+
if (retry.length > 0) {
|
|
2517
|
+
this.queue.unshift(...retry);
|
|
2518
|
+
if (this.queue.length > 1) {
|
|
2519
|
+
this.queue = topologicalSort(this.queue);
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
if (acknowledged.length > 0) {
|
|
2523
|
+
const ids = acknowledged.map((op) => op.id);
|
|
2524
|
+
for (const id of ids) {
|
|
2525
|
+
this.seen.delete(id);
|
|
2526
|
+
}
|
|
2527
|
+
await this.storage.dequeue(ids);
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2225
2530
|
/**
|
|
2226
2531
|
* Return a failed batch to the front of the queue for retry.
|
|
2227
2532
|
* Prepends the operations to maintain priority.
|
|
@@ -2304,6 +2609,37 @@ var OutboundQueue = class {
|
|
|
2304
2609
|
get isInitialized() {
|
|
2305
2610
|
return this.initialized;
|
|
2306
2611
|
}
|
|
2612
|
+
/**
|
|
2613
|
+
* Remove a single operation the server permanently rejected — from the queue,
|
|
2614
|
+
* from any in-flight batch, and from durable storage — so a later batch ack or
|
|
2615
|
+
* a reconnect `returnBatch` can never resend or resurrect it. Returns the
|
|
2616
|
+
* removed operation so the caller can record it in a durable rejected store,
|
|
2617
|
+
* or null if it was not present (already acknowledged and removed).
|
|
2618
|
+
*/
|
|
2619
|
+
async reject(opId) {
|
|
2620
|
+
let removed = null;
|
|
2621
|
+
for (const [batchId, ops] of this.inFlight) {
|
|
2622
|
+
const idx = ops.findIndex((op) => op.id === opId);
|
|
2623
|
+
if (idx !== -1) {
|
|
2624
|
+
removed = ops[idx] ?? null;
|
|
2625
|
+
ops.splice(idx, 1);
|
|
2626
|
+
if (ops.length === 0) {
|
|
2627
|
+
this.inFlight.delete(batchId);
|
|
2628
|
+
}
|
|
2629
|
+
break;
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
if (!removed) {
|
|
2633
|
+
const idx = this.queue.findIndex((op) => op.id === opId);
|
|
2634
|
+
if (idx !== -1) {
|
|
2635
|
+
removed = this.queue[idx] ?? null;
|
|
2636
|
+
this.queue.splice(idx, 1);
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
this.seen.delete(opId);
|
|
2640
|
+
await this.storage.dequeue([opId]);
|
|
2641
|
+
return removed;
|
|
2642
|
+
}
|
|
2307
2643
|
/**
|
|
2308
2644
|
* Remove operations by id from queue and persistent storage.
|
|
2309
2645
|
* Used when ops were already sent during handshake delta exchange.
|
|
@@ -2322,6 +2658,7 @@ var OutboundQueue = class {
|
|
|
2322
2658
|
// src/engine/sync-engine.ts
|
|
2323
2659
|
var DEFAULT_BATCH_SIZE = 100;
|
|
2324
2660
|
var DEFAULT_SCHEMA_VERSION = 1;
|
|
2661
|
+
var DEFAULT_OUTBOUND_ACK_TIMEOUT_MS = 3e4;
|
|
2325
2662
|
var VALID_TRANSITIONS = {
|
|
2326
2663
|
disconnected: ["connecting"],
|
|
2327
2664
|
connecting: ["handshaking", "error", "disconnected"],
|
|
@@ -2335,6 +2672,17 @@ var nextQuerySubsetId = 0;
|
|
|
2335
2672
|
function generateMessageId() {
|
|
2336
2673
|
return `msg-${Date.now()}-${nextMessageId++}`;
|
|
2337
2674
|
}
|
|
2675
|
+
var MAX_DELIVERY_VIEW_WATERMARKS = 64;
|
|
2676
|
+
function stableStringify(value) {
|
|
2677
|
+
if (value === null || typeof value !== "object") {
|
|
2678
|
+
return JSON.stringify(value) ?? "null";
|
|
2679
|
+
}
|
|
2680
|
+
if (Array.isArray(value)) {
|
|
2681
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
2682
|
+
}
|
|
2683
|
+
const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
|
|
2684
|
+
return `{${entries.join(",")}}`;
|
|
2685
|
+
}
|
|
2338
2686
|
var SyncEngine = class {
|
|
2339
2687
|
state = "disconnected";
|
|
2340
2688
|
transport;
|
|
@@ -2343,10 +2691,13 @@ var SyncEngine = class {
|
|
|
2343
2691
|
serializer;
|
|
2344
2692
|
emitter;
|
|
2345
2693
|
outboundQueue;
|
|
2694
|
+
rejectedStorage;
|
|
2346
2695
|
batchSize;
|
|
2696
|
+
outboundAckTimeoutMs;
|
|
2347
2697
|
encryptor;
|
|
2348
2698
|
awarenessManager;
|
|
2349
2699
|
richtextDocChannel;
|
|
2700
|
+
blobChunkChannel;
|
|
2350
2701
|
metricsCollector;
|
|
2351
2702
|
syncState;
|
|
2352
2703
|
remoteVector = /* @__PURE__ */ new Map();
|
|
@@ -2357,10 +2708,16 @@ var SyncEngine = class {
|
|
|
2357
2708
|
lastSuccessfulPull = null;
|
|
2358
2709
|
conflictCount = 0;
|
|
2359
2710
|
currentBatch = null;
|
|
2711
|
+
currentBatchRequiresPartialAck = false;
|
|
2360
2712
|
reconnecting = false;
|
|
2361
2713
|
schemaBlocked = false;
|
|
2362
2714
|
clockBlocked = false;
|
|
2363
2715
|
clockSkewMs = null;
|
|
2716
|
+
blobStorageEnabled = false;
|
|
2717
|
+
outboundAckTimer = null;
|
|
2718
|
+
startPromise = null;
|
|
2719
|
+
stopPromise = null;
|
|
2720
|
+
reconnectPromise = null;
|
|
2364
2721
|
// Track delta exchange state
|
|
2365
2722
|
deltaBatchesReceived = 0;
|
|
2366
2723
|
deltaReceiveComplete = false;
|
|
@@ -2381,6 +2738,27 @@ var SyncEngine = class {
|
|
|
2381
2738
|
/** Resume cursor for paginated initial sync (persisted across reconnects) */
|
|
2382
2739
|
resumeDeltaCursor = null;
|
|
2383
2740
|
initialSyncTotalBatches = 0;
|
|
2741
|
+
/**
|
|
2742
|
+
* The delivery watermark for the CURRENT sync view: the highest server delivery
|
|
2743
|
+
* sequence up to which every operation in the current view (scope + active query
|
|
2744
|
+
* subscriptions) has been contiguously applied. Advanced only through the server's
|
|
2745
|
+
* gap-free delivery stream (never through live relay), so it is a durable lower bound
|
|
2746
|
+
* on what this client holds for this view. Reported at handshake to resume the stream.
|
|
2747
|
+
*
|
|
2748
|
+
* The watermark is keyed by view: switching views (a scope or subscription change)
|
|
2749
|
+
* saves the current view's watermark and loads the target view's, so returning to a
|
|
2750
|
+
* previously-synced view resumes exactly where it left off instead of re-syncing. This
|
|
2751
|
+
* is correct because a view's watermark is a lower bound specific to that view's
|
|
2752
|
+
* filter; a widened view is simply a different (initially unsynced) view.
|
|
2753
|
+
*/
|
|
2754
|
+
deliveryWatermark = 0;
|
|
2755
|
+
/**
|
|
2756
|
+
* In-memory cache of watermark per view signature, preloaded from persistence at start
|
|
2757
|
+
* so a view switch resolves synchronously (no race with the reconnect it triggers).
|
|
2758
|
+
*/
|
|
2759
|
+
deliverySignatureWatermarks = /* @__PURE__ */ new Map();
|
|
2760
|
+
deliveryGapRepeatCount = 0;
|
|
2761
|
+
lastDeliveryGapKey = null;
|
|
2384
2762
|
constructor(options) {
|
|
2385
2763
|
this.transport = options.transport;
|
|
2386
2764
|
this.store = options.store;
|
|
@@ -2388,11 +2766,13 @@ var SyncEngine = class {
|
|
|
2388
2766
|
this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
|
|
2389
2767
|
this.emitter = options.emitter ?? null;
|
|
2390
2768
|
this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2769
|
+
this.outboundAckTimeoutMs = options.config.outboundAckTimeoutMs ?? DEFAULT_OUTBOUND_ACK_TIMEOUT_MS;
|
|
2391
2770
|
this.encryptor = options.encryptor ?? null;
|
|
2392
2771
|
this.syncState = options.syncState ?? null;
|
|
2393
2772
|
this.activeScope = options.config.scopeMap;
|
|
2394
2773
|
const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
|
|
2395
2774
|
this.outboundQueue = new OutboundQueue(queueStorage);
|
|
2775
|
+
this.rejectedStorage = options.rejectedStorage ?? new MemoryRejectedOperationStorage();
|
|
2396
2776
|
this.metricsCollector = new SyncMetricsCollector(options.metricsConfig);
|
|
2397
2777
|
if (this.emitter) {
|
|
2398
2778
|
this.metricsCollector.attachEmitter(this.emitter);
|
|
@@ -2409,6 +2789,14 @@ var SyncEngine = class {
|
|
|
2409
2789
|
this.transport.send(message);
|
|
2410
2790
|
}
|
|
2411
2791
|
});
|
|
2792
|
+
this.blobChunkChannel = new BlobChunkChannel({
|
|
2793
|
+
onSend: (message) => {
|
|
2794
|
+
if (this.state !== "streaming") {
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
this.transport.send(message);
|
|
2798
|
+
}
|
|
2799
|
+
});
|
|
2412
2800
|
this.awarenessManager.onSend((message) => {
|
|
2413
2801
|
if (this.state !== "streaming") return;
|
|
2414
2802
|
const wireMessage = {
|
|
@@ -2424,10 +2812,20 @@ var SyncEngine = class {
|
|
|
2424
2812
|
* Start the sync engine: connect → handshake → delta exchange → streaming.
|
|
2425
2813
|
*/
|
|
2426
2814
|
async start() {
|
|
2815
|
+
if (this.state === "streaming") return;
|
|
2816
|
+
if (this.startPromise) return this.startPromise;
|
|
2817
|
+
this.startPromise = this.startInternal().finally(() => {
|
|
2818
|
+
this.startPromise = null;
|
|
2819
|
+
});
|
|
2820
|
+
return this.startPromise;
|
|
2821
|
+
}
|
|
2822
|
+
async startInternal() {
|
|
2823
|
+
if (this.stopPromise) {
|
|
2824
|
+
await this.stopPromise;
|
|
2825
|
+
}
|
|
2826
|
+
if (this.state === "streaming") return;
|
|
2427
2827
|
if (this.state !== "disconnected") {
|
|
2428
|
-
|
|
2429
|
-
currentState: this.state
|
|
2430
|
-
});
|
|
2828
|
+
await this.stop();
|
|
2431
2829
|
}
|
|
2432
2830
|
await this.outboundQueue.initialize();
|
|
2433
2831
|
if (this.syncState) {
|
|
@@ -2435,6 +2833,16 @@ var SyncEngine = class {
|
|
|
2435
2833
|
if (this.syncState.loadDeltaCursor) {
|
|
2436
2834
|
this.resumeDeltaCursor = await this.syncState.loadDeltaCursor();
|
|
2437
2835
|
}
|
|
2836
|
+
if (this.syncState.loadAllDeliveryWatermarks) {
|
|
2837
|
+
const all = await this.syncState.loadAllDeliveryWatermarks();
|
|
2838
|
+
for (const [signature, watermark] of Object.entries(all)) {
|
|
2839
|
+
this.deliverySignatureWatermarks.set(signature, watermark);
|
|
2840
|
+
}
|
|
2841
|
+
} else if (this.syncState.loadDeliveryWatermark) {
|
|
2842
|
+
this.deliverySignatureWatermarks.set("", await this.syncState.loadDeliveryWatermark(""));
|
|
2843
|
+
}
|
|
2844
|
+
this.deliveryWatermark = this.deliverySignatureWatermarks.get(this.deliverySignature()) ?? 0;
|
|
2845
|
+
this.evictColdViewWatermarks();
|
|
2438
2846
|
}
|
|
2439
2847
|
await this.reconcileOutboundFromOpLog();
|
|
2440
2848
|
await this.refreshPendingCount();
|
|
@@ -2450,7 +2858,12 @@ var SyncEngine = class {
|
|
|
2450
2858
|
this.transitionTo("connecting");
|
|
2451
2859
|
try {
|
|
2452
2860
|
const authToken = this.config.auth ? (await this.config.auth()).token : void 0;
|
|
2861
|
+
if (this.state !== "connecting") return;
|
|
2453
2862
|
await this.transport.connect(this.config.url, { authToken });
|
|
2863
|
+
if (this.state !== "connecting") {
|
|
2864
|
+
await this.transport.disconnect();
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2454
2867
|
this.transitionTo("handshaking");
|
|
2455
2868
|
const localVector = this.store.getVersionVector();
|
|
2456
2869
|
const activeQuerySubsets = this.getActiveQuerySubsets();
|
|
@@ -2464,7 +2877,11 @@ var SyncEngine = class {
|
|
|
2464
2877
|
supportedWireFormats: ["json", "protobuf"],
|
|
2465
2878
|
...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
|
|
2466
2879
|
...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
|
|
2467
|
-
...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {}
|
|
2880
|
+
...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {},
|
|
2881
|
+
// Resume the server's gap-free delivery stream from our watermark. A server
|
|
2882
|
+
// that understands it drives server->client sync from delivery sequences; an
|
|
2883
|
+
// older server ignores it and falls back to the version-vector delta.
|
|
2884
|
+
lastDeliverySequence: this.deliveryWatermark
|
|
2468
2885
|
};
|
|
2469
2886
|
this.transport.send(handshake);
|
|
2470
2887
|
} catch (err) {
|
|
@@ -2476,11 +2893,20 @@ var SyncEngine = class {
|
|
|
2476
2893
|
* Stop the sync engine. Disconnects the transport.
|
|
2477
2894
|
*/
|
|
2478
2895
|
async stop() {
|
|
2896
|
+
if (this.stopPromise) return this.stopPromise;
|
|
2897
|
+
this.stopPromise = this.stopInternal().finally(() => {
|
|
2898
|
+
this.stopPromise = null;
|
|
2899
|
+
});
|
|
2900
|
+
return this.stopPromise;
|
|
2901
|
+
}
|
|
2902
|
+
async stopInternal() {
|
|
2479
2903
|
if (this.state === "disconnected") return;
|
|
2904
|
+
this.clearOutboundAckTimer();
|
|
2480
2905
|
this.awarenessManager.stopCleanupTimer();
|
|
2481
2906
|
if (this.currentBatch) {
|
|
2482
2907
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
2483
2908
|
this.currentBatch = null;
|
|
2909
|
+
this.currentBatchRequiresPartialAck = false;
|
|
2484
2910
|
}
|
|
2485
2911
|
try {
|
|
2486
2912
|
await this.transport.disconnect();
|
|
@@ -2501,7 +2927,7 @@ var SyncEngine = class {
|
|
|
2501
2927
|
* because they should remain local-only and not be sent to the server.
|
|
2502
2928
|
*/
|
|
2503
2929
|
async pushOperation(op) {
|
|
2504
|
-
if (!this.operationAllowedForSync(op)) {
|
|
2930
|
+
if (!await this.operationAllowedForSync(op)) {
|
|
2505
2931
|
return;
|
|
2506
2932
|
}
|
|
2507
2933
|
if (this.syncState) {
|
|
@@ -2530,6 +2956,7 @@ var SyncEngine = class {
|
|
|
2530
2956
|
getStatus() {
|
|
2531
2957
|
const pendingOperations = this.syncState ? this.cachedUnsyncedCount : this.outboundQueue.totalPending;
|
|
2532
2958
|
const base = {
|
|
2959
|
+
reconnecting: this.reconnecting,
|
|
2533
2960
|
pendingOperations,
|
|
2534
2961
|
lastSyncedAt: this.lastSyncedAt,
|
|
2535
2962
|
lastSuccessfulPush: this.lastSuccessfulPush,
|
|
@@ -2549,7 +2976,7 @@ var SyncEngine = class {
|
|
|
2549
2976
|
case "connecting":
|
|
2550
2977
|
case "handshaking":
|
|
2551
2978
|
case "syncing":
|
|
2552
|
-
return { ...base, status: this.reconnecting ? "
|
|
2979
|
+
return { ...base, status: this.reconnecting ? "reconnecting" : "syncing" };
|
|
2553
2980
|
case "streaming":
|
|
2554
2981
|
return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
|
|
2555
2982
|
case "error":
|
|
@@ -2634,6 +3061,24 @@ var SyncEngine = class {
|
|
|
2634
3061
|
await this.start();
|
|
2635
3062
|
}
|
|
2636
3063
|
}
|
|
3064
|
+
/**
|
|
3065
|
+
* Atomically refresh the transport session. Overlapping callers share one
|
|
3066
|
+
* reconnect, in-flight outbound batches are returned to the queue by stop(), and the
|
|
3067
|
+
* next start handshakes with the latest auth token, sync scope, and query subsets.
|
|
3068
|
+
*/
|
|
3069
|
+
async reconnect() {
|
|
3070
|
+
if (this.schemaBlocked) return;
|
|
3071
|
+
if (this.reconnectPromise) return this.reconnectPromise;
|
|
3072
|
+
this.reconnecting = false;
|
|
3073
|
+
this.reconnectPromise = this.reconnectInternal().finally(() => {
|
|
3074
|
+
this.reconnectPromise = null;
|
|
3075
|
+
});
|
|
3076
|
+
return this.reconnectPromise;
|
|
3077
|
+
}
|
|
3078
|
+
async reconnectInternal() {
|
|
3079
|
+
await this.stop();
|
|
3080
|
+
await this.start();
|
|
3081
|
+
}
|
|
2637
3082
|
/**
|
|
2638
3083
|
* Export a diagnostics snapshot for debugging and support tickets.
|
|
2639
3084
|
* Contains connection state, timing info, and queue metrics.
|
|
@@ -2652,6 +3097,8 @@ var SyncEngine = class {
|
|
|
2652
3097
|
pendingOperations: this.outboundQueue.totalPending,
|
|
2653
3098
|
hasInFlightBatch: this.currentBatch !== null,
|
|
2654
3099
|
reconnecting: this.reconnecting,
|
|
3100
|
+
deliveryWatermark: this.deliveryWatermark,
|
|
3101
|
+
deliveryGapRepeatCount: this.deliveryGapRepeatCount,
|
|
2655
3102
|
timestamp: Date.now()
|
|
2656
3103
|
};
|
|
2657
3104
|
}
|
|
@@ -2680,8 +3127,84 @@ var SyncEngine = class {
|
|
|
2680
3127
|
* @param scopeMap - New per-collection scope filters, or undefined to remove scope
|
|
2681
3128
|
*/
|
|
2682
3129
|
updateScope(scopeMap) {
|
|
3130
|
+
const previousSignature = this.deliverySignature();
|
|
2683
3131
|
this.activeScope = scopeMap;
|
|
2684
3132
|
this.config.scopeMap = scopeMap;
|
|
3133
|
+
this.switchDeliveryView(previousSignature);
|
|
3134
|
+
}
|
|
3135
|
+
/**
|
|
3136
|
+
* A stable identifier for the current sync view: the auth/tenant scope plus the set of
|
|
3137
|
+
* active query subscriptions, canonicalized so the same view always produces the same
|
|
3138
|
+
* string. The delivery watermark is keyed by this, so each distinct view resumes from
|
|
3139
|
+
* its own last position.
|
|
3140
|
+
*/
|
|
3141
|
+
deliverySignature() {
|
|
3142
|
+
const subsets = this.getActiveQuerySubsets();
|
|
3143
|
+
if (!this.activeScope && subsets.length === 0) {
|
|
3144
|
+
return "";
|
|
3145
|
+
}
|
|
3146
|
+
const normalizedSubsets = subsets.map((s) => `${s.collection}:${stableStringify(s.where)}`).sort();
|
|
3147
|
+
return stableStringify({ scope: this.activeScope ?? null, subsets: normalizedSubsets });
|
|
3148
|
+
}
|
|
3149
|
+
/**
|
|
3150
|
+
* Switch the active delivery watermark to the current view after a scope or subscription
|
|
3151
|
+
* change. The prior view's watermark is saved so returning to it resumes; the new view's
|
|
3152
|
+
* watermark is restored from the in-memory cache (0 for a never-synced view, which then
|
|
3153
|
+
* does a one-time resync under that view). No data is lost: a view's watermark is a lower
|
|
3154
|
+
* bound for that specific view's filter.
|
|
3155
|
+
*/
|
|
3156
|
+
switchDeliveryView(previousSignature) {
|
|
3157
|
+
const nextSignature = this.deliverySignature();
|
|
3158
|
+
if (nextSignature === previousSignature) {
|
|
3159
|
+
return;
|
|
3160
|
+
}
|
|
3161
|
+
this.setViewWatermark(previousSignature, this.deliveryWatermark);
|
|
3162
|
+
void this.persistDeliveryWatermark(this.deliveryWatermark, previousSignature);
|
|
3163
|
+
this.deliveryWatermark = this.deliverySignatureWatermarks.get(nextSignature) ?? 0;
|
|
3164
|
+
}
|
|
3165
|
+
/**
|
|
3166
|
+
* Record a view's watermark in the in-memory cache as the most-recently-used entry (the
|
|
3167
|
+
* Map preserves insertion order, so re-inserting moves it to the tail), then evict cold
|
|
3168
|
+
* views past the retention cap. This is the single place the cache is written, so LRU
|
|
3169
|
+
* order and the size bound are always maintained together.
|
|
3170
|
+
*/
|
|
3171
|
+
setViewWatermark(signature, watermark) {
|
|
3172
|
+
this.deliverySignatureWatermarks.delete(signature);
|
|
3173
|
+
this.deliverySignatureWatermarks.set(signature, watermark);
|
|
3174
|
+
this.evictColdViewWatermarks();
|
|
3175
|
+
}
|
|
3176
|
+
/**
|
|
3177
|
+
* Trim the per-view watermark cache to `MAX_DELIVERY_VIEW_WATERMARKS`, evicting the
|
|
3178
|
+
* least-recently-used views. The default view ('') and the live view are never evicted
|
|
3179
|
+
* (evicting the live view would force an immediate re-scan of what we are actively
|
|
3180
|
+
* syncing). Each eviction also removes the persisted row, bounding storage. Safe by
|
|
3181
|
+
* construction: an evicted view simply back-fills from 0 (deduplicated) when next seen.
|
|
3182
|
+
*/
|
|
3183
|
+
evictColdViewWatermarks() {
|
|
3184
|
+
if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
|
|
3185
|
+
return;
|
|
3186
|
+
}
|
|
3187
|
+
const liveSignature = this.deliverySignature();
|
|
3188
|
+
for (const signature of this.deliverySignatureWatermarks.keys()) {
|
|
3189
|
+
if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
|
|
3190
|
+
break;
|
|
3191
|
+
}
|
|
3192
|
+
if (signature === "" || signature === liveSignature) {
|
|
3193
|
+
continue;
|
|
3194
|
+
}
|
|
3195
|
+
this.deliverySignatureWatermarks.delete(signature);
|
|
3196
|
+
void this.deleteDeliveryWatermark(signature);
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
/**
|
|
3200
|
+
* Remove a view's persisted watermark row (best-effort; a persistence layer that omits
|
|
3201
|
+
* the delete simply keeps the row, which is harmless).
|
|
3202
|
+
*/
|
|
3203
|
+
async deleteDeliveryWatermark(signature) {
|
|
3204
|
+
if (!this.syncState?.deleteDeliveryWatermark) {
|
|
3205
|
+
return;
|
|
3206
|
+
}
|
|
3207
|
+
await this.syncState.deleteDeliveryWatermark(signature);
|
|
2685
3208
|
}
|
|
2686
3209
|
/**
|
|
2687
3210
|
* Get the currently active scope map. Returns undefined if no scope is configured.
|
|
@@ -2695,10 +3218,14 @@ var SyncEngine = class {
|
|
|
2695
3218
|
*/
|
|
2696
3219
|
registerQuerySubset(subset) {
|
|
2697
3220
|
const id = `query-${nextQuerySubsetId++}`;
|
|
3221
|
+
const previousSignature = this.deliverySignature();
|
|
2698
3222
|
this.querySubsets.set(id, subset);
|
|
3223
|
+
this.switchDeliveryView(previousSignature);
|
|
2699
3224
|
this.scheduleQuerySubsetReconnect();
|
|
2700
3225
|
return () => {
|
|
3226
|
+
const sigBeforeRemove = this.deliverySignature();
|
|
2701
3227
|
this.querySubsets.delete(id);
|
|
3228
|
+
this.switchDeliveryView(sigBeforeRemove);
|
|
2702
3229
|
this.scheduleQuerySubsetReconnect();
|
|
2703
3230
|
};
|
|
2704
3231
|
}
|
|
@@ -2722,6 +3249,31 @@ var SyncEngine = class {
|
|
|
2722
3249
|
getRichtextDocChannel() {
|
|
2723
3250
|
return this.richtextDocChannel;
|
|
2724
3251
|
}
|
|
3252
|
+
/**
|
|
3253
|
+
* Side channel for out-of-band blob chunk transfer over the sync connection.
|
|
3254
|
+
* The app layer bridges this to a `ChunkMessagePort` to pull/serve blob bytes.
|
|
3255
|
+
*/
|
|
3256
|
+
getBlobChunkChannel() {
|
|
3257
|
+
return this.blobChunkChannel;
|
|
3258
|
+
}
|
|
3259
|
+
/**
|
|
3260
|
+
* Whether the connected server persists blob bytes centrally (learned from the
|
|
3261
|
+
* handshake response). When true, the app uploads the bytes behind `blob`
|
|
3262
|
+
* fields so they stay available after the authoring device goes offline.
|
|
3263
|
+
*/
|
|
3264
|
+
isBlobStorageEnabled() {
|
|
3265
|
+
return this.blobStorageEnabled;
|
|
3266
|
+
}
|
|
3267
|
+
/**
|
|
3268
|
+
* Upload a blob chunk (or manifest) to the server for central persistence.
|
|
3269
|
+
* A no-op unless connected and the server advertised blob storage.
|
|
3270
|
+
*/
|
|
3271
|
+
uploadBlobChunk(hash, bytes) {
|
|
3272
|
+
if (this.state !== "streaming" || !this.blobStorageEnabled) {
|
|
3273
|
+
return;
|
|
3274
|
+
}
|
|
3275
|
+
this.blobChunkChannel.send({ type: "blob-chunk-push", hash, bytes });
|
|
3276
|
+
}
|
|
2725
3277
|
// --- Private methods ---
|
|
2726
3278
|
messageChain = Promise.resolve();
|
|
2727
3279
|
enqueueMessage(message) {
|
|
@@ -2736,17 +3288,25 @@ var SyncEngine = class {
|
|
|
2736
3288
|
await this.handleOperationBatch(message);
|
|
2737
3289
|
break;
|
|
2738
3290
|
case "acknowledgment":
|
|
2739
|
-
this.handleAcknowledgment(message);
|
|
3291
|
+
await this.handleAcknowledgment(message);
|
|
2740
3292
|
break;
|
|
2741
3293
|
case "error":
|
|
2742
3294
|
this.handleError(message);
|
|
2743
3295
|
break;
|
|
3296
|
+
case "operation-rejected":
|
|
3297
|
+
await this.handleOperationRejected(message);
|
|
3298
|
+
break;
|
|
2744
3299
|
case "awareness-update":
|
|
2745
3300
|
this.handleAwarenessUpdate(message);
|
|
2746
3301
|
break;
|
|
2747
3302
|
case "yjs-doc-update":
|
|
2748
3303
|
this.richtextDocChannel.deliver(message);
|
|
2749
3304
|
break;
|
|
3305
|
+
case "blob-chunk-request":
|
|
3306
|
+
case "blob-chunk-response":
|
|
3307
|
+
case "blob-chunk-push":
|
|
3308
|
+
this.blobChunkChannel.deliver(message);
|
|
3309
|
+
break;
|
|
2750
3310
|
}
|
|
2751
3311
|
}
|
|
2752
3312
|
handleMessageFailure(error) {
|
|
@@ -2779,6 +3339,7 @@ var SyncEngine = class {
|
|
|
2779
3339
|
}
|
|
2780
3340
|
async handleHandshakeResponse(msg) {
|
|
2781
3341
|
if (this.state !== "handshaking") return;
|
|
3342
|
+
this.blobStorageEnabled = msg.blobStorageEnabled === true;
|
|
2782
3343
|
if (typeof msg.serverTime === "number") {
|
|
2783
3344
|
this.evaluateClockSkew(msg.serverTime);
|
|
2784
3345
|
if (this.clockBlocked) {
|
|
@@ -2817,6 +3378,11 @@ var SyncEngine = class {
|
|
|
2817
3378
|
}
|
|
2818
3379
|
this.remoteVector = wireToVersionVector(msg.versionVector);
|
|
2819
3380
|
void this.persistLastAckedServerVector(this.remoteVector);
|
|
3381
|
+
if (typeof msg.serverMaxDeliverySequence === "number" && this.deliveryWatermark > msg.serverMaxDeliverySequence) {
|
|
3382
|
+
this.deliveryWatermark = 0;
|
|
3383
|
+
this.setViewWatermark(this.deliverySignature(), 0);
|
|
3384
|
+
await this.persistDeliveryWatermark(0);
|
|
3385
|
+
}
|
|
2820
3386
|
if (msg.selectedWireFormat) {
|
|
2821
3387
|
this.setSerializerWireFormat(msg.selectedWireFormat);
|
|
2822
3388
|
}
|
|
@@ -2882,7 +3448,7 @@ var SyncEngine = class {
|
|
|
2882
3448
|
async sendDelta() {
|
|
2883
3449
|
const localVector = this.store.getVersionVector();
|
|
2884
3450
|
const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
|
|
2885
|
-
const missingOps =
|
|
3451
|
+
const missingOps = await this.filterAllowedForSync(allMissingOps);
|
|
2886
3452
|
this.deltaSentOpIds = missingOps.map((op) => op.id);
|
|
2887
3453
|
if (missingOps.length === 0) {
|
|
2888
3454
|
const messageId = generateMessageId();
|
|
@@ -2957,11 +3523,34 @@ var SyncEngine = class {
|
|
|
2957
3523
|
return missing;
|
|
2958
3524
|
}
|
|
2959
3525
|
async handleOperationBatch(msg) {
|
|
3526
|
+
const isDeliveryBatch = msg.maxDeliverySequence !== void 0;
|
|
3527
|
+
if (isDeliveryBatch) {
|
|
3528
|
+
const base = msg.baseDeliverySequence ?? 0;
|
|
3529
|
+
if (base > this.deliveryWatermark) {
|
|
3530
|
+
const gapKey = `${this.deliveryWatermark}:${base}`;
|
|
3531
|
+
this.deliveryGapRepeatCount = this.lastDeliveryGapKey === gapKey ? this.deliveryGapRepeatCount + 1 : 1;
|
|
3532
|
+
this.lastDeliveryGapKey = gapKey;
|
|
3533
|
+
this.emitter?.emit({
|
|
3534
|
+
type: "sync:delivery-gap",
|
|
3535
|
+
expectedBase: this.deliveryWatermark,
|
|
3536
|
+
receivedBase: base,
|
|
3537
|
+
currentWatermark: this.deliveryWatermark,
|
|
3538
|
+
messageId: msg.messageId,
|
|
3539
|
+
repeatCount: this.deliveryGapRepeatCount
|
|
3540
|
+
});
|
|
3541
|
+
return;
|
|
3542
|
+
}
|
|
3543
|
+
if (base < this.deliveryWatermark) {
|
|
3544
|
+
this.sendDeliveryAck(msg.messageId, this.deliveryWatermark);
|
|
3545
|
+
return;
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
2960
3548
|
const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
2961
3549
|
const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
|
|
2962
|
-
const inScopeOps =
|
|
3550
|
+
const inScopeOps = await this.filterAllowedForSync(operations);
|
|
2963
3551
|
const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
2964
3552
|
const transforms = this.config.operationTransforms ?? [];
|
|
3553
|
+
let fullyApplied = true;
|
|
2965
3554
|
for (const op of inScopeOps) {
|
|
2966
3555
|
const transformed = transforms.length > 0 ? applyOperationTransforms(op, targetSchemaVersion, transforms) : op;
|
|
2967
3556
|
if (transformed === null) {
|
|
@@ -2973,6 +3562,7 @@ var SyncEngine = class {
|
|
|
2973
3562
|
this.emitApplyFailure(transformed, result);
|
|
2974
3563
|
}
|
|
2975
3564
|
} catch (error) {
|
|
3565
|
+
fullyApplied = false;
|
|
2976
3566
|
if (error instanceof ClockDriftError) {
|
|
2977
3567
|
this.emitApplyFailure(transformed, "rejected", {
|
|
2978
3568
|
code: APPLY_FAILURE_CODES.CLOCK_DRIFT,
|
|
@@ -2983,11 +3573,8 @@ var SyncEngine = class {
|
|
|
2983
3573
|
}
|
|
2984
3574
|
const message = error instanceof Error ? error.message : "Apply failed";
|
|
2985
3575
|
const code = error instanceof SyncError4 ? error.code : error instanceof KoraError2 ? error.code : error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : APPLY_FAILURE_CODES.APPLY_FAILED;
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
message,
|
|
2989
|
-
retriable: code !== APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY
|
|
2990
|
-
});
|
|
3576
|
+
const retriable = code !== APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY;
|
|
3577
|
+
this.emitApplyFailure(transformed, "rejected", { code, message, retriable });
|
|
2991
3578
|
}
|
|
2992
3579
|
}
|
|
2993
3580
|
if (inScopeOps.length > 0) {
|
|
@@ -2998,18 +3585,29 @@ var SyncEngine = class {
|
|
|
2998
3585
|
batchSize: inScopeOps.length
|
|
2999
3586
|
});
|
|
3000
3587
|
}
|
|
3001
|
-
const
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3588
|
+
const deliveryFullyApplied = isDeliveryBatch && fullyApplied;
|
|
3589
|
+
if (!isDeliveryBatch || deliveryFullyApplied) {
|
|
3590
|
+
const lastOp = operations[operations.length - 1];
|
|
3591
|
+
const ack = {
|
|
3592
|
+
type: "acknowledgment",
|
|
3593
|
+
messageId: generateMessageId(),
|
|
3594
|
+
acknowledgedMessageId: msg.messageId,
|
|
3595
|
+
lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0,
|
|
3596
|
+
...deliveryFullyApplied && msg.maxDeliverySequence !== void 0 ? { deliverySequence: msg.maxDeliverySequence } : {}
|
|
3597
|
+
};
|
|
3598
|
+
this.transport.send(ack);
|
|
3599
|
+
this.emitter?.emit({
|
|
3600
|
+
type: "sync:acknowledged",
|
|
3601
|
+
sequenceNumber: lastOp ? lastOp.sequenceNumber : 0
|
|
3602
|
+
});
|
|
3603
|
+
}
|
|
3604
|
+
if (isDeliveryBatch && fullyApplied && msg.maxDeliverySequence !== void 0) {
|
|
3605
|
+
this.deliveryGapRepeatCount = 0;
|
|
3606
|
+
this.lastDeliveryGapKey = null;
|
|
3607
|
+
this.deliveryWatermark = msg.maxDeliverySequence;
|
|
3608
|
+
this.setViewWatermark(this.deliverySignature(), this.deliveryWatermark);
|
|
3609
|
+
await this.persistDeliveryWatermark(this.deliveryWatermark);
|
|
3610
|
+
}
|
|
3013
3611
|
if (this.state === "syncing") {
|
|
3014
3612
|
this.deltaBatchesReceived++;
|
|
3015
3613
|
const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
|
|
@@ -3017,15 +3615,17 @@ var SyncEngine = class {
|
|
|
3017
3615
|
this.initialSyncTotalBatches = msg.totalBatches;
|
|
3018
3616
|
}
|
|
3019
3617
|
this.metricsCollector.updateInitialSyncProgress(this.deltaBatchesReceived, totalBatches);
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3618
|
+
if (!isDeliveryBatch) {
|
|
3619
|
+
const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
|
|
3620
|
+
inScopeOps.length > 0 ? inScopeOps : operations,
|
|
3621
|
+
msg.batchIndex
|
|
3622
|
+
);
|
|
3623
|
+
if (cursorFromBatch) {
|
|
3624
|
+
this.resumeDeltaCursor = cursorFromBatch;
|
|
3625
|
+
await this.persistDeltaCursor(cursorFromBatch);
|
|
3626
|
+
}
|
|
3027
3627
|
}
|
|
3028
|
-
if (msg.isFinal) {
|
|
3628
|
+
if (msg.isFinal && (!isDeliveryBatch || fullyApplied)) {
|
|
3029
3629
|
this.deltaReceiveComplete = true;
|
|
3030
3630
|
this.resumeDeltaCursor = null;
|
|
3031
3631
|
await this.persistDeltaCursor(null);
|
|
@@ -3034,26 +3634,51 @@ var SyncEngine = class {
|
|
|
3034
3634
|
}
|
|
3035
3635
|
}
|
|
3036
3636
|
}
|
|
3037
|
-
|
|
3637
|
+
/** Acknowledge a delivery batch (used for duplicates) without re-applying it. */
|
|
3638
|
+
sendDeliveryAck(acknowledgedMessageId, deliverySequence) {
|
|
3639
|
+
const ack = {
|
|
3640
|
+
type: "acknowledgment",
|
|
3641
|
+
messageId: generateMessageId(),
|
|
3642
|
+
acknowledgedMessageId,
|
|
3643
|
+
lastSequenceNumber: 0,
|
|
3644
|
+
deliverySequence
|
|
3645
|
+
};
|
|
3646
|
+
this.transport.send(ack);
|
|
3647
|
+
}
|
|
3648
|
+
async handleAcknowledgment(msg) {
|
|
3649
|
+
this.clearOutboundAckTimer();
|
|
3038
3650
|
if (this.state === "syncing" && this.config.strictHandshake) {
|
|
3039
3651
|
this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
|
|
3040
3652
|
this.markDeltaSendCompleteIfReady();
|
|
3041
3653
|
}
|
|
3042
3654
|
if (this.currentBatch) {
|
|
3043
|
-
|
|
3655
|
+
if (this.currentBatchRequiresPartialAck) {
|
|
3656
|
+
await this.outboundQueue.acknowledgeThrough(
|
|
3657
|
+
this.currentBatch.batchId,
|
|
3658
|
+
msg.lastSequenceNumber
|
|
3659
|
+
);
|
|
3660
|
+
} else {
|
|
3661
|
+
await this.outboundQueue.acknowledge(this.currentBatch.batchId);
|
|
3662
|
+
}
|
|
3044
3663
|
this.currentBatch = null;
|
|
3664
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3045
3665
|
const now = Date.now();
|
|
3046
3666
|
this.lastSyncedAt = now;
|
|
3047
3667
|
this.lastSuccessfulPush = now;
|
|
3048
3668
|
}
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
);
|
|
3669
|
+
await this.advanceLastAckedForLocalNode(msg.lastSequenceNumber);
|
|
3670
|
+
await this.refreshPendingCount();
|
|
3052
3671
|
if (this.state === "streaming" && this.outboundQueue.hasOperations) {
|
|
3053
3672
|
this.flushQueue();
|
|
3054
3673
|
}
|
|
3055
3674
|
}
|
|
3056
3675
|
handleError(msg) {
|
|
3676
|
+
this.clearOutboundAckTimer();
|
|
3677
|
+
if (this.currentBatch) {
|
|
3678
|
+
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
3679
|
+
this.currentBatch = null;
|
|
3680
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3681
|
+
}
|
|
3057
3682
|
this.transitionTo("error");
|
|
3058
3683
|
if (msg.code === "AUTH_FAILED") {
|
|
3059
3684
|
this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
|
|
@@ -3072,6 +3697,64 @@ var SyncEngine = class {
|
|
|
3072
3697
|
this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
|
|
3073
3698
|
this.transitionTo("disconnected");
|
|
3074
3699
|
}
|
|
3700
|
+
/**
|
|
3701
|
+
* Handle a per-operation rejection: divert the op out of the pending outbound
|
|
3702
|
+
* queue (so it is never retried or resurrected on reconnect), record it in the
|
|
3703
|
+
* durable rejected store (so it is kept and explainable), and emit an event so
|
|
3704
|
+
* the app can reconcile. Unlike {@link handleError}, this is a normal per-op
|
|
3705
|
+
* signal, so the connection stays up.
|
|
3706
|
+
*/
|
|
3707
|
+
async handleOperationRejected(msg) {
|
|
3708
|
+
if (msg.retriable) {
|
|
3709
|
+
this.currentBatchRequiresPartialAck = true;
|
|
3710
|
+
await this.refreshPendingCount();
|
|
3711
|
+
this.emitter?.emit({
|
|
3712
|
+
type: "sync:operation-rejected",
|
|
3713
|
+
operationId: msg.operationId,
|
|
3714
|
+
collection: msg.collection,
|
|
3715
|
+
recordId: msg.recordId,
|
|
3716
|
+
code: msg.code,
|
|
3717
|
+
message: msg.message,
|
|
3718
|
+
retriable: true
|
|
3719
|
+
});
|
|
3720
|
+
return;
|
|
3721
|
+
}
|
|
3722
|
+
await this.outboundQueue.reject(msg.operationId);
|
|
3723
|
+
await this.rejectedStorage.record({
|
|
3724
|
+
operationId: msg.operationId,
|
|
3725
|
+
collection: msg.collection,
|
|
3726
|
+
recordId: msg.recordId,
|
|
3727
|
+
code: msg.code,
|
|
3728
|
+
message: msg.message,
|
|
3729
|
+
retriable: msg.retriable,
|
|
3730
|
+
rejectedAt: Date.now()
|
|
3731
|
+
});
|
|
3732
|
+
await this.refreshPendingCount();
|
|
3733
|
+
this.emitter?.emit({
|
|
3734
|
+
type: "sync:operation-rejected",
|
|
3735
|
+
operationId: msg.operationId,
|
|
3736
|
+
collection: msg.collection,
|
|
3737
|
+
recordId: msg.recordId,
|
|
3738
|
+
code: msg.code,
|
|
3739
|
+
message: msg.message,
|
|
3740
|
+
retriable: msg.retriable
|
|
3741
|
+
});
|
|
3742
|
+
}
|
|
3743
|
+
/**
|
|
3744
|
+
* Every operation the server rejected that has not yet been reconciled. The app
|
|
3745
|
+
* uses this (alongside the `sync:operation-rejected` event) to surface failed
|
|
3746
|
+
* submissions and decide whether to roll back or resubmit.
|
|
3747
|
+
*/
|
|
3748
|
+
getRejectedOperations() {
|
|
3749
|
+
return this.rejectedStorage.list();
|
|
3750
|
+
}
|
|
3751
|
+
/**
|
|
3752
|
+
* Forget rejected operations by id once the app has reconciled them (rolled the
|
|
3753
|
+
* optimistic write back or resubmitted a corrected op).
|
|
3754
|
+
*/
|
|
3755
|
+
clearRejectedOperations(operationIds) {
|
|
3756
|
+
return this.rejectedStorage.remove(operationIds);
|
|
3757
|
+
}
|
|
3075
3758
|
async checkDeltaComplete() {
|
|
3076
3759
|
if (!this.deltaSendComplete || !this.deltaReceiveComplete) {
|
|
3077
3760
|
return;
|
|
@@ -3170,7 +3853,7 @@ var SyncEngine = class {
|
|
|
3170
3853
|
return;
|
|
3171
3854
|
}
|
|
3172
3855
|
const ops = await this.store.getOperationRange(localNodeId, 1, localSeq);
|
|
3173
|
-
const inScope =
|
|
3856
|
+
const inScope = await this.filterAllowedForSync(ops);
|
|
3174
3857
|
for (const op of inScope) {
|
|
3175
3858
|
await this.outboundQueue.enqueue(op);
|
|
3176
3859
|
}
|
|
@@ -3181,6 +3864,7 @@ var SyncEngine = class {
|
|
|
3181
3864
|
const batch = this.outboundQueue.takeBatch(this.batchSize);
|
|
3182
3865
|
if (!batch) return;
|
|
3183
3866
|
this.currentBatch = batch;
|
|
3867
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3184
3868
|
if (this.encryptor) {
|
|
3185
3869
|
this.encryptor.encryptBatch(batch.operations).then(
|
|
3186
3870
|
(encrypted) => {
|
|
@@ -3193,6 +3877,7 @@ var SyncEngine = class {
|
|
|
3193
3877
|
batchIndex: 0
|
|
3194
3878
|
};
|
|
3195
3879
|
this.transport.send(batchMsg);
|
|
3880
|
+
this.startOutboundAckTimer(batch.batchId);
|
|
3196
3881
|
this.emitter?.emit({
|
|
3197
3882
|
type: "sync:sent",
|
|
3198
3883
|
operations: batch.operations,
|
|
@@ -3202,6 +3887,7 @@ var SyncEngine = class {
|
|
|
3202
3887
|
(err) => {
|
|
3203
3888
|
this.outboundQueue.returnBatch(batch.batchId);
|
|
3204
3889
|
this.currentBatch = null;
|
|
3890
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3205
3891
|
this.emitter?.emit({
|
|
3206
3892
|
type: "sync:disconnected",
|
|
3207
3893
|
reason: err instanceof Error ? err.message : "Encryption failed"
|
|
@@ -3218,6 +3904,7 @@ var SyncEngine = class {
|
|
|
3218
3904
|
batchIndex: 0
|
|
3219
3905
|
};
|
|
3220
3906
|
this.transport.send(batchMsg);
|
|
3907
|
+
this.startOutboundAckTimer(batch.batchId);
|
|
3221
3908
|
this.emitter?.emit({
|
|
3222
3909
|
type: "sync:sent",
|
|
3223
3910
|
operations: batch.operations,
|
|
@@ -3225,6 +3912,34 @@ var SyncEngine = class {
|
|
|
3225
3912
|
});
|
|
3226
3913
|
}
|
|
3227
3914
|
}
|
|
3915
|
+
startOutboundAckTimer(batchId) {
|
|
3916
|
+
this.clearOutboundAckTimer();
|
|
3917
|
+
if (this.outboundAckTimeoutMs <= 0) return;
|
|
3918
|
+
this.outboundAckTimer = setTimeout(() => {
|
|
3919
|
+
void this.handleOutboundAckTimeout(batchId);
|
|
3920
|
+
}, this.outboundAckTimeoutMs);
|
|
3921
|
+
}
|
|
3922
|
+
clearOutboundAckTimer() {
|
|
3923
|
+
if (!this.outboundAckTimer) return;
|
|
3924
|
+
clearTimeout(this.outboundAckTimer);
|
|
3925
|
+
this.outboundAckTimer = null;
|
|
3926
|
+
}
|
|
3927
|
+
async handleOutboundAckTimeout(batchId) {
|
|
3928
|
+
if (!this.currentBatch || this.currentBatch.batchId !== batchId) return;
|
|
3929
|
+
this.outboundQueue.returnBatch(batchId);
|
|
3930
|
+
this.currentBatch = null;
|
|
3931
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3932
|
+
this.clearOutboundAckTimer();
|
|
3933
|
+
await this.refreshPendingCount();
|
|
3934
|
+
if (this.state === "disconnected") return;
|
|
3935
|
+
const reason = `Timed out waiting for acknowledgment of outbound batch ${batchId}`;
|
|
3936
|
+
try {
|
|
3937
|
+
await this.transport.disconnect();
|
|
3938
|
+
} finally {
|
|
3939
|
+
this.ensureDisconnected();
|
|
3940
|
+
this.emitter?.emit({ type: "sync:disconnected", reason });
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3228
3943
|
handleAwarenessUpdate(msg) {
|
|
3229
3944
|
const awarenessMessage = {
|
|
3230
3945
|
type: "awareness",
|
|
@@ -3234,9 +3949,11 @@ var SyncEngine = class {
|
|
|
3234
3949
|
this.awarenessManager.handleRemoteMessage(awarenessMessage);
|
|
3235
3950
|
}
|
|
3236
3951
|
handleTransportClose(reason) {
|
|
3952
|
+
this.clearOutboundAckTimer();
|
|
3237
3953
|
if (this.currentBatch) {
|
|
3238
3954
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
3239
3955
|
this.currentBatch = null;
|
|
3956
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3240
3957
|
}
|
|
3241
3958
|
if (this.schemaBlocked) {
|
|
3242
3959
|
return;
|
|
@@ -3268,11 +3985,40 @@ var SyncEngine = class {
|
|
|
3268
3985
|
this.serializer.setWireFormat(format);
|
|
3269
3986
|
}
|
|
3270
3987
|
}
|
|
3271
|
-
operationAllowedForSync(op) {
|
|
3272
|
-
if (
|
|
3988
|
+
async operationAllowedForSync(op) {
|
|
3989
|
+
if (this.matchesScopeAndSubsets(op)) {
|
|
3990
|
+
return true;
|
|
3991
|
+
}
|
|
3992
|
+
const fullRecord = await this.readRecordForBackfill(op);
|
|
3993
|
+
if (!fullRecord) {
|
|
3273
3994
|
return false;
|
|
3274
3995
|
}
|
|
3275
|
-
return
|
|
3996
|
+
return this.matchesScopeAndSubsets(op, fullRecord);
|
|
3997
|
+
}
|
|
3998
|
+
matchesScopeAndSubsets(op, fullRecord) {
|
|
3999
|
+
if (!operationMatchesScope(op, this.activeScope, fullRecord)) {
|
|
4000
|
+
return false;
|
|
4001
|
+
}
|
|
4002
|
+
return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets(), fullRecord);
|
|
4003
|
+
}
|
|
4004
|
+
async readRecordForBackfill(op) {
|
|
4005
|
+
if (!this.store.readRecordFields) {
|
|
4006
|
+
return null;
|
|
4007
|
+
}
|
|
4008
|
+
try {
|
|
4009
|
+
return await this.store.readRecordFields(op.collection, op.recordId);
|
|
4010
|
+
} catch {
|
|
4011
|
+
return null;
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
4014
|
+
async filterAllowedForSync(ops) {
|
|
4015
|
+
const allowed = [];
|
|
4016
|
+
for (const op of ops) {
|
|
4017
|
+
if (await this.operationAllowedForSync(op)) {
|
|
4018
|
+
allowed.push(op);
|
|
4019
|
+
}
|
|
4020
|
+
}
|
|
4021
|
+
return allowed;
|
|
3276
4022
|
}
|
|
3277
4023
|
async persistDeltaCursor(cursor) {
|
|
3278
4024
|
if (!this.syncState?.saveDeltaCursor) {
|
|
@@ -3280,6 +4026,12 @@ var SyncEngine = class {
|
|
|
3280
4026
|
}
|
|
3281
4027
|
await this.syncState.saveDeltaCursor(cursor);
|
|
3282
4028
|
}
|
|
4029
|
+
async persistDeliveryWatermark(watermark, signature = this.deliverySignature()) {
|
|
4030
|
+
if (!this.syncState?.saveDeliveryWatermark) {
|
|
4031
|
+
return;
|
|
4032
|
+
}
|
|
4033
|
+
await this.syncState.saveDeliveryWatermark(signature, watermark);
|
|
4034
|
+
}
|
|
3283
4035
|
scheduleQuerySubsetReconnect() {
|
|
3284
4036
|
if (this.querySubsetReconnectTimer) {
|
|
3285
4037
|
clearTimeout(this.querySubsetReconnectTimer);
|
|
@@ -3292,8 +4044,7 @@ var SyncEngine = class {
|
|
|
3292
4044
|
}, 500);
|
|
3293
4045
|
}
|
|
3294
4046
|
async reconnectForQuerySubsets() {
|
|
3295
|
-
await this.
|
|
3296
|
-
await this.start();
|
|
4047
|
+
await this.reconnect();
|
|
3297
4048
|
}
|
|
3298
4049
|
};
|
|
3299
4050
|
function awarenessStatesToWire(states) {
|
|
@@ -3974,6 +4725,7 @@ function fromBase642(str) {
|
|
|
3974
4725
|
// src/reactivity/sync-status-controller.ts
|
|
3975
4726
|
var OFFLINE_SYNC_STATUS = Object.freeze({
|
|
3976
4727
|
status: "offline",
|
|
4728
|
+
reconnecting: false,
|
|
3977
4729
|
pendingOperations: 0,
|
|
3978
4730
|
lastSyncedAt: null,
|
|
3979
4731
|
lastSuccessfulPush: null,
|
|
@@ -3991,6 +4743,7 @@ var SYNC_STATUS_EVENT_TYPES = [
|
|
|
3991
4743
|
"sync:received",
|
|
3992
4744
|
"sync:acknowledged",
|
|
3993
4745
|
"sync:apply-failed",
|
|
4746
|
+
"sync:delivery-gap",
|
|
3994
4747
|
"sync:diagnostics",
|
|
3995
4748
|
"sync:initial-sync-progress"
|
|
3996
4749
|
];
|
|
@@ -4126,6 +4879,7 @@ function subscribeRemoteAwarenessStates(awareness, listener) {
|
|
|
4126
4879
|
}
|
|
4127
4880
|
export {
|
|
4128
4881
|
AwarenessManager,
|
|
4882
|
+
BlobChunkChannel,
|
|
4129
4883
|
ChaosTransport,
|
|
4130
4884
|
ConnectionMonitor,
|
|
4131
4885
|
DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD,
|
|
@@ -4135,6 +4889,7 @@ export {
|
|
|
4135
4889
|
InvalidScopeError,
|
|
4136
4890
|
JsonMessageSerializer,
|
|
4137
4891
|
KeyDerivationError,
|
|
4892
|
+
MemoryRejectedOperationStorage,
|
|
4138
4893
|
NegotiatedMessageSerializer,
|
|
4139
4894
|
OFFLINE_SYNC_STATUS,
|
|
4140
4895
|
OutboundQueue,
|
|
@@ -4150,11 +4905,13 @@ export {
|
|
|
4150
4905
|
WebSocketTransport,
|
|
4151
4906
|
createDeltaCursorFromBatch,
|
|
4152
4907
|
createSyncStatusController,
|
|
4908
|
+
decodeBlobChunkBytes,
|
|
4153
4909
|
decodeDeltaCursor,
|
|
4154
4910
|
decodeYjsUpdate,
|
|
4155
4911
|
dedupeQuerySubsets,
|
|
4156
4912
|
deriveKey,
|
|
4157
4913
|
deriveVersionedKey,
|
|
4914
|
+
encodeBlobChunkBytes,
|
|
4158
4915
|
encodeDeltaCursor,
|
|
4159
4916
|
encodeYjsUpdate,
|
|
4160
4917
|
filterOperationsByScope,
|
|
@@ -4162,12 +4919,16 @@ export {
|
|
|
4162
4919
|
getRemoteAwarenessStates,
|
|
4163
4920
|
isAcknowledgmentMessage,
|
|
4164
4921
|
isAwarenessUpdateMessage,
|
|
4922
|
+
isBlobChunkPushMessage,
|
|
4923
|
+
isBlobChunkRequestMessage,
|
|
4924
|
+
isBlobChunkResponseMessage,
|
|
4165
4925
|
isClientSchemaVersionSupported,
|
|
4166
4926
|
isEncryptedPayload,
|
|
4167
4927
|
isErrorMessage,
|
|
4168
4928
|
isHandshakeMessage,
|
|
4169
4929
|
isHandshakeResponseMessage,
|
|
4170
4930
|
isOperationBatchMessage,
|
|
4931
|
+
isOperationRejectedMessage,
|
|
4171
4932
|
isSchemaMismatchReject,
|
|
4172
4933
|
isSyncMessage,
|
|
4173
4934
|
isYjsDocUpdateMessage,
|