@korajs/sync 1.0.0-beta.1 → 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 +591 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +145 -3
- package/dist/index.d.ts +145 -3
- package/dist/index.js +589 -53
- package/dist/index.js.map +1 -1
- package/dist/internal.d.cts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/{transport-CZLMdZ0k.d.cts → transport-B2GQerqJ.d.cts} +137 -3
- package/dist/{transport-CZLMdZ0k.d.ts → transport-B2GQerqJ.d.ts} +137 -3
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -41,6 +41,7 @@ __export(index_exports, {
|
|
|
41
41
|
InvalidScopeError: () => InvalidScopeError,
|
|
42
42
|
JsonMessageSerializer: () => JsonMessageSerializer,
|
|
43
43
|
KeyDerivationError: () => KeyDerivationError,
|
|
44
|
+
MemoryRejectedOperationStorage: () => MemoryRejectedOperationStorage,
|
|
44
45
|
NegotiatedMessageSerializer: () => NegotiatedMessageSerializer,
|
|
45
46
|
OFFLINE_SYNC_STATUS: () => OFFLINE_SYNC_STATUS,
|
|
46
47
|
OutboundQueue: () => OutboundQueue,
|
|
@@ -79,6 +80,7 @@ __export(index_exports, {
|
|
|
79
80
|
isHandshakeMessage: () => isHandshakeMessage,
|
|
80
81
|
isHandshakeResponseMessage: () => isHandshakeResponseMessage,
|
|
81
82
|
isOperationBatchMessage: () => isOperationBatchMessage,
|
|
83
|
+
isOperationRejectedMessage: () => isOperationRejectedMessage,
|
|
82
84
|
isSchemaMismatchReject: () => isSchemaMismatchReject,
|
|
83
85
|
isSyncMessage: () => isSyncMessage,
|
|
84
86
|
isYjsDocUpdateMessage: () => isYjsDocUpdateMessage,
|
|
@@ -104,6 +106,7 @@ var SYNC_STATES = [
|
|
|
104
106
|
];
|
|
105
107
|
var SYNC_STATUSES = [
|
|
106
108
|
"connected",
|
|
109
|
+
"reconnecting",
|
|
107
110
|
"syncing",
|
|
108
111
|
"synced",
|
|
109
112
|
"offline",
|
|
@@ -111,6 +114,20 @@ var SYNC_STATUSES = [
|
|
|
111
114
|
"error",
|
|
112
115
|
"schema-mismatch"
|
|
113
116
|
];
|
|
117
|
+
var MemoryRejectedOperationStorage = class {
|
|
118
|
+
rejected = /* @__PURE__ */ new Map();
|
|
119
|
+
async record(rejected) {
|
|
120
|
+
this.rejected.set(rejected.operationId, rejected);
|
|
121
|
+
}
|
|
122
|
+
async list() {
|
|
123
|
+
return [...this.rejected.values()];
|
|
124
|
+
}
|
|
125
|
+
async remove(operationIds) {
|
|
126
|
+
for (const id of operationIds) {
|
|
127
|
+
this.rejected.delete(id);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
114
131
|
var ScopeViolationError = class extends import_core.KoraError {
|
|
115
132
|
constructor(operationId, collection, scope, message) {
|
|
116
133
|
super(
|
|
@@ -308,6 +325,8 @@ function isSyncMessage(value) {
|
|
|
308
325
|
return isAcknowledgmentMessage(value);
|
|
309
326
|
case "error":
|
|
310
327
|
return isErrorMessage(value);
|
|
328
|
+
case "operation-rejected":
|
|
329
|
+
return isOperationRejectedMessage(value);
|
|
311
330
|
case "awareness-update":
|
|
312
331
|
return isAwarenessUpdateMessage(value);
|
|
313
332
|
case "yjs-doc-update":
|
|
@@ -347,6 +366,11 @@ function isErrorMessage(value) {
|
|
|
347
366
|
const msg = value;
|
|
348
367
|
return msg.type === "error" && typeof msg.messageId === "string" && typeof msg.code === "string" && typeof msg.message === "string" && typeof msg.retriable === "boolean";
|
|
349
368
|
}
|
|
369
|
+
function isOperationRejectedMessage(value) {
|
|
370
|
+
if (typeof value !== "object" || value === null) return false;
|
|
371
|
+
const msg = value;
|
|
372
|
+
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";
|
|
373
|
+
}
|
|
350
374
|
function isAwarenessUpdateMessage(value) {
|
|
351
375
|
if (typeof value !== "object" || value === null) return false;
|
|
352
376
|
const msg = value;
|
|
@@ -597,7 +621,8 @@ function toProtoEnvelope(message) {
|
|
|
597
621
|
})),
|
|
598
622
|
schemaVersion: message.schemaVersion,
|
|
599
623
|
authToken: message.authToken,
|
|
600
|
-
supportedWireFormats: message.supportedWireFormats
|
|
624
|
+
supportedWireFormats: message.supportedWireFormats,
|
|
625
|
+
...message.lastDeliverySequence !== void 0 ? { lastDeliverySequence: message.lastDeliverySequence } : {}
|
|
601
626
|
};
|
|
602
627
|
case "handshake-response":
|
|
603
628
|
return {
|
|
@@ -613,7 +638,8 @@ function toProtoEnvelope(message) {
|
|
|
613
638
|
rejectReason: message.rejectReason,
|
|
614
639
|
selectedWireFormat: message.selectedWireFormat,
|
|
615
640
|
serverTime: message.serverTime,
|
|
616
|
-
blobStorageEnabled: message.blobStorageEnabled
|
|
641
|
+
blobStorageEnabled: message.blobStorageEnabled,
|
|
642
|
+
...message.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: message.serverMaxDeliverySequence } : {}
|
|
617
643
|
};
|
|
618
644
|
case "operation-batch":
|
|
619
645
|
return {
|
|
@@ -621,14 +647,17 @@ function toProtoEnvelope(message) {
|
|
|
621
647
|
messageId: message.messageId,
|
|
622
648
|
operations: message.operations.map(serializeProtoOperation),
|
|
623
649
|
isFinal: message.isFinal,
|
|
624
|
-
batchIndex: message.batchIndex
|
|
650
|
+
batchIndex: message.batchIndex,
|
|
651
|
+
...message.baseDeliverySequence !== void 0 ? { baseDeliverySequence: message.baseDeliverySequence } : {},
|
|
652
|
+
...message.maxDeliverySequence !== void 0 ? { maxDeliverySequence: message.maxDeliverySequence } : {}
|
|
625
653
|
};
|
|
626
654
|
case "acknowledgment":
|
|
627
655
|
return {
|
|
628
656
|
type: message.type,
|
|
629
657
|
messageId: message.messageId,
|
|
630
658
|
acknowledgedMessageId: message.acknowledgedMessageId,
|
|
631
|
-
lastSequenceNumber: message.lastSequenceNumber
|
|
659
|
+
lastSequenceNumber: message.lastSequenceNumber,
|
|
660
|
+
...message.deliverySequence !== void 0 ? { deliverySequence: message.deliverySequence } : {}
|
|
632
661
|
};
|
|
633
662
|
case "error":
|
|
634
663
|
return {
|
|
@@ -638,6 +667,17 @@ function toProtoEnvelope(message) {
|
|
|
638
667
|
errorMessage: message.message,
|
|
639
668
|
retriable: message.retriable
|
|
640
669
|
};
|
|
670
|
+
case "operation-rejected":
|
|
671
|
+
return {
|
|
672
|
+
type: message.type,
|
|
673
|
+
messageId: message.messageId,
|
|
674
|
+
operationId: message.operationId,
|
|
675
|
+
collection: message.collection,
|
|
676
|
+
recordId: message.recordId,
|
|
677
|
+
errorCode: message.code,
|
|
678
|
+
errorMessage: message.message,
|
|
679
|
+
retriable: message.retriable
|
|
680
|
+
};
|
|
641
681
|
case "awareness-update":
|
|
642
682
|
case "yjs-doc-update":
|
|
643
683
|
return {
|
|
@@ -682,7 +722,8 @@ function fromProtoEnvelope(envelope) {
|
|
|
682
722
|
authToken: envelope.authToken,
|
|
683
723
|
supportedWireFormats: envelope.supportedWireFormats?.filter(
|
|
684
724
|
(format) => format === "json" || format === "protobuf"
|
|
685
|
-
)
|
|
725
|
+
),
|
|
726
|
+
...envelope.lastDeliverySequence !== void 0 ? { lastDeliverySequence: envelope.lastDeliverySequence } : {}
|
|
686
727
|
};
|
|
687
728
|
case "handshake-response":
|
|
688
729
|
return {
|
|
@@ -699,7 +740,8 @@ function fromProtoEnvelope(envelope) {
|
|
|
699
740
|
// Preserve the server's wall-clock time so clock-skew detection and
|
|
700
741
|
// automatic timestamp rebase work over the protobuf wire, not just JSON.
|
|
701
742
|
...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {},
|
|
702
|
-
...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {}
|
|
743
|
+
...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {},
|
|
744
|
+
...envelope.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: envelope.serverMaxDeliverySequence } : {}
|
|
703
745
|
};
|
|
704
746
|
case "operation-batch":
|
|
705
747
|
return {
|
|
@@ -707,14 +749,17 @@ function fromProtoEnvelope(envelope) {
|
|
|
707
749
|
messageId: envelope.messageId,
|
|
708
750
|
operations: (envelope.operations ?? []).map(deserializeProtoOperation),
|
|
709
751
|
isFinal: envelope.isFinal ?? false,
|
|
710
|
-
batchIndex: envelope.batchIndex ?? 0
|
|
752
|
+
batchIndex: envelope.batchIndex ?? 0,
|
|
753
|
+
...envelope.baseDeliverySequence !== void 0 ? { baseDeliverySequence: envelope.baseDeliverySequence } : {},
|
|
754
|
+
...envelope.maxDeliverySequence !== void 0 ? { maxDeliverySequence: envelope.maxDeliverySequence } : {}
|
|
711
755
|
};
|
|
712
756
|
case "acknowledgment":
|
|
713
757
|
return {
|
|
714
758
|
type: "acknowledgment",
|
|
715
759
|
messageId: envelope.messageId,
|
|
716
760
|
acknowledgedMessageId: envelope.acknowledgedMessageId ?? "",
|
|
717
|
-
lastSequenceNumber: envelope.lastSequenceNumber ?? 0
|
|
761
|
+
lastSequenceNumber: envelope.lastSequenceNumber ?? 0,
|
|
762
|
+
...envelope.deliverySequence !== void 0 ? { deliverySequence: envelope.deliverySequence } : {}
|
|
718
763
|
};
|
|
719
764
|
case "error":
|
|
720
765
|
return {
|
|
@@ -724,6 +769,17 @@ function fromProtoEnvelope(envelope) {
|
|
|
724
769
|
message: envelope.errorMessage ?? "Unknown error",
|
|
725
770
|
retriable: envelope.retriable ?? false
|
|
726
771
|
};
|
|
772
|
+
case "operation-rejected":
|
|
773
|
+
return {
|
|
774
|
+
type: "operation-rejected",
|
|
775
|
+
messageId: envelope.messageId,
|
|
776
|
+
operationId: envelope.operationId ?? "",
|
|
777
|
+
collection: envelope.collection ?? "",
|
|
778
|
+
recordId: envelope.recordId ?? "",
|
|
779
|
+
code: envelope.errorCode ?? "REJECTED",
|
|
780
|
+
message: envelope.errorMessage ?? "",
|
|
781
|
+
retriable: envelope.retriable ?? false
|
|
782
|
+
};
|
|
727
783
|
case "blob-chunk-request":
|
|
728
784
|
return {
|
|
729
785
|
type: "blob-chunk-request",
|
|
@@ -898,6 +954,21 @@ function encodeEnvelope(envelope) {
|
|
|
898
954
|
if (envelope.hasBytes !== void 0) writer.uint32(184).bool(envelope.hasBytes);
|
|
899
955
|
if (envelope.blobStorageEnabled !== void 0)
|
|
900
956
|
writer.uint32(200).bool(envelope.blobStorageEnabled);
|
|
957
|
+
if (envelope.operationId && envelope.operationId.length > 0)
|
|
958
|
+
writer.uint32(210).string(envelope.operationId);
|
|
959
|
+
if (envelope.collection && envelope.collection.length > 0)
|
|
960
|
+
writer.uint32(218).string(envelope.collection);
|
|
961
|
+
if (envelope.recordId && envelope.recordId.length > 0)
|
|
962
|
+
writer.uint32(226).string(envelope.recordId);
|
|
963
|
+
if (envelope.lastDeliverySequence !== void 0)
|
|
964
|
+
writer.uint32(232).int64(envelope.lastDeliverySequence);
|
|
965
|
+
if (envelope.baseDeliverySequence !== void 0)
|
|
966
|
+
writer.uint32(240).int64(envelope.baseDeliverySequence);
|
|
967
|
+
if (envelope.maxDeliverySequence !== void 0)
|
|
968
|
+
writer.uint32(248).int64(envelope.maxDeliverySequence);
|
|
969
|
+
if (envelope.deliverySequence !== void 0) writer.uint32(256).int64(envelope.deliverySequence);
|
|
970
|
+
if (envelope.serverMaxDeliverySequence !== void 0)
|
|
971
|
+
writer.uint32(264).int64(envelope.serverMaxDeliverySequence);
|
|
901
972
|
return writer.finish();
|
|
902
973
|
}
|
|
903
974
|
function decodeEnvelope(bytes) {
|
|
@@ -984,6 +1055,30 @@ function decodeEnvelope(bytes) {
|
|
|
984
1055
|
case 25:
|
|
985
1056
|
envelope.blobStorageEnabled = reader.bool();
|
|
986
1057
|
break;
|
|
1058
|
+
case 26:
|
|
1059
|
+
envelope.operationId = reader.string();
|
|
1060
|
+
break;
|
|
1061
|
+
case 27:
|
|
1062
|
+
envelope.collection = reader.string();
|
|
1063
|
+
break;
|
|
1064
|
+
case 28:
|
|
1065
|
+
envelope.recordId = reader.string();
|
|
1066
|
+
break;
|
|
1067
|
+
case 29:
|
|
1068
|
+
envelope.lastDeliverySequence = longToNumber(reader.int64());
|
|
1069
|
+
break;
|
|
1070
|
+
case 30:
|
|
1071
|
+
envelope.baseDeliverySequence = longToNumber(reader.int64());
|
|
1072
|
+
break;
|
|
1073
|
+
case 31:
|
|
1074
|
+
envelope.maxDeliverySequence = longToNumber(reader.int64());
|
|
1075
|
+
break;
|
|
1076
|
+
case 32:
|
|
1077
|
+
envelope.deliverySequence = longToNumber(reader.int64());
|
|
1078
|
+
break;
|
|
1079
|
+
case 33:
|
|
1080
|
+
envelope.serverMaxDeliverySequence = longToNumber(reader.int64());
|
|
1081
|
+
break;
|
|
987
1082
|
default:
|
|
988
1083
|
reader.skipType(tag & 7);
|
|
989
1084
|
}
|
|
@@ -1483,6 +1578,7 @@ var ChaosTransport = class {
|
|
|
1483
1578
|
reorderRate;
|
|
1484
1579
|
maxLatency;
|
|
1485
1580
|
random;
|
|
1581
|
+
dropPredicate;
|
|
1486
1582
|
messageHandler = null;
|
|
1487
1583
|
reorderBuffer = [];
|
|
1488
1584
|
pending = [];
|
|
@@ -1493,6 +1589,7 @@ var ChaosTransport = class {
|
|
|
1493
1589
|
this.reorderRate = config?.reorderRate ?? 0;
|
|
1494
1590
|
this.maxLatency = config?.maxLatency ?? 0;
|
|
1495
1591
|
this.random = config?.randomSource ?? Math.random;
|
|
1592
|
+
this.dropPredicate = config?.dropPredicate ?? null;
|
|
1496
1593
|
}
|
|
1497
1594
|
async connect(url, options) {
|
|
1498
1595
|
this.inner.onMessage((msg) => this.handleIncoming(msg));
|
|
@@ -1511,6 +1608,9 @@ var ChaosTransport = class {
|
|
|
1511
1608
|
return this.inner.disconnect();
|
|
1512
1609
|
}
|
|
1513
1610
|
send(message) {
|
|
1611
|
+
if (this.dropPredicate?.(message, "outgoing")) {
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1514
1614
|
if (this.random() < this.dropRate) {
|
|
1515
1615
|
return;
|
|
1516
1616
|
}
|
|
@@ -1538,6 +1638,9 @@ var ChaosTransport = class {
|
|
|
1538
1638
|
}
|
|
1539
1639
|
handleIncoming(message) {
|
|
1540
1640
|
if (!this.messageHandler) return;
|
|
1641
|
+
if (this.dropPredicate?.(message, "incoming")) {
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1541
1644
|
if (this.random() < this.dropRate) {
|
|
1542
1645
|
return;
|
|
1543
1646
|
}
|
|
@@ -2502,6 +2605,32 @@ var OutboundQueue = class {
|
|
|
2502
2605
|
const ids = ops.map((op) => op.id);
|
|
2503
2606
|
await this.storage.dequeue(ids);
|
|
2504
2607
|
}
|
|
2608
|
+
/**
|
|
2609
|
+
* Acknowledge only operations in an in-flight batch whose local sequence number is
|
|
2610
|
+
* covered by the server's ack. Any later operations return to the front of the
|
|
2611
|
+
* queue for retry. This is required when the server accepts a prefix but rejects a
|
|
2612
|
+
* later operation retriably (for example a stale-scope push after auth changed).
|
|
2613
|
+
*/
|
|
2614
|
+
async acknowledgeThrough(batchId, lastSequenceNumber) {
|
|
2615
|
+
const ops = this.inFlight.get(batchId);
|
|
2616
|
+
if (!ops) return;
|
|
2617
|
+
this.inFlight.delete(batchId);
|
|
2618
|
+
const acknowledged = ops.filter((op) => op.sequenceNumber <= lastSequenceNumber);
|
|
2619
|
+
const retry = ops.filter((op) => op.sequenceNumber > lastSequenceNumber);
|
|
2620
|
+
if (retry.length > 0) {
|
|
2621
|
+
this.queue.unshift(...retry);
|
|
2622
|
+
if (this.queue.length > 1) {
|
|
2623
|
+
this.queue = (0, import_internal.topologicalSort)(this.queue);
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
if (acknowledged.length > 0) {
|
|
2627
|
+
const ids = acknowledged.map((op) => op.id);
|
|
2628
|
+
for (const id of ids) {
|
|
2629
|
+
this.seen.delete(id);
|
|
2630
|
+
}
|
|
2631
|
+
await this.storage.dequeue(ids);
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2505
2634
|
/**
|
|
2506
2635
|
* Return a failed batch to the front of the queue for retry.
|
|
2507
2636
|
* Prepends the operations to maintain priority.
|
|
@@ -2584,6 +2713,37 @@ var OutboundQueue = class {
|
|
|
2584
2713
|
get isInitialized() {
|
|
2585
2714
|
return this.initialized;
|
|
2586
2715
|
}
|
|
2716
|
+
/**
|
|
2717
|
+
* Remove a single operation the server permanently rejected — from the queue,
|
|
2718
|
+
* from any in-flight batch, and from durable storage — so a later batch ack or
|
|
2719
|
+
* a reconnect `returnBatch` can never resend or resurrect it. Returns the
|
|
2720
|
+
* removed operation so the caller can record it in a durable rejected store,
|
|
2721
|
+
* or null if it was not present (already acknowledged and removed).
|
|
2722
|
+
*/
|
|
2723
|
+
async reject(opId) {
|
|
2724
|
+
let removed = null;
|
|
2725
|
+
for (const [batchId, ops] of this.inFlight) {
|
|
2726
|
+
const idx = ops.findIndex((op) => op.id === opId);
|
|
2727
|
+
if (idx !== -1) {
|
|
2728
|
+
removed = ops[idx] ?? null;
|
|
2729
|
+
ops.splice(idx, 1);
|
|
2730
|
+
if (ops.length === 0) {
|
|
2731
|
+
this.inFlight.delete(batchId);
|
|
2732
|
+
}
|
|
2733
|
+
break;
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
if (!removed) {
|
|
2737
|
+
const idx = this.queue.findIndex((op) => op.id === opId);
|
|
2738
|
+
if (idx !== -1) {
|
|
2739
|
+
removed = this.queue[idx] ?? null;
|
|
2740
|
+
this.queue.splice(idx, 1);
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
this.seen.delete(opId);
|
|
2744
|
+
await this.storage.dequeue([opId]);
|
|
2745
|
+
return removed;
|
|
2746
|
+
}
|
|
2587
2747
|
/**
|
|
2588
2748
|
* Remove operations by id from queue and persistent storage.
|
|
2589
2749
|
* Used when ops were already sent during handshake delta exchange.
|
|
@@ -2602,6 +2762,7 @@ var OutboundQueue = class {
|
|
|
2602
2762
|
// src/engine/sync-engine.ts
|
|
2603
2763
|
var DEFAULT_BATCH_SIZE = 100;
|
|
2604
2764
|
var DEFAULT_SCHEMA_VERSION = 1;
|
|
2765
|
+
var DEFAULT_OUTBOUND_ACK_TIMEOUT_MS = 3e4;
|
|
2605
2766
|
var VALID_TRANSITIONS = {
|
|
2606
2767
|
disconnected: ["connecting"],
|
|
2607
2768
|
connecting: ["handshaking", "error", "disconnected"],
|
|
@@ -2615,6 +2776,17 @@ var nextQuerySubsetId = 0;
|
|
|
2615
2776
|
function generateMessageId() {
|
|
2616
2777
|
return `msg-${Date.now()}-${nextMessageId++}`;
|
|
2617
2778
|
}
|
|
2779
|
+
var MAX_DELIVERY_VIEW_WATERMARKS = 64;
|
|
2780
|
+
function stableStringify(value) {
|
|
2781
|
+
if (value === null || typeof value !== "object") {
|
|
2782
|
+
return JSON.stringify(value) ?? "null";
|
|
2783
|
+
}
|
|
2784
|
+
if (Array.isArray(value)) {
|
|
2785
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
2786
|
+
}
|
|
2787
|
+
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)}`);
|
|
2788
|
+
return `{${entries.join(",")}}`;
|
|
2789
|
+
}
|
|
2618
2790
|
var SyncEngine = class {
|
|
2619
2791
|
state = "disconnected";
|
|
2620
2792
|
transport;
|
|
@@ -2623,7 +2795,9 @@ var SyncEngine = class {
|
|
|
2623
2795
|
serializer;
|
|
2624
2796
|
emitter;
|
|
2625
2797
|
outboundQueue;
|
|
2798
|
+
rejectedStorage;
|
|
2626
2799
|
batchSize;
|
|
2800
|
+
outboundAckTimeoutMs;
|
|
2627
2801
|
encryptor;
|
|
2628
2802
|
awarenessManager;
|
|
2629
2803
|
richtextDocChannel;
|
|
@@ -2638,11 +2812,16 @@ var SyncEngine = class {
|
|
|
2638
2812
|
lastSuccessfulPull = null;
|
|
2639
2813
|
conflictCount = 0;
|
|
2640
2814
|
currentBatch = null;
|
|
2815
|
+
currentBatchRequiresPartialAck = false;
|
|
2641
2816
|
reconnecting = false;
|
|
2642
2817
|
schemaBlocked = false;
|
|
2643
2818
|
clockBlocked = false;
|
|
2644
2819
|
clockSkewMs = null;
|
|
2645
2820
|
blobStorageEnabled = false;
|
|
2821
|
+
outboundAckTimer = null;
|
|
2822
|
+
startPromise = null;
|
|
2823
|
+
stopPromise = null;
|
|
2824
|
+
reconnectPromise = null;
|
|
2646
2825
|
// Track delta exchange state
|
|
2647
2826
|
deltaBatchesReceived = 0;
|
|
2648
2827
|
deltaReceiveComplete = false;
|
|
@@ -2663,6 +2842,27 @@ var SyncEngine = class {
|
|
|
2663
2842
|
/** Resume cursor for paginated initial sync (persisted across reconnects) */
|
|
2664
2843
|
resumeDeltaCursor = null;
|
|
2665
2844
|
initialSyncTotalBatches = 0;
|
|
2845
|
+
/**
|
|
2846
|
+
* The delivery watermark for the CURRENT sync view: the highest server delivery
|
|
2847
|
+
* sequence up to which every operation in the current view (scope + active query
|
|
2848
|
+
* subscriptions) has been contiguously applied. Advanced only through the server's
|
|
2849
|
+
* gap-free delivery stream (never through live relay), so it is a durable lower bound
|
|
2850
|
+
* on what this client holds for this view. Reported at handshake to resume the stream.
|
|
2851
|
+
*
|
|
2852
|
+
* The watermark is keyed by view: switching views (a scope or subscription change)
|
|
2853
|
+
* saves the current view's watermark and loads the target view's, so returning to a
|
|
2854
|
+
* previously-synced view resumes exactly where it left off instead of re-syncing. This
|
|
2855
|
+
* is correct because a view's watermark is a lower bound specific to that view's
|
|
2856
|
+
* filter; a widened view is simply a different (initially unsynced) view.
|
|
2857
|
+
*/
|
|
2858
|
+
deliveryWatermark = 0;
|
|
2859
|
+
/**
|
|
2860
|
+
* In-memory cache of watermark per view signature, preloaded from persistence at start
|
|
2861
|
+
* so a view switch resolves synchronously (no race with the reconnect it triggers).
|
|
2862
|
+
*/
|
|
2863
|
+
deliverySignatureWatermarks = /* @__PURE__ */ new Map();
|
|
2864
|
+
deliveryGapRepeatCount = 0;
|
|
2865
|
+
lastDeliveryGapKey = null;
|
|
2666
2866
|
constructor(options) {
|
|
2667
2867
|
this.transport = options.transport;
|
|
2668
2868
|
this.store = options.store;
|
|
@@ -2670,11 +2870,13 @@ var SyncEngine = class {
|
|
|
2670
2870
|
this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
|
|
2671
2871
|
this.emitter = options.emitter ?? null;
|
|
2672
2872
|
this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2873
|
+
this.outboundAckTimeoutMs = options.config.outboundAckTimeoutMs ?? DEFAULT_OUTBOUND_ACK_TIMEOUT_MS;
|
|
2673
2874
|
this.encryptor = options.encryptor ?? null;
|
|
2674
2875
|
this.syncState = options.syncState ?? null;
|
|
2675
2876
|
this.activeScope = options.config.scopeMap;
|
|
2676
2877
|
const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
|
|
2677
2878
|
this.outboundQueue = new OutboundQueue(queueStorage);
|
|
2879
|
+
this.rejectedStorage = options.rejectedStorage ?? new MemoryRejectedOperationStorage();
|
|
2678
2880
|
this.metricsCollector = new SyncMetricsCollector(options.metricsConfig);
|
|
2679
2881
|
if (this.emitter) {
|
|
2680
2882
|
this.metricsCollector.attachEmitter(this.emitter);
|
|
@@ -2714,10 +2916,20 @@ var SyncEngine = class {
|
|
|
2714
2916
|
* Start the sync engine: connect → handshake → delta exchange → streaming.
|
|
2715
2917
|
*/
|
|
2716
2918
|
async start() {
|
|
2919
|
+
if (this.state === "streaming") return;
|
|
2920
|
+
if (this.startPromise) return this.startPromise;
|
|
2921
|
+
this.startPromise = this.startInternal().finally(() => {
|
|
2922
|
+
this.startPromise = null;
|
|
2923
|
+
});
|
|
2924
|
+
return this.startPromise;
|
|
2925
|
+
}
|
|
2926
|
+
async startInternal() {
|
|
2927
|
+
if (this.stopPromise) {
|
|
2928
|
+
await this.stopPromise;
|
|
2929
|
+
}
|
|
2930
|
+
if (this.state === "streaming") return;
|
|
2717
2931
|
if (this.state !== "disconnected") {
|
|
2718
|
-
|
|
2719
|
-
currentState: this.state
|
|
2720
|
-
});
|
|
2932
|
+
await this.stop();
|
|
2721
2933
|
}
|
|
2722
2934
|
await this.outboundQueue.initialize();
|
|
2723
2935
|
if (this.syncState) {
|
|
@@ -2725,6 +2937,16 @@ var SyncEngine = class {
|
|
|
2725
2937
|
if (this.syncState.loadDeltaCursor) {
|
|
2726
2938
|
this.resumeDeltaCursor = await this.syncState.loadDeltaCursor();
|
|
2727
2939
|
}
|
|
2940
|
+
if (this.syncState.loadAllDeliveryWatermarks) {
|
|
2941
|
+
const all = await this.syncState.loadAllDeliveryWatermarks();
|
|
2942
|
+
for (const [signature, watermark] of Object.entries(all)) {
|
|
2943
|
+
this.deliverySignatureWatermarks.set(signature, watermark);
|
|
2944
|
+
}
|
|
2945
|
+
} else if (this.syncState.loadDeliveryWatermark) {
|
|
2946
|
+
this.deliverySignatureWatermarks.set("", await this.syncState.loadDeliveryWatermark(""));
|
|
2947
|
+
}
|
|
2948
|
+
this.deliveryWatermark = this.deliverySignatureWatermarks.get(this.deliverySignature()) ?? 0;
|
|
2949
|
+
this.evictColdViewWatermarks();
|
|
2728
2950
|
}
|
|
2729
2951
|
await this.reconcileOutboundFromOpLog();
|
|
2730
2952
|
await this.refreshPendingCount();
|
|
@@ -2740,7 +2962,12 @@ var SyncEngine = class {
|
|
|
2740
2962
|
this.transitionTo("connecting");
|
|
2741
2963
|
try {
|
|
2742
2964
|
const authToken = this.config.auth ? (await this.config.auth()).token : void 0;
|
|
2965
|
+
if (this.state !== "connecting") return;
|
|
2743
2966
|
await this.transport.connect(this.config.url, { authToken });
|
|
2967
|
+
if (this.state !== "connecting") {
|
|
2968
|
+
await this.transport.disconnect();
|
|
2969
|
+
return;
|
|
2970
|
+
}
|
|
2744
2971
|
this.transitionTo("handshaking");
|
|
2745
2972
|
const localVector = this.store.getVersionVector();
|
|
2746
2973
|
const activeQuerySubsets = this.getActiveQuerySubsets();
|
|
@@ -2754,7 +2981,11 @@ var SyncEngine = class {
|
|
|
2754
2981
|
supportedWireFormats: ["json", "protobuf"],
|
|
2755
2982
|
...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
|
|
2756
2983
|
...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
|
|
2757
|
-
...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {}
|
|
2984
|
+
...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {},
|
|
2985
|
+
// Resume the server's gap-free delivery stream from our watermark. A server
|
|
2986
|
+
// that understands it drives server->client sync from delivery sequences; an
|
|
2987
|
+
// older server ignores it and falls back to the version-vector delta.
|
|
2988
|
+
lastDeliverySequence: this.deliveryWatermark
|
|
2758
2989
|
};
|
|
2759
2990
|
this.transport.send(handshake);
|
|
2760
2991
|
} catch (err) {
|
|
@@ -2766,11 +2997,20 @@ var SyncEngine = class {
|
|
|
2766
2997
|
* Stop the sync engine. Disconnects the transport.
|
|
2767
2998
|
*/
|
|
2768
2999
|
async stop() {
|
|
3000
|
+
if (this.stopPromise) return this.stopPromise;
|
|
3001
|
+
this.stopPromise = this.stopInternal().finally(() => {
|
|
3002
|
+
this.stopPromise = null;
|
|
3003
|
+
});
|
|
3004
|
+
return this.stopPromise;
|
|
3005
|
+
}
|
|
3006
|
+
async stopInternal() {
|
|
2769
3007
|
if (this.state === "disconnected") return;
|
|
3008
|
+
this.clearOutboundAckTimer();
|
|
2770
3009
|
this.awarenessManager.stopCleanupTimer();
|
|
2771
3010
|
if (this.currentBatch) {
|
|
2772
3011
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
2773
3012
|
this.currentBatch = null;
|
|
3013
|
+
this.currentBatchRequiresPartialAck = false;
|
|
2774
3014
|
}
|
|
2775
3015
|
try {
|
|
2776
3016
|
await this.transport.disconnect();
|
|
@@ -2791,7 +3031,7 @@ var SyncEngine = class {
|
|
|
2791
3031
|
* because they should remain local-only and not be sent to the server.
|
|
2792
3032
|
*/
|
|
2793
3033
|
async pushOperation(op) {
|
|
2794
|
-
if (!this.operationAllowedForSync(op)) {
|
|
3034
|
+
if (!await this.operationAllowedForSync(op)) {
|
|
2795
3035
|
return;
|
|
2796
3036
|
}
|
|
2797
3037
|
if (this.syncState) {
|
|
@@ -2820,6 +3060,7 @@ var SyncEngine = class {
|
|
|
2820
3060
|
getStatus() {
|
|
2821
3061
|
const pendingOperations = this.syncState ? this.cachedUnsyncedCount : this.outboundQueue.totalPending;
|
|
2822
3062
|
const base = {
|
|
3063
|
+
reconnecting: this.reconnecting,
|
|
2823
3064
|
pendingOperations,
|
|
2824
3065
|
lastSyncedAt: this.lastSyncedAt,
|
|
2825
3066
|
lastSuccessfulPush: this.lastSuccessfulPush,
|
|
@@ -2839,7 +3080,7 @@ var SyncEngine = class {
|
|
|
2839
3080
|
case "connecting":
|
|
2840
3081
|
case "handshaking":
|
|
2841
3082
|
case "syncing":
|
|
2842
|
-
return { ...base, status: this.reconnecting ? "
|
|
3083
|
+
return { ...base, status: this.reconnecting ? "reconnecting" : "syncing" };
|
|
2843
3084
|
case "streaming":
|
|
2844
3085
|
return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
|
|
2845
3086
|
case "error":
|
|
@@ -2924,6 +3165,24 @@ var SyncEngine = class {
|
|
|
2924
3165
|
await this.start();
|
|
2925
3166
|
}
|
|
2926
3167
|
}
|
|
3168
|
+
/**
|
|
3169
|
+
* Atomically refresh the transport session. Overlapping callers share one
|
|
3170
|
+
* reconnect, in-flight outbound batches are returned to the queue by stop(), and the
|
|
3171
|
+
* next start handshakes with the latest auth token, sync scope, and query subsets.
|
|
3172
|
+
*/
|
|
3173
|
+
async reconnect() {
|
|
3174
|
+
if (this.schemaBlocked) return;
|
|
3175
|
+
if (this.reconnectPromise) return this.reconnectPromise;
|
|
3176
|
+
this.reconnecting = false;
|
|
3177
|
+
this.reconnectPromise = this.reconnectInternal().finally(() => {
|
|
3178
|
+
this.reconnectPromise = null;
|
|
3179
|
+
});
|
|
3180
|
+
return this.reconnectPromise;
|
|
3181
|
+
}
|
|
3182
|
+
async reconnectInternal() {
|
|
3183
|
+
await this.stop();
|
|
3184
|
+
await this.start();
|
|
3185
|
+
}
|
|
2927
3186
|
/**
|
|
2928
3187
|
* Export a diagnostics snapshot for debugging and support tickets.
|
|
2929
3188
|
* Contains connection state, timing info, and queue metrics.
|
|
@@ -2942,6 +3201,8 @@ var SyncEngine = class {
|
|
|
2942
3201
|
pendingOperations: this.outboundQueue.totalPending,
|
|
2943
3202
|
hasInFlightBatch: this.currentBatch !== null,
|
|
2944
3203
|
reconnecting: this.reconnecting,
|
|
3204
|
+
deliveryWatermark: this.deliveryWatermark,
|
|
3205
|
+
deliveryGapRepeatCount: this.deliveryGapRepeatCount,
|
|
2945
3206
|
timestamp: Date.now()
|
|
2946
3207
|
};
|
|
2947
3208
|
}
|
|
@@ -2970,8 +3231,84 @@ var SyncEngine = class {
|
|
|
2970
3231
|
* @param scopeMap - New per-collection scope filters, or undefined to remove scope
|
|
2971
3232
|
*/
|
|
2972
3233
|
updateScope(scopeMap) {
|
|
3234
|
+
const previousSignature = this.deliverySignature();
|
|
2973
3235
|
this.activeScope = scopeMap;
|
|
2974
3236
|
this.config.scopeMap = scopeMap;
|
|
3237
|
+
this.switchDeliveryView(previousSignature);
|
|
3238
|
+
}
|
|
3239
|
+
/**
|
|
3240
|
+
* A stable identifier for the current sync view: the auth/tenant scope plus the set of
|
|
3241
|
+
* active query subscriptions, canonicalized so the same view always produces the same
|
|
3242
|
+
* string. The delivery watermark is keyed by this, so each distinct view resumes from
|
|
3243
|
+
* its own last position.
|
|
3244
|
+
*/
|
|
3245
|
+
deliverySignature() {
|
|
3246
|
+
const subsets = this.getActiveQuerySubsets();
|
|
3247
|
+
if (!this.activeScope && subsets.length === 0) {
|
|
3248
|
+
return "";
|
|
3249
|
+
}
|
|
3250
|
+
const normalizedSubsets = subsets.map((s) => `${s.collection}:${stableStringify(s.where)}`).sort();
|
|
3251
|
+
return stableStringify({ scope: this.activeScope ?? null, subsets: normalizedSubsets });
|
|
3252
|
+
}
|
|
3253
|
+
/**
|
|
3254
|
+
* Switch the active delivery watermark to the current view after a scope or subscription
|
|
3255
|
+
* change. The prior view's watermark is saved so returning to it resumes; the new view's
|
|
3256
|
+
* watermark is restored from the in-memory cache (0 for a never-synced view, which then
|
|
3257
|
+
* does a one-time resync under that view). No data is lost: a view's watermark is a lower
|
|
3258
|
+
* bound for that specific view's filter.
|
|
3259
|
+
*/
|
|
3260
|
+
switchDeliveryView(previousSignature) {
|
|
3261
|
+
const nextSignature = this.deliverySignature();
|
|
3262
|
+
if (nextSignature === previousSignature) {
|
|
3263
|
+
return;
|
|
3264
|
+
}
|
|
3265
|
+
this.setViewWatermark(previousSignature, this.deliveryWatermark);
|
|
3266
|
+
void this.persistDeliveryWatermark(this.deliveryWatermark, previousSignature);
|
|
3267
|
+
this.deliveryWatermark = this.deliverySignatureWatermarks.get(nextSignature) ?? 0;
|
|
3268
|
+
}
|
|
3269
|
+
/**
|
|
3270
|
+
* Record a view's watermark in the in-memory cache as the most-recently-used entry (the
|
|
3271
|
+
* Map preserves insertion order, so re-inserting moves it to the tail), then evict cold
|
|
3272
|
+
* views past the retention cap. This is the single place the cache is written, so LRU
|
|
3273
|
+
* order and the size bound are always maintained together.
|
|
3274
|
+
*/
|
|
3275
|
+
setViewWatermark(signature, watermark) {
|
|
3276
|
+
this.deliverySignatureWatermarks.delete(signature);
|
|
3277
|
+
this.deliverySignatureWatermarks.set(signature, watermark);
|
|
3278
|
+
this.evictColdViewWatermarks();
|
|
3279
|
+
}
|
|
3280
|
+
/**
|
|
3281
|
+
* Trim the per-view watermark cache to `MAX_DELIVERY_VIEW_WATERMARKS`, evicting the
|
|
3282
|
+
* least-recently-used views. The default view ('') and the live view are never evicted
|
|
3283
|
+
* (evicting the live view would force an immediate re-scan of what we are actively
|
|
3284
|
+
* syncing). Each eviction also removes the persisted row, bounding storage. Safe by
|
|
3285
|
+
* construction: an evicted view simply back-fills from 0 (deduplicated) when next seen.
|
|
3286
|
+
*/
|
|
3287
|
+
evictColdViewWatermarks() {
|
|
3288
|
+
if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
|
|
3289
|
+
return;
|
|
3290
|
+
}
|
|
3291
|
+
const liveSignature = this.deliverySignature();
|
|
3292
|
+
for (const signature of this.deliverySignatureWatermarks.keys()) {
|
|
3293
|
+
if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
|
|
3294
|
+
break;
|
|
3295
|
+
}
|
|
3296
|
+
if (signature === "" || signature === liveSignature) {
|
|
3297
|
+
continue;
|
|
3298
|
+
}
|
|
3299
|
+
this.deliverySignatureWatermarks.delete(signature);
|
|
3300
|
+
void this.deleteDeliveryWatermark(signature);
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
/**
|
|
3304
|
+
* Remove a view's persisted watermark row (best-effort; a persistence layer that omits
|
|
3305
|
+
* the delete simply keeps the row, which is harmless).
|
|
3306
|
+
*/
|
|
3307
|
+
async deleteDeliveryWatermark(signature) {
|
|
3308
|
+
if (!this.syncState?.deleteDeliveryWatermark) {
|
|
3309
|
+
return;
|
|
3310
|
+
}
|
|
3311
|
+
await this.syncState.deleteDeliveryWatermark(signature);
|
|
2975
3312
|
}
|
|
2976
3313
|
/**
|
|
2977
3314
|
* Get the currently active scope map. Returns undefined if no scope is configured.
|
|
@@ -2985,10 +3322,14 @@ var SyncEngine = class {
|
|
|
2985
3322
|
*/
|
|
2986
3323
|
registerQuerySubset(subset) {
|
|
2987
3324
|
const id = `query-${nextQuerySubsetId++}`;
|
|
3325
|
+
const previousSignature = this.deliverySignature();
|
|
2988
3326
|
this.querySubsets.set(id, subset);
|
|
3327
|
+
this.switchDeliveryView(previousSignature);
|
|
2989
3328
|
this.scheduleQuerySubsetReconnect();
|
|
2990
3329
|
return () => {
|
|
3330
|
+
const sigBeforeRemove = this.deliverySignature();
|
|
2991
3331
|
this.querySubsets.delete(id);
|
|
3332
|
+
this.switchDeliveryView(sigBeforeRemove);
|
|
2992
3333
|
this.scheduleQuerySubsetReconnect();
|
|
2993
3334
|
};
|
|
2994
3335
|
}
|
|
@@ -3051,11 +3392,14 @@ var SyncEngine = class {
|
|
|
3051
3392
|
await this.handleOperationBatch(message);
|
|
3052
3393
|
break;
|
|
3053
3394
|
case "acknowledgment":
|
|
3054
|
-
this.handleAcknowledgment(message);
|
|
3395
|
+
await this.handleAcknowledgment(message);
|
|
3055
3396
|
break;
|
|
3056
3397
|
case "error":
|
|
3057
3398
|
this.handleError(message);
|
|
3058
3399
|
break;
|
|
3400
|
+
case "operation-rejected":
|
|
3401
|
+
await this.handleOperationRejected(message);
|
|
3402
|
+
break;
|
|
3059
3403
|
case "awareness-update":
|
|
3060
3404
|
this.handleAwarenessUpdate(message);
|
|
3061
3405
|
break;
|
|
@@ -3138,6 +3482,11 @@ var SyncEngine = class {
|
|
|
3138
3482
|
}
|
|
3139
3483
|
this.remoteVector = wireToVersionVector(msg.versionVector);
|
|
3140
3484
|
void this.persistLastAckedServerVector(this.remoteVector);
|
|
3485
|
+
if (typeof msg.serverMaxDeliverySequence === "number" && this.deliveryWatermark > msg.serverMaxDeliverySequence) {
|
|
3486
|
+
this.deliveryWatermark = 0;
|
|
3487
|
+
this.setViewWatermark(this.deliverySignature(), 0);
|
|
3488
|
+
await this.persistDeliveryWatermark(0);
|
|
3489
|
+
}
|
|
3141
3490
|
if (msg.selectedWireFormat) {
|
|
3142
3491
|
this.setSerializerWireFormat(msg.selectedWireFormat);
|
|
3143
3492
|
}
|
|
@@ -3203,7 +3552,7 @@ var SyncEngine = class {
|
|
|
3203
3552
|
async sendDelta() {
|
|
3204
3553
|
const localVector = this.store.getVersionVector();
|
|
3205
3554
|
const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
|
|
3206
|
-
const missingOps =
|
|
3555
|
+
const missingOps = await this.filterAllowedForSync(allMissingOps);
|
|
3207
3556
|
this.deltaSentOpIds = missingOps.map((op) => op.id);
|
|
3208
3557
|
if (missingOps.length === 0) {
|
|
3209
3558
|
const messageId = generateMessageId();
|
|
@@ -3278,11 +3627,34 @@ var SyncEngine = class {
|
|
|
3278
3627
|
return missing;
|
|
3279
3628
|
}
|
|
3280
3629
|
async handleOperationBatch(msg) {
|
|
3630
|
+
const isDeliveryBatch = msg.maxDeliverySequence !== void 0;
|
|
3631
|
+
if (isDeliveryBatch) {
|
|
3632
|
+
const base = msg.baseDeliverySequence ?? 0;
|
|
3633
|
+
if (base > this.deliveryWatermark) {
|
|
3634
|
+
const gapKey = `${this.deliveryWatermark}:${base}`;
|
|
3635
|
+
this.deliveryGapRepeatCount = this.lastDeliveryGapKey === gapKey ? this.deliveryGapRepeatCount + 1 : 1;
|
|
3636
|
+
this.lastDeliveryGapKey = gapKey;
|
|
3637
|
+
this.emitter?.emit({
|
|
3638
|
+
type: "sync:delivery-gap",
|
|
3639
|
+
expectedBase: this.deliveryWatermark,
|
|
3640
|
+
receivedBase: base,
|
|
3641
|
+
currentWatermark: this.deliveryWatermark,
|
|
3642
|
+
messageId: msg.messageId,
|
|
3643
|
+
repeatCount: this.deliveryGapRepeatCount
|
|
3644
|
+
});
|
|
3645
|
+
return;
|
|
3646
|
+
}
|
|
3647
|
+
if (base < this.deliveryWatermark) {
|
|
3648
|
+
this.sendDeliveryAck(msg.messageId, this.deliveryWatermark);
|
|
3649
|
+
return;
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3281
3652
|
const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
3282
3653
|
const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
|
|
3283
|
-
const inScopeOps =
|
|
3654
|
+
const inScopeOps = await this.filterAllowedForSync(operations);
|
|
3284
3655
|
const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
3285
3656
|
const transforms = this.config.operationTransforms ?? [];
|
|
3657
|
+
let fullyApplied = true;
|
|
3286
3658
|
for (const op of inScopeOps) {
|
|
3287
3659
|
const transformed = transforms.length > 0 ? (0, import_core7.applyOperationTransforms)(op, targetSchemaVersion, transforms) : op;
|
|
3288
3660
|
if (transformed === null) {
|
|
@@ -3294,6 +3666,7 @@ var SyncEngine = class {
|
|
|
3294
3666
|
this.emitApplyFailure(transformed, result);
|
|
3295
3667
|
}
|
|
3296
3668
|
} catch (error) {
|
|
3669
|
+
fullyApplied = false;
|
|
3297
3670
|
if (error instanceof import_core7.ClockDriftError) {
|
|
3298
3671
|
this.emitApplyFailure(transformed, "rejected", {
|
|
3299
3672
|
code: import_core7.APPLY_FAILURE_CODES.CLOCK_DRIFT,
|
|
@@ -3304,11 +3677,8 @@ var SyncEngine = class {
|
|
|
3304
3677
|
}
|
|
3305
3678
|
const message = error instanceof Error ? error.message : "Apply failed";
|
|
3306
3679
|
const code = error instanceof import_core7.SyncError ? error.code : error instanceof import_core7.KoraError ? error.code : error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : import_core7.APPLY_FAILURE_CODES.APPLY_FAILED;
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
message,
|
|
3310
|
-
retriable: code !== import_core7.APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY
|
|
3311
|
-
});
|
|
3680
|
+
const retriable = code !== import_core7.APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY;
|
|
3681
|
+
this.emitApplyFailure(transformed, "rejected", { code, message, retriable });
|
|
3312
3682
|
}
|
|
3313
3683
|
}
|
|
3314
3684
|
if (inScopeOps.length > 0) {
|
|
@@ -3319,18 +3689,29 @@ var SyncEngine = class {
|
|
|
3319
3689
|
batchSize: inScopeOps.length
|
|
3320
3690
|
});
|
|
3321
3691
|
}
|
|
3322
|
-
const
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3692
|
+
const deliveryFullyApplied = isDeliveryBatch && fullyApplied;
|
|
3693
|
+
if (!isDeliveryBatch || deliveryFullyApplied) {
|
|
3694
|
+
const lastOp = operations[operations.length - 1];
|
|
3695
|
+
const ack = {
|
|
3696
|
+
type: "acknowledgment",
|
|
3697
|
+
messageId: generateMessageId(),
|
|
3698
|
+
acknowledgedMessageId: msg.messageId,
|
|
3699
|
+
lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0,
|
|
3700
|
+
...deliveryFullyApplied && msg.maxDeliverySequence !== void 0 ? { deliverySequence: msg.maxDeliverySequence } : {}
|
|
3701
|
+
};
|
|
3702
|
+
this.transport.send(ack);
|
|
3703
|
+
this.emitter?.emit({
|
|
3704
|
+
type: "sync:acknowledged",
|
|
3705
|
+
sequenceNumber: lastOp ? lastOp.sequenceNumber : 0
|
|
3706
|
+
});
|
|
3707
|
+
}
|
|
3708
|
+
if (isDeliveryBatch && fullyApplied && msg.maxDeliverySequence !== void 0) {
|
|
3709
|
+
this.deliveryGapRepeatCount = 0;
|
|
3710
|
+
this.lastDeliveryGapKey = null;
|
|
3711
|
+
this.deliveryWatermark = msg.maxDeliverySequence;
|
|
3712
|
+
this.setViewWatermark(this.deliverySignature(), this.deliveryWatermark);
|
|
3713
|
+
await this.persistDeliveryWatermark(this.deliveryWatermark);
|
|
3714
|
+
}
|
|
3334
3715
|
if (this.state === "syncing") {
|
|
3335
3716
|
this.deltaBatchesReceived++;
|
|
3336
3717
|
const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
|
|
@@ -3338,15 +3719,17 @@ var SyncEngine = class {
|
|
|
3338
3719
|
this.initialSyncTotalBatches = msg.totalBatches;
|
|
3339
3720
|
}
|
|
3340
3721
|
this.metricsCollector.updateInitialSyncProgress(this.deltaBatchesReceived, totalBatches);
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3722
|
+
if (!isDeliveryBatch) {
|
|
3723
|
+
const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
|
|
3724
|
+
inScopeOps.length > 0 ? inScopeOps : operations,
|
|
3725
|
+
msg.batchIndex
|
|
3726
|
+
);
|
|
3727
|
+
if (cursorFromBatch) {
|
|
3728
|
+
this.resumeDeltaCursor = cursorFromBatch;
|
|
3729
|
+
await this.persistDeltaCursor(cursorFromBatch);
|
|
3730
|
+
}
|
|
3348
3731
|
}
|
|
3349
|
-
if (msg.isFinal) {
|
|
3732
|
+
if (msg.isFinal && (!isDeliveryBatch || fullyApplied)) {
|
|
3350
3733
|
this.deltaReceiveComplete = true;
|
|
3351
3734
|
this.resumeDeltaCursor = null;
|
|
3352
3735
|
await this.persistDeltaCursor(null);
|
|
@@ -3355,26 +3738,51 @@ var SyncEngine = class {
|
|
|
3355
3738
|
}
|
|
3356
3739
|
}
|
|
3357
3740
|
}
|
|
3358
|
-
|
|
3741
|
+
/** Acknowledge a delivery batch (used for duplicates) without re-applying it. */
|
|
3742
|
+
sendDeliveryAck(acknowledgedMessageId, deliverySequence) {
|
|
3743
|
+
const ack = {
|
|
3744
|
+
type: "acknowledgment",
|
|
3745
|
+
messageId: generateMessageId(),
|
|
3746
|
+
acknowledgedMessageId,
|
|
3747
|
+
lastSequenceNumber: 0,
|
|
3748
|
+
deliverySequence
|
|
3749
|
+
};
|
|
3750
|
+
this.transport.send(ack);
|
|
3751
|
+
}
|
|
3752
|
+
async handleAcknowledgment(msg) {
|
|
3753
|
+
this.clearOutboundAckTimer();
|
|
3359
3754
|
if (this.state === "syncing" && this.config.strictHandshake) {
|
|
3360
3755
|
this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
|
|
3361
3756
|
this.markDeltaSendCompleteIfReady();
|
|
3362
3757
|
}
|
|
3363
3758
|
if (this.currentBatch) {
|
|
3364
|
-
|
|
3759
|
+
if (this.currentBatchRequiresPartialAck) {
|
|
3760
|
+
await this.outboundQueue.acknowledgeThrough(
|
|
3761
|
+
this.currentBatch.batchId,
|
|
3762
|
+
msg.lastSequenceNumber
|
|
3763
|
+
);
|
|
3764
|
+
} else {
|
|
3765
|
+
await this.outboundQueue.acknowledge(this.currentBatch.batchId);
|
|
3766
|
+
}
|
|
3365
3767
|
this.currentBatch = null;
|
|
3768
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3366
3769
|
const now = Date.now();
|
|
3367
3770
|
this.lastSyncedAt = now;
|
|
3368
3771
|
this.lastSuccessfulPush = now;
|
|
3369
3772
|
}
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
);
|
|
3773
|
+
await this.advanceLastAckedForLocalNode(msg.lastSequenceNumber);
|
|
3774
|
+
await this.refreshPendingCount();
|
|
3373
3775
|
if (this.state === "streaming" && this.outboundQueue.hasOperations) {
|
|
3374
3776
|
this.flushQueue();
|
|
3375
3777
|
}
|
|
3376
3778
|
}
|
|
3377
3779
|
handleError(msg) {
|
|
3780
|
+
this.clearOutboundAckTimer();
|
|
3781
|
+
if (this.currentBatch) {
|
|
3782
|
+
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
3783
|
+
this.currentBatch = null;
|
|
3784
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3785
|
+
}
|
|
3378
3786
|
this.transitionTo("error");
|
|
3379
3787
|
if (msg.code === "AUTH_FAILED") {
|
|
3380
3788
|
this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
|
|
@@ -3393,6 +3801,64 @@ var SyncEngine = class {
|
|
|
3393
3801
|
this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
|
|
3394
3802
|
this.transitionTo("disconnected");
|
|
3395
3803
|
}
|
|
3804
|
+
/**
|
|
3805
|
+
* Handle a per-operation rejection: divert the op out of the pending outbound
|
|
3806
|
+
* queue (so it is never retried or resurrected on reconnect), record it in the
|
|
3807
|
+
* durable rejected store (so it is kept and explainable), and emit an event so
|
|
3808
|
+
* the app can reconcile. Unlike {@link handleError}, this is a normal per-op
|
|
3809
|
+
* signal, so the connection stays up.
|
|
3810
|
+
*/
|
|
3811
|
+
async handleOperationRejected(msg) {
|
|
3812
|
+
if (msg.retriable) {
|
|
3813
|
+
this.currentBatchRequiresPartialAck = true;
|
|
3814
|
+
await this.refreshPendingCount();
|
|
3815
|
+
this.emitter?.emit({
|
|
3816
|
+
type: "sync:operation-rejected",
|
|
3817
|
+
operationId: msg.operationId,
|
|
3818
|
+
collection: msg.collection,
|
|
3819
|
+
recordId: msg.recordId,
|
|
3820
|
+
code: msg.code,
|
|
3821
|
+
message: msg.message,
|
|
3822
|
+
retriable: true
|
|
3823
|
+
});
|
|
3824
|
+
return;
|
|
3825
|
+
}
|
|
3826
|
+
await this.outboundQueue.reject(msg.operationId);
|
|
3827
|
+
await this.rejectedStorage.record({
|
|
3828
|
+
operationId: msg.operationId,
|
|
3829
|
+
collection: msg.collection,
|
|
3830
|
+
recordId: msg.recordId,
|
|
3831
|
+
code: msg.code,
|
|
3832
|
+
message: msg.message,
|
|
3833
|
+
retriable: msg.retriable,
|
|
3834
|
+
rejectedAt: Date.now()
|
|
3835
|
+
});
|
|
3836
|
+
await this.refreshPendingCount();
|
|
3837
|
+
this.emitter?.emit({
|
|
3838
|
+
type: "sync:operation-rejected",
|
|
3839
|
+
operationId: msg.operationId,
|
|
3840
|
+
collection: msg.collection,
|
|
3841
|
+
recordId: msg.recordId,
|
|
3842
|
+
code: msg.code,
|
|
3843
|
+
message: msg.message,
|
|
3844
|
+
retriable: msg.retriable
|
|
3845
|
+
});
|
|
3846
|
+
}
|
|
3847
|
+
/**
|
|
3848
|
+
* Every operation the server rejected that has not yet been reconciled. The app
|
|
3849
|
+
* uses this (alongside the `sync:operation-rejected` event) to surface failed
|
|
3850
|
+
* submissions and decide whether to roll back or resubmit.
|
|
3851
|
+
*/
|
|
3852
|
+
getRejectedOperations() {
|
|
3853
|
+
return this.rejectedStorage.list();
|
|
3854
|
+
}
|
|
3855
|
+
/**
|
|
3856
|
+
* Forget rejected operations by id once the app has reconciled them (rolled the
|
|
3857
|
+
* optimistic write back or resubmitted a corrected op).
|
|
3858
|
+
*/
|
|
3859
|
+
clearRejectedOperations(operationIds) {
|
|
3860
|
+
return this.rejectedStorage.remove(operationIds);
|
|
3861
|
+
}
|
|
3396
3862
|
async checkDeltaComplete() {
|
|
3397
3863
|
if (!this.deltaSendComplete || !this.deltaReceiveComplete) {
|
|
3398
3864
|
return;
|
|
@@ -3491,7 +3957,7 @@ var SyncEngine = class {
|
|
|
3491
3957
|
return;
|
|
3492
3958
|
}
|
|
3493
3959
|
const ops = await this.store.getOperationRange(localNodeId, 1, localSeq);
|
|
3494
|
-
const inScope =
|
|
3960
|
+
const inScope = await this.filterAllowedForSync(ops);
|
|
3495
3961
|
for (const op of inScope) {
|
|
3496
3962
|
await this.outboundQueue.enqueue(op);
|
|
3497
3963
|
}
|
|
@@ -3502,6 +3968,7 @@ var SyncEngine = class {
|
|
|
3502
3968
|
const batch = this.outboundQueue.takeBatch(this.batchSize);
|
|
3503
3969
|
if (!batch) return;
|
|
3504
3970
|
this.currentBatch = batch;
|
|
3971
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3505
3972
|
if (this.encryptor) {
|
|
3506
3973
|
this.encryptor.encryptBatch(batch.operations).then(
|
|
3507
3974
|
(encrypted) => {
|
|
@@ -3514,6 +3981,7 @@ var SyncEngine = class {
|
|
|
3514
3981
|
batchIndex: 0
|
|
3515
3982
|
};
|
|
3516
3983
|
this.transport.send(batchMsg);
|
|
3984
|
+
this.startOutboundAckTimer(batch.batchId);
|
|
3517
3985
|
this.emitter?.emit({
|
|
3518
3986
|
type: "sync:sent",
|
|
3519
3987
|
operations: batch.operations,
|
|
@@ -3523,6 +3991,7 @@ var SyncEngine = class {
|
|
|
3523
3991
|
(err) => {
|
|
3524
3992
|
this.outboundQueue.returnBatch(batch.batchId);
|
|
3525
3993
|
this.currentBatch = null;
|
|
3994
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3526
3995
|
this.emitter?.emit({
|
|
3527
3996
|
type: "sync:disconnected",
|
|
3528
3997
|
reason: err instanceof Error ? err.message : "Encryption failed"
|
|
@@ -3539,6 +4008,7 @@ var SyncEngine = class {
|
|
|
3539
4008
|
batchIndex: 0
|
|
3540
4009
|
};
|
|
3541
4010
|
this.transport.send(batchMsg);
|
|
4011
|
+
this.startOutboundAckTimer(batch.batchId);
|
|
3542
4012
|
this.emitter?.emit({
|
|
3543
4013
|
type: "sync:sent",
|
|
3544
4014
|
operations: batch.operations,
|
|
@@ -3546,6 +4016,34 @@ var SyncEngine = class {
|
|
|
3546
4016
|
});
|
|
3547
4017
|
}
|
|
3548
4018
|
}
|
|
4019
|
+
startOutboundAckTimer(batchId) {
|
|
4020
|
+
this.clearOutboundAckTimer();
|
|
4021
|
+
if (this.outboundAckTimeoutMs <= 0) return;
|
|
4022
|
+
this.outboundAckTimer = setTimeout(() => {
|
|
4023
|
+
void this.handleOutboundAckTimeout(batchId);
|
|
4024
|
+
}, this.outboundAckTimeoutMs);
|
|
4025
|
+
}
|
|
4026
|
+
clearOutboundAckTimer() {
|
|
4027
|
+
if (!this.outboundAckTimer) return;
|
|
4028
|
+
clearTimeout(this.outboundAckTimer);
|
|
4029
|
+
this.outboundAckTimer = null;
|
|
4030
|
+
}
|
|
4031
|
+
async handleOutboundAckTimeout(batchId) {
|
|
4032
|
+
if (!this.currentBatch || this.currentBatch.batchId !== batchId) return;
|
|
4033
|
+
this.outboundQueue.returnBatch(batchId);
|
|
4034
|
+
this.currentBatch = null;
|
|
4035
|
+
this.currentBatchRequiresPartialAck = false;
|
|
4036
|
+
this.clearOutboundAckTimer();
|
|
4037
|
+
await this.refreshPendingCount();
|
|
4038
|
+
if (this.state === "disconnected") return;
|
|
4039
|
+
const reason = `Timed out waiting for acknowledgment of outbound batch ${batchId}`;
|
|
4040
|
+
try {
|
|
4041
|
+
await this.transport.disconnect();
|
|
4042
|
+
} finally {
|
|
4043
|
+
this.ensureDisconnected();
|
|
4044
|
+
this.emitter?.emit({ type: "sync:disconnected", reason });
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
3549
4047
|
handleAwarenessUpdate(msg) {
|
|
3550
4048
|
const awarenessMessage = {
|
|
3551
4049
|
type: "awareness",
|
|
@@ -3555,9 +4053,11 @@ var SyncEngine = class {
|
|
|
3555
4053
|
this.awarenessManager.handleRemoteMessage(awarenessMessage);
|
|
3556
4054
|
}
|
|
3557
4055
|
handleTransportClose(reason) {
|
|
4056
|
+
this.clearOutboundAckTimer();
|
|
3558
4057
|
if (this.currentBatch) {
|
|
3559
4058
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
3560
4059
|
this.currentBatch = null;
|
|
4060
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3561
4061
|
}
|
|
3562
4062
|
if (this.schemaBlocked) {
|
|
3563
4063
|
return;
|
|
@@ -3589,11 +4089,40 @@ var SyncEngine = class {
|
|
|
3589
4089
|
this.serializer.setWireFormat(format);
|
|
3590
4090
|
}
|
|
3591
4091
|
}
|
|
3592
|
-
operationAllowedForSync(op) {
|
|
3593
|
-
if (
|
|
4092
|
+
async operationAllowedForSync(op) {
|
|
4093
|
+
if (this.matchesScopeAndSubsets(op)) {
|
|
4094
|
+
return true;
|
|
4095
|
+
}
|
|
4096
|
+
const fullRecord = await this.readRecordForBackfill(op);
|
|
4097
|
+
if (!fullRecord) {
|
|
4098
|
+
return false;
|
|
4099
|
+
}
|
|
4100
|
+
return this.matchesScopeAndSubsets(op, fullRecord);
|
|
4101
|
+
}
|
|
4102
|
+
matchesScopeAndSubsets(op, fullRecord) {
|
|
4103
|
+
if (!operationMatchesScope(op, this.activeScope, fullRecord)) {
|
|
3594
4104
|
return false;
|
|
3595
4105
|
}
|
|
3596
|
-
return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets());
|
|
4106
|
+
return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets(), fullRecord);
|
|
4107
|
+
}
|
|
4108
|
+
async readRecordForBackfill(op) {
|
|
4109
|
+
if (!this.store.readRecordFields) {
|
|
4110
|
+
return null;
|
|
4111
|
+
}
|
|
4112
|
+
try {
|
|
4113
|
+
return await this.store.readRecordFields(op.collection, op.recordId);
|
|
4114
|
+
} catch {
|
|
4115
|
+
return null;
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
4118
|
+
async filterAllowedForSync(ops) {
|
|
4119
|
+
const allowed = [];
|
|
4120
|
+
for (const op of ops) {
|
|
4121
|
+
if (await this.operationAllowedForSync(op)) {
|
|
4122
|
+
allowed.push(op);
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
4125
|
+
return allowed;
|
|
3597
4126
|
}
|
|
3598
4127
|
async persistDeltaCursor(cursor) {
|
|
3599
4128
|
if (!this.syncState?.saveDeltaCursor) {
|
|
@@ -3601,6 +4130,12 @@ var SyncEngine = class {
|
|
|
3601
4130
|
}
|
|
3602
4131
|
await this.syncState.saveDeltaCursor(cursor);
|
|
3603
4132
|
}
|
|
4133
|
+
async persistDeliveryWatermark(watermark, signature = this.deliverySignature()) {
|
|
4134
|
+
if (!this.syncState?.saveDeliveryWatermark) {
|
|
4135
|
+
return;
|
|
4136
|
+
}
|
|
4137
|
+
await this.syncState.saveDeliveryWatermark(signature, watermark);
|
|
4138
|
+
}
|
|
3604
4139
|
scheduleQuerySubsetReconnect() {
|
|
3605
4140
|
if (this.querySubsetReconnectTimer) {
|
|
3606
4141
|
clearTimeout(this.querySubsetReconnectTimer);
|
|
@@ -3613,8 +4148,7 @@ var SyncEngine = class {
|
|
|
3613
4148
|
}, 500);
|
|
3614
4149
|
}
|
|
3615
4150
|
async reconnectForQuerySubsets() {
|
|
3616
|
-
await this.
|
|
3617
|
-
await this.start();
|
|
4151
|
+
await this.reconnect();
|
|
3618
4152
|
}
|
|
3619
4153
|
};
|
|
3620
4154
|
function awarenessStatesToWire(states) {
|
|
@@ -4295,6 +4829,7 @@ function fromBase642(str) {
|
|
|
4295
4829
|
// src/reactivity/sync-status-controller.ts
|
|
4296
4830
|
var OFFLINE_SYNC_STATUS = Object.freeze({
|
|
4297
4831
|
status: "offline",
|
|
4832
|
+
reconnecting: false,
|
|
4298
4833
|
pendingOperations: 0,
|
|
4299
4834
|
lastSyncedAt: null,
|
|
4300
4835
|
lastSuccessfulPush: null,
|
|
@@ -4312,6 +4847,7 @@ var SYNC_STATUS_EVENT_TYPES = [
|
|
|
4312
4847
|
"sync:received",
|
|
4313
4848
|
"sync:acknowledged",
|
|
4314
4849
|
"sync:apply-failed",
|
|
4850
|
+
"sync:delivery-gap",
|
|
4315
4851
|
"sync:diagnostics",
|
|
4316
4852
|
"sync:initial-sync-progress"
|
|
4317
4853
|
];
|
|
@@ -4458,6 +4994,7 @@ function subscribeRemoteAwarenessStates(awareness, listener) {
|
|
|
4458
4994
|
InvalidScopeError,
|
|
4459
4995
|
JsonMessageSerializer,
|
|
4460
4996
|
KeyDerivationError,
|
|
4997
|
+
MemoryRejectedOperationStorage,
|
|
4461
4998
|
NegotiatedMessageSerializer,
|
|
4462
4999
|
OFFLINE_SYNC_STATUS,
|
|
4463
5000
|
OutboundQueue,
|
|
@@ -4496,6 +5033,7 @@ function subscribeRemoteAwarenessStates(awareness, listener) {
|
|
|
4496
5033
|
isHandshakeMessage,
|
|
4497
5034
|
isHandshakeResponseMessage,
|
|
4498
5035
|
isOperationBatchMessage,
|
|
5036
|
+
isOperationRejectedMessage,
|
|
4499
5037
|
isSchemaMismatchReject,
|
|
4500
5038
|
isSyncMessage,
|
|
4501
5039
|
isYjsDocUpdateMessage,
|