@korajs/sync 1.0.0-beta.1 → 1.0.0-beta.11
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 +812 -73
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +157 -3
- package/dist/index.d.ts +157 -3
- package/dist/index.js +809 -73
- 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-iXX0KA_W.d.cts} +201 -6
- package/dist/{transport-CZLMdZ0k.d.ts → transport-iXX0KA_W.d.ts} +201 -6
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -14,13 +14,29 @@ var SYNC_STATES = [
|
|
|
14
14
|
];
|
|
15
15
|
var SYNC_STATUSES = [
|
|
16
16
|
"connected",
|
|
17
|
+
"reconnecting",
|
|
17
18
|
"syncing",
|
|
18
19
|
"synced",
|
|
19
20
|
"offline",
|
|
20
21
|
"clock-error",
|
|
21
22
|
"error",
|
|
22
|
-
"schema-mismatch"
|
|
23
|
+
"schema-mismatch",
|
|
24
|
+
"auth-required"
|
|
23
25
|
];
|
|
26
|
+
var MemoryRejectedOperationStorage = class {
|
|
27
|
+
rejected = /* @__PURE__ */ new Map();
|
|
28
|
+
async record(rejected) {
|
|
29
|
+
this.rejected.set(rejected.operationId, rejected);
|
|
30
|
+
}
|
|
31
|
+
async list() {
|
|
32
|
+
return [...this.rejected.values()];
|
|
33
|
+
}
|
|
34
|
+
async remove(operationIds) {
|
|
35
|
+
for (const id of operationIds) {
|
|
36
|
+
this.rejected.delete(id);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
24
40
|
var ScopeViolationError = class extends KoraError {
|
|
25
41
|
constructor(operationId, collection, scope, message) {
|
|
26
42
|
super(
|
|
@@ -53,12 +69,19 @@ function operationMatchesScope(op, scopeMap, fullRecord) {
|
|
|
53
69
|
const snapshot = buildSnapshot(op, fullRecord ?? void 0);
|
|
54
70
|
if (!snapshot) return false;
|
|
55
71
|
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
56
|
-
if (snapshot[field]
|
|
72
|
+
if (!matchesPredicate(snapshot[field], expected)) {
|
|
57
73
|
return false;
|
|
58
74
|
}
|
|
59
75
|
}
|
|
60
76
|
return true;
|
|
61
77
|
}
|
|
78
|
+
function matchesPredicate(actual, expected) {
|
|
79
|
+
if (expected && typeof expected === "object" && !Array.isArray(expected) && "$in" in expected) {
|
|
80
|
+
const values = expected.$in;
|
|
81
|
+
return Array.isArray(values) && values.some((value) => Object.is(actual, value));
|
|
82
|
+
}
|
|
83
|
+
return Object.is(actual, expected);
|
|
84
|
+
}
|
|
62
85
|
function filterOperationsByScope(operations, scopeMap) {
|
|
63
86
|
if (!scopeMap) return operations;
|
|
64
87
|
return operations.filter((op) => operationMatchesScope(op, scopeMap));
|
|
@@ -115,24 +138,77 @@ function operationMatchesQuerySubsets(op, subsets, fullRecord) {
|
|
|
115
138
|
if (collectionSubsets.length === 0) {
|
|
116
139
|
return true;
|
|
117
140
|
}
|
|
141
|
+
if (collectionSubsets.some((subset) => Object.keys(subset.where).length === 0)) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
118
144
|
const snapshot = buildSnapshot2(op, fullRecord);
|
|
119
145
|
if (!snapshot) {
|
|
120
146
|
return false;
|
|
121
147
|
}
|
|
122
148
|
return collectionSubsets.some((subset) => recordMatchesWhere(snapshot, subset.where));
|
|
123
149
|
}
|
|
150
|
+
function stableValue(value) {
|
|
151
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
152
|
+
if (value && typeof value === "object") {
|
|
153
|
+
return Object.fromEntries(
|
|
154
|
+
Object.entries(value).filter(([, entry]) => entry !== void 0).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => [key, stableValue(entry)])
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
159
|
+
function whereContains(broad, narrow) {
|
|
160
|
+
return Object.entries(broad).every(
|
|
161
|
+
([field, expected]) => JSON.stringify(stableValue(narrow[field])) === JSON.stringify(stableValue(expected))
|
|
162
|
+
);
|
|
163
|
+
}
|
|
124
164
|
function dedupeQuerySubsets(subsets) {
|
|
125
|
-
const
|
|
165
|
+
const normalized = subsets.map((subset) => ({
|
|
166
|
+
collection: subset.collection,
|
|
167
|
+
where: stableValue(subset.where)
|
|
168
|
+
}));
|
|
126
169
|
const result = [];
|
|
127
|
-
for (const
|
|
128
|
-
|
|
129
|
-
|
|
170
|
+
for (const candidate of normalized) {
|
|
171
|
+
if (normalized.some(
|
|
172
|
+
(other) => other !== candidate && other.collection === candidate.collection && whereContains(other.where, candidate.where) && (Object.keys(other.where).length < Object.keys(candidate.where).length || JSON.stringify(other.where) === JSON.stringify(candidate.where))
|
|
173
|
+
)) {
|
|
174
|
+
const duplicateAlreadyKept = result.some(
|
|
175
|
+
(item) => item.collection === candidate.collection && JSON.stringify(item.where) === JSON.stringify(candidate.where)
|
|
176
|
+
);
|
|
177
|
+
if (duplicateAlreadyKept || normalized.some(
|
|
178
|
+
(other) => other !== candidate && other.collection === candidate.collection && Object.keys(other.where).length < Object.keys(candidate.where).length && whereContains(other.where, candidate.where)
|
|
179
|
+
))
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (!result.some(
|
|
183
|
+
(item) => item.collection === candidate.collection && JSON.stringify(item.where) === JSON.stringify(candidate.where)
|
|
184
|
+
))
|
|
185
|
+
result.push(candidate);
|
|
186
|
+
}
|
|
187
|
+
return result.sort(
|
|
188
|
+
(a, b) => `${a.collection}:${JSON.stringify(a.where)}`.localeCompare(
|
|
189
|
+
`${b.collection}:${JSON.stringify(b.where)}`
|
|
190
|
+
)
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
function querySubsetContains(broad, narrow) {
|
|
194
|
+
const collections = /* @__PURE__ */ new Set([
|
|
195
|
+
...broad.map((subset) => subset.collection),
|
|
196
|
+
...narrow.map((subset) => subset.collection)
|
|
197
|
+
]);
|
|
198
|
+
for (const collection of collections) {
|
|
199
|
+
const broadForCollection = broad.filter((subset) => subset.collection === collection);
|
|
200
|
+
const narrowForCollection = narrow.filter((subset) => subset.collection === collection);
|
|
201
|
+
if (narrowForCollection.length === 0) {
|
|
202
|
+
if (broadForCollection.length > 0) return false;
|
|
130
203
|
continue;
|
|
131
204
|
}
|
|
132
|
-
|
|
133
|
-
|
|
205
|
+
if (broadForCollection.length === 0) continue;
|
|
206
|
+
if (!narrowForCollection.every(
|
|
207
|
+
(candidate) => broadForCollection.some((other) => whereContains(other.where, candidate.where))
|
|
208
|
+
))
|
|
209
|
+
return false;
|
|
134
210
|
}
|
|
135
|
-
return
|
|
211
|
+
return true;
|
|
136
212
|
}
|
|
137
213
|
|
|
138
214
|
// src/delta/delta-cursor.ts
|
|
@@ -218,6 +294,8 @@ function isSyncMessage(value) {
|
|
|
218
294
|
return isAcknowledgmentMessage(value);
|
|
219
295
|
case "error":
|
|
220
296
|
return isErrorMessage(value);
|
|
297
|
+
case "operation-rejected":
|
|
298
|
+
return isOperationRejectedMessage(value);
|
|
221
299
|
case "awareness-update":
|
|
222
300
|
return isAwarenessUpdateMessage(value);
|
|
223
301
|
case "yjs-doc-update":
|
|
@@ -257,6 +335,11 @@ function isErrorMessage(value) {
|
|
|
257
335
|
const msg = value;
|
|
258
336
|
return msg.type === "error" && typeof msg.messageId === "string" && typeof msg.code === "string" && typeof msg.message === "string" && typeof msg.retriable === "boolean";
|
|
259
337
|
}
|
|
338
|
+
function isOperationRejectedMessage(value) {
|
|
339
|
+
if (typeof value !== "object" || value === null) return false;
|
|
340
|
+
const msg = value;
|
|
341
|
+
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";
|
|
342
|
+
}
|
|
260
343
|
function isAwarenessUpdateMessage(value) {
|
|
261
344
|
if (typeof value !== "object" || value === null) return false;
|
|
262
345
|
const msg = value;
|
|
@@ -507,7 +590,8 @@ function toProtoEnvelope(message) {
|
|
|
507
590
|
})),
|
|
508
591
|
schemaVersion: message.schemaVersion,
|
|
509
592
|
authToken: message.authToken,
|
|
510
|
-
supportedWireFormats: message.supportedWireFormats
|
|
593
|
+
supportedWireFormats: message.supportedWireFormats,
|
|
594
|
+
...message.lastDeliverySequence !== void 0 ? { lastDeliverySequence: message.lastDeliverySequence } : {}
|
|
511
595
|
};
|
|
512
596
|
case "handshake-response":
|
|
513
597
|
return {
|
|
@@ -523,7 +607,8 @@ function toProtoEnvelope(message) {
|
|
|
523
607
|
rejectReason: message.rejectReason,
|
|
524
608
|
selectedWireFormat: message.selectedWireFormat,
|
|
525
609
|
serverTime: message.serverTime,
|
|
526
|
-
blobStorageEnabled: message.blobStorageEnabled
|
|
610
|
+
blobStorageEnabled: message.blobStorageEnabled,
|
|
611
|
+
...message.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: message.serverMaxDeliverySequence } : {}
|
|
527
612
|
};
|
|
528
613
|
case "operation-batch":
|
|
529
614
|
return {
|
|
@@ -531,14 +616,17 @@ function toProtoEnvelope(message) {
|
|
|
531
616
|
messageId: message.messageId,
|
|
532
617
|
operations: message.operations.map(serializeProtoOperation),
|
|
533
618
|
isFinal: message.isFinal,
|
|
534
|
-
batchIndex: message.batchIndex
|
|
619
|
+
batchIndex: message.batchIndex,
|
|
620
|
+
...message.baseDeliverySequence !== void 0 ? { baseDeliverySequence: message.baseDeliverySequence } : {},
|
|
621
|
+
...message.maxDeliverySequence !== void 0 ? { maxDeliverySequence: message.maxDeliverySequence } : {}
|
|
535
622
|
};
|
|
536
623
|
case "acknowledgment":
|
|
537
624
|
return {
|
|
538
625
|
type: message.type,
|
|
539
626
|
messageId: message.messageId,
|
|
540
627
|
acknowledgedMessageId: message.acknowledgedMessageId,
|
|
541
|
-
lastSequenceNumber: message.lastSequenceNumber
|
|
628
|
+
lastSequenceNumber: message.lastSequenceNumber,
|
|
629
|
+
...message.deliverySequence !== void 0 ? { deliverySequence: message.deliverySequence } : {}
|
|
542
630
|
};
|
|
543
631
|
case "error":
|
|
544
632
|
return {
|
|
@@ -548,6 +636,17 @@ function toProtoEnvelope(message) {
|
|
|
548
636
|
errorMessage: message.message,
|
|
549
637
|
retriable: message.retriable
|
|
550
638
|
};
|
|
639
|
+
case "operation-rejected":
|
|
640
|
+
return {
|
|
641
|
+
type: message.type,
|
|
642
|
+
messageId: message.messageId,
|
|
643
|
+
operationId: message.operationId,
|
|
644
|
+
collection: message.collection,
|
|
645
|
+
recordId: message.recordId,
|
|
646
|
+
errorCode: message.code,
|
|
647
|
+
errorMessage: message.message,
|
|
648
|
+
retriable: message.retriable
|
|
649
|
+
};
|
|
551
650
|
case "awareness-update":
|
|
552
651
|
case "yjs-doc-update":
|
|
553
652
|
return {
|
|
@@ -592,7 +691,8 @@ function fromProtoEnvelope(envelope) {
|
|
|
592
691
|
authToken: envelope.authToken,
|
|
593
692
|
supportedWireFormats: envelope.supportedWireFormats?.filter(
|
|
594
693
|
(format) => format === "json" || format === "protobuf"
|
|
595
|
-
)
|
|
694
|
+
),
|
|
695
|
+
...envelope.lastDeliverySequence !== void 0 ? { lastDeliverySequence: envelope.lastDeliverySequence } : {}
|
|
596
696
|
};
|
|
597
697
|
case "handshake-response":
|
|
598
698
|
return {
|
|
@@ -609,7 +709,8 @@ function fromProtoEnvelope(envelope) {
|
|
|
609
709
|
// Preserve the server's wall-clock time so clock-skew detection and
|
|
610
710
|
// automatic timestamp rebase work over the protobuf wire, not just JSON.
|
|
611
711
|
...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {},
|
|
612
|
-
...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {}
|
|
712
|
+
...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {},
|
|
713
|
+
...envelope.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: envelope.serverMaxDeliverySequence } : {}
|
|
613
714
|
};
|
|
614
715
|
case "operation-batch":
|
|
615
716
|
return {
|
|
@@ -617,14 +718,17 @@ function fromProtoEnvelope(envelope) {
|
|
|
617
718
|
messageId: envelope.messageId,
|
|
618
719
|
operations: (envelope.operations ?? []).map(deserializeProtoOperation),
|
|
619
720
|
isFinal: envelope.isFinal ?? false,
|
|
620
|
-
batchIndex: envelope.batchIndex ?? 0
|
|
721
|
+
batchIndex: envelope.batchIndex ?? 0,
|
|
722
|
+
...envelope.baseDeliverySequence !== void 0 ? { baseDeliverySequence: envelope.baseDeliverySequence } : {},
|
|
723
|
+
...envelope.maxDeliverySequence !== void 0 ? { maxDeliverySequence: envelope.maxDeliverySequence } : {}
|
|
621
724
|
};
|
|
622
725
|
case "acknowledgment":
|
|
623
726
|
return {
|
|
624
727
|
type: "acknowledgment",
|
|
625
728
|
messageId: envelope.messageId,
|
|
626
729
|
acknowledgedMessageId: envelope.acknowledgedMessageId ?? "",
|
|
627
|
-
lastSequenceNumber: envelope.lastSequenceNumber ?? 0
|
|
730
|
+
lastSequenceNumber: envelope.lastSequenceNumber ?? 0,
|
|
731
|
+
...envelope.deliverySequence !== void 0 ? { deliverySequence: envelope.deliverySequence } : {}
|
|
628
732
|
};
|
|
629
733
|
case "error":
|
|
630
734
|
return {
|
|
@@ -634,6 +738,17 @@ function fromProtoEnvelope(envelope) {
|
|
|
634
738
|
message: envelope.errorMessage ?? "Unknown error",
|
|
635
739
|
retriable: envelope.retriable ?? false
|
|
636
740
|
};
|
|
741
|
+
case "operation-rejected":
|
|
742
|
+
return {
|
|
743
|
+
type: "operation-rejected",
|
|
744
|
+
messageId: envelope.messageId,
|
|
745
|
+
operationId: envelope.operationId ?? "",
|
|
746
|
+
collection: envelope.collection ?? "",
|
|
747
|
+
recordId: envelope.recordId ?? "",
|
|
748
|
+
code: envelope.errorCode ?? "REJECTED",
|
|
749
|
+
message: envelope.errorMessage ?? "",
|
|
750
|
+
retriable: envelope.retriable ?? false
|
|
751
|
+
};
|
|
637
752
|
case "blob-chunk-request":
|
|
638
753
|
return {
|
|
639
754
|
type: "blob-chunk-request",
|
|
@@ -808,6 +923,21 @@ function encodeEnvelope(envelope) {
|
|
|
808
923
|
if (envelope.hasBytes !== void 0) writer.uint32(184).bool(envelope.hasBytes);
|
|
809
924
|
if (envelope.blobStorageEnabled !== void 0)
|
|
810
925
|
writer.uint32(200).bool(envelope.blobStorageEnabled);
|
|
926
|
+
if (envelope.operationId && envelope.operationId.length > 0)
|
|
927
|
+
writer.uint32(210).string(envelope.operationId);
|
|
928
|
+
if (envelope.collection && envelope.collection.length > 0)
|
|
929
|
+
writer.uint32(218).string(envelope.collection);
|
|
930
|
+
if (envelope.recordId && envelope.recordId.length > 0)
|
|
931
|
+
writer.uint32(226).string(envelope.recordId);
|
|
932
|
+
if (envelope.lastDeliverySequence !== void 0)
|
|
933
|
+
writer.uint32(232).int64(envelope.lastDeliverySequence);
|
|
934
|
+
if (envelope.baseDeliverySequence !== void 0)
|
|
935
|
+
writer.uint32(240).int64(envelope.baseDeliverySequence);
|
|
936
|
+
if (envelope.maxDeliverySequence !== void 0)
|
|
937
|
+
writer.uint32(248).int64(envelope.maxDeliverySequence);
|
|
938
|
+
if (envelope.deliverySequence !== void 0) writer.uint32(256).int64(envelope.deliverySequence);
|
|
939
|
+
if (envelope.serverMaxDeliverySequence !== void 0)
|
|
940
|
+
writer.uint32(264).int64(envelope.serverMaxDeliverySequence);
|
|
811
941
|
return writer.finish();
|
|
812
942
|
}
|
|
813
943
|
function decodeEnvelope(bytes) {
|
|
@@ -894,6 +1024,30 @@ function decodeEnvelope(bytes) {
|
|
|
894
1024
|
case 25:
|
|
895
1025
|
envelope.blobStorageEnabled = reader.bool();
|
|
896
1026
|
break;
|
|
1027
|
+
case 26:
|
|
1028
|
+
envelope.operationId = reader.string();
|
|
1029
|
+
break;
|
|
1030
|
+
case 27:
|
|
1031
|
+
envelope.collection = reader.string();
|
|
1032
|
+
break;
|
|
1033
|
+
case 28:
|
|
1034
|
+
envelope.recordId = reader.string();
|
|
1035
|
+
break;
|
|
1036
|
+
case 29:
|
|
1037
|
+
envelope.lastDeliverySequence = longToNumber(reader.int64());
|
|
1038
|
+
break;
|
|
1039
|
+
case 30:
|
|
1040
|
+
envelope.baseDeliverySequence = longToNumber(reader.int64());
|
|
1041
|
+
break;
|
|
1042
|
+
case 31:
|
|
1043
|
+
envelope.maxDeliverySequence = longToNumber(reader.int64());
|
|
1044
|
+
break;
|
|
1045
|
+
case 32:
|
|
1046
|
+
envelope.deliverySequence = longToNumber(reader.int64());
|
|
1047
|
+
break;
|
|
1048
|
+
case 33:
|
|
1049
|
+
envelope.serverMaxDeliverySequence = longToNumber(reader.int64());
|
|
1050
|
+
break;
|
|
897
1051
|
default:
|
|
898
1052
|
reader.skipType(tag & 7);
|
|
899
1053
|
}
|
|
@@ -1393,6 +1547,7 @@ var ChaosTransport = class {
|
|
|
1393
1547
|
reorderRate;
|
|
1394
1548
|
maxLatency;
|
|
1395
1549
|
random;
|
|
1550
|
+
dropPredicate;
|
|
1396
1551
|
messageHandler = null;
|
|
1397
1552
|
reorderBuffer = [];
|
|
1398
1553
|
pending = [];
|
|
@@ -1403,6 +1558,7 @@ var ChaosTransport = class {
|
|
|
1403
1558
|
this.reorderRate = config?.reorderRate ?? 0;
|
|
1404
1559
|
this.maxLatency = config?.maxLatency ?? 0;
|
|
1405
1560
|
this.random = config?.randomSource ?? Math.random;
|
|
1561
|
+
this.dropPredicate = config?.dropPredicate ?? null;
|
|
1406
1562
|
}
|
|
1407
1563
|
async connect(url, options) {
|
|
1408
1564
|
this.inner.onMessage((msg) => this.handleIncoming(msg));
|
|
@@ -1421,6 +1577,9 @@ var ChaosTransport = class {
|
|
|
1421
1577
|
return this.inner.disconnect();
|
|
1422
1578
|
}
|
|
1423
1579
|
send(message) {
|
|
1580
|
+
if (this.dropPredicate?.(message, "outgoing")) {
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1424
1583
|
if (this.random() < this.dropRate) {
|
|
1425
1584
|
return;
|
|
1426
1585
|
}
|
|
@@ -1448,6 +1607,9 @@ var ChaosTransport = class {
|
|
|
1448
1607
|
}
|
|
1449
1608
|
handleIncoming(message) {
|
|
1450
1609
|
if (!this.messageHandler) return;
|
|
1610
|
+
if (this.dropPredicate?.(message, "incoming")) {
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1451
1613
|
if (this.random() < this.dropRate) {
|
|
1452
1614
|
return;
|
|
1453
1615
|
}
|
|
@@ -2400,6 +2562,32 @@ var OutboundQueue = class {
|
|
|
2400
2562
|
const ids = ops.map((op) => op.id);
|
|
2401
2563
|
await this.storage.dequeue(ids);
|
|
2402
2564
|
}
|
|
2565
|
+
/**
|
|
2566
|
+
* Acknowledge only operations in an in-flight batch whose local sequence number is
|
|
2567
|
+
* covered by the server's ack. Any later operations return to the front of the
|
|
2568
|
+
* queue for retry. This is required when the server accepts a prefix but rejects a
|
|
2569
|
+
* later operation retriably (for example a stale-scope push after auth changed).
|
|
2570
|
+
*/
|
|
2571
|
+
async acknowledgeThrough(batchId, lastSequenceNumber) {
|
|
2572
|
+
const ops = this.inFlight.get(batchId);
|
|
2573
|
+
if (!ops) return;
|
|
2574
|
+
this.inFlight.delete(batchId);
|
|
2575
|
+
const acknowledged = ops.filter((op) => op.sequenceNumber <= lastSequenceNumber);
|
|
2576
|
+
const retry = ops.filter((op) => op.sequenceNumber > lastSequenceNumber);
|
|
2577
|
+
if (retry.length > 0) {
|
|
2578
|
+
this.queue.unshift(...retry);
|
|
2579
|
+
if (this.queue.length > 1) {
|
|
2580
|
+
this.queue = topologicalSort(this.queue);
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
if (acknowledged.length > 0) {
|
|
2584
|
+
const ids = acknowledged.map((op) => op.id);
|
|
2585
|
+
for (const id of ids) {
|
|
2586
|
+
this.seen.delete(id);
|
|
2587
|
+
}
|
|
2588
|
+
await this.storage.dequeue(ids);
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2403
2591
|
/**
|
|
2404
2592
|
* Return a failed batch to the front of the queue for retry.
|
|
2405
2593
|
* Prepends the operations to maintain priority.
|
|
@@ -2482,6 +2670,37 @@ var OutboundQueue = class {
|
|
|
2482
2670
|
get isInitialized() {
|
|
2483
2671
|
return this.initialized;
|
|
2484
2672
|
}
|
|
2673
|
+
/**
|
|
2674
|
+
* Remove a single operation the server permanently rejected — from the queue,
|
|
2675
|
+
* from any in-flight batch, and from durable storage — so a later batch ack or
|
|
2676
|
+
* a reconnect `returnBatch` can never resend or resurrect it. Returns the
|
|
2677
|
+
* removed operation so the caller can record it in a durable rejected store,
|
|
2678
|
+
* or null if it was not present (already acknowledged and removed).
|
|
2679
|
+
*/
|
|
2680
|
+
async reject(opId) {
|
|
2681
|
+
let removed = null;
|
|
2682
|
+
for (const [batchId, ops] of this.inFlight) {
|
|
2683
|
+
const idx = ops.findIndex((op) => op.id === opId);
|
|
2684
|
+
if (idx !== -1) {
|
|
2685
|
+
removed = ops[idx] ?? null;
|
|
2686
|
+
ops.splice(idx, 1);
|
|
2687
|
+
if (ops.length === 0) {
|
|
2688
|
+
this.inFlight.delete(batchId);
|
|
2689
|
+
}
|
|
2690
|
+
break;
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
if (!removed) {
|
|
2694
|
+
const idx = this.queue.findIndex((op) => op.id === opId);
|
|
2695
|
+
if (idx !== -1) {
|
|
2696
|
+
removed = this.queue[idx] ?? null;
|
|
2697
|
+
this.queue.splice(idx, 1);
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
this.seen.delete(opId);
|
|
2701
|
+
await this.storage.dequeue([opId]);
|
|
2702
|
+
return removed;
|
|
2703
|
+
}
|
|
2485
2704
|
/**
|
|
2486
2705
|
* Remove operations by id from queue and persistent storage.
|
|
2487
2706
|
* Used when ops were already sent during handshake delta exchange.
|
|
@@ -2500,6 +2719,7 @@ var OutboundQueue = class {
|
|
|
2500
2719
|
// src/engine/sync-engine.ts
|
|
2501
2720
|
var DEFAULT_BATCH_SIZE = 100;
|
|
2502
2721
|
var DEFAULT_SCHEMA_VERSION = 1;
|
|
2722
|
+
var DEFAULT_OUTBOUND_ACK_TIMEOUT_MS = 3e4;
|
|
2503
2723
|
var VALID_TRANSITIONS = {
|
|
2504
2724
|
disconnected: ["connecting"],
|
|
2505
2725
|
connecting: ["handshaking", "error", "disconnected"],
|
|
@@ -2513,6 +2733,17 @@ var nextQuerySubsetId = 0;
|
|
|
2513
2733
|
function generateMessageId() {
|
|
2514
2734
|
return `msg-${Date.now()}-${nextMessageId++}`;
|
|
2515
2735
|
}
|
|
2736
|
+
var MAX_DELIVERY_VIEW_WATERMARKS = 64;
|
|
2737
|
+
function stableStringify(value) {
|
|
2738
|
+
if (value === null || typeof value !== "object") {
|
|
2739
|
+
return JSON.stringify(value) ?? "null";
|
|
2740
|
+
}
|
|
2741
|
+
if (Array.isArray(value)) {
|
|
2742
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
2743
|
+
}
|
|
2744
|
+
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)}`);
|
|
2745
|
+
return `{${entries.join(",")}}`;
|
|
2746
|
+
}
|
|
2516
2747
|
var SyncEngine = class {
|
|
2517
2748
|
state = "disconnected";
|
|
2518
2749
|
transport;
|
|
@@ -2521,7 +2752,9 @@ var SyncEngine = class {
|
|
|
2521
2752
|
serializer;
|
|
2522
2753
|
emitter;
|
|
2523
2754
|
outboundQueue;
|
|
2755
|
+
rejectedStorage;
|
|
2524
2756
|
batchSize;
|
|
2757
|
+
outboundAckTimeoutMs;
|
|
2525
2758
|
encryptor;
|
|
2526
2759
|
awarenessManager;
|
|
2527
2760
|
richtextDocChannel;
|
|
@@ -2536,11 +2769,21 @@ var SyncEngine = class {
|
|
|
2536
2769
|
lastSuccessfulPull = null;
|
|
2537
2770
|
conflictCount = 0;
|
|
2538
2771
|
currentBatch = null;
|
|
2772
|
+
currentBatchRequiresPartialAck = false;
|
|
2539
2773
|
reconnecting = false;
|
|
2540
2774
|
schemaBlocked = false;
|
|
2541
2775
|
clockBlocked = false;
|
|
2542
2776
|
clockSkewMs = null;
|
|
2543
2777
|
blobStorageEnabled = false;
|
|
2778
|
+
suspensionReason = null;
|
|
2779
|
+
authRejected = false;
|
|
2780
|
+
serverFrontier = null;
|
|
2781
|
+
hasInFlightDeliveryBatch = false;
|
|
2782
|
+
blockedFailure = null;
|
|
2783
|
+
outboundAckTimer = null;
|
|
2784
|
+
startPromise = null;
|
|
2785
|
+
stopPromise = null;
|
|
2786
|
+
reconnectPromise = null;
|
|
2544
2787
|
// Track delta exchange state
|
|
2545
2788
|
deltaBatchesReceived = 0;
|
|
2546
2789
|
deltaReceiveComplete = false;
|
|
@@ -2557,10 +2800,32 @@ var SyncEngine = class {
|
|
|
2557
2800
|
activeScope;
|
|
2558
2801
|
/** Live query subsets registered from reactive subscriptions */
|
|
2559
2802
|
querySubsets = /* @__PURE__ */ new Map();
|
|
2803
|
+
staticQuerySubsets = [];
|
|
2560
2804
|
querySubsetReconnectTimer = null;
|
|
2561
2805
|
/** Resume cursor for paginated initial sync (persisted across reconnects) */
|
|
2562
2806
|
resumeDeltaCursor = null;
|
|
2563
2807
|
initialSyncTotalBatches = 0;
|
|
2808
|
+
/**
|
|
2809
|
+
* The delivery watermark for the CURRENT sync view: the highest server delivery
|
|
2810
|
+
* sequence up to which every operation in the current view (scope + active query
|
|
2811
|
+
* subscriptions) has been contiguously applied. Advanced only through the server's
|
|
2812
|
+
* gap-free delivery stream (never through live relay), so it is a durable lower bound
|
|
2813
|
+
* on what this client holds for this view. Reported at handshake to resume the stream.
|
|
2814
|
+
*
|
|
2815
|
+
* The watermark is keyed by view: switching views (a scope or subscription change)
|
|
2816
|
+
* saves the current view's watermark and loads the target view's, so returning to a
|
|
2817
|
+
* previously-synced view resumes exactly where it left off instead of re-syncing. This
|
|
2818
|
+
* is correct because a view's watermark is a lower bound specific to that view's
|
|
2819
|
+
* filter; a widened view is simply a different (initially unsynced) view.
|
|
2820
|
+
*/
|
|
2821
|
+
deliveryWatermark = 0;
|
|
2822
|
+
/**
|
|
2823
|
+
* In-memory cache of watermark per view signature, preloaded from persistence at start
|
|
2824
|
+
* so a view switch resolves synchronously (no race with the reconnect it triggers).
|
|
2825
|
+
*/
|
|
2826
|
+
deliverySignatureWatermarks = /* @__PURE__ */ new Map();
|
|
2827
|
+
deliveryGapRepeatCount = 0;
|
|
2828
|
+
lastDeliveryGapKey = null;
|
|
2564
2829
|
constructor(options) {
|
|
2565
2830
|
this.transport = options.transport;
|
|
2566
2831
|
this.store = options.store;
|
|
@@ -2568,11 +2833,13 @@ var SyncEngine = class {
|
|
|
2568
2833
|
this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
|
|
2569
2834
|
this.emitter = options.emitter ?? null;
|
|
2570
2835
|
this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2836
|
+
this.outboundAckTimeoutMs = options.config.outboundAckTimeoutMs ?? DEFAULT_OUTBOUND_ACK_TIMEOUT_MS;
|
|
2571
2837
|
this.encryptor = options.encryptor ?? null;
|
|
2572
2838
|
this.syncState = options.syncState ?? null;
|
|
2573
2839
|
this.activeScope = options.config.scopeMap;
|
|
2574
2840
|
const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
|
|
2575
2841
|
this.outboundQueue = new OutboundQueue(queueStorage);
|
|
2842
|
+
this.rejectedStorage = options.rejectedStorage ?? new MemoryRejectedOperationStorage();
|
|
2576
2843
|
this.metricsCollector = new SyncMetricsCollector(options.metricsConfig);
|
|
2577
2844
|
if (this.emitter) {
|
|
2578
2845
|
this.metricsCollector.attachEmitter(this.emitter);
|
|
@@ -2612,17 +2879,53 @@ var SyncEngine = class {
|
|
|
2612
2879
|
* Start the sync engine: connect → handshake → delta exchange → streaming.
|
|
2613
2880
|
*/
|
|
2614
2881
|
async start() {
|
|
2882
|
+
if (this.state === "streaming") return;
|
|
2883
|
+
if (this.startPromise) return this.startPromise;
|
|
2884
|
+
this.startPromise = this.startInternal().finally(() => {
|
|
2885
|
+
this.startPromise = null;
|
|
2886
|
+
});
|
|
2887
|
+
return this.startPromise;
|
|
2888
|
+
}
|
|
2889
|
+
async startInternal() {
|
|
2890
|
+
if (this.stopPromise) {
|
|
2891
|
+
await this.stopPromise;
|
|
2892
|
+
}
|
|
2893
|
+
if (this.state === "streaming") return;
|
|
2615
2894
|
if (this.state !== "disconnected") {
|
|
2616
|
-
|
|
2617
|
-
|
|
2895
|
+
await this.stop();
|
|
2896
|
+
}
|
|
2897
|
+
if (this.authRejected) {
|
|
2898
|
+
this.emitter?.emit({
|
|
2899
|
+
type: "sync:suspended",
|
|
2900
|
+
reason: this.suspensionReason ?? "auth-rejected"
|
|
2618
2901
|
});
|
|
2902
|
+
return;
|
|
2619
2903
|
}
|
|
2904
|
+
if (this.config.authState) {
|
|
2905
|
+
const authState = await this.config.authState();
|
|
2906
|
+
if (authState.state === "loading" || authState.state === "signed-out") {
|
|
2907
|
+
this.suspensionReason = authState.state === "loading" ? "auth-loading" : "auth-required";
|
|
2908
|
+
this.emitter?.emit({ type: "sync:suspended", reason: this.suspensionReason });
|
|
2909
|
+
return;
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
this.suspensionReason = null;
|
|
2620
2913
|
await this.outboundQueue.initialize();
|
|
2621
2914
|
if (this.syncState) {
|
|
2622
2915
|
this.lastAckedServerVector = await this.syncState.loadLastAckedServerVector();
|
|
2623
2916
|
if (this.syncState.loadDeltaCursor) {
|
|
2624
2917
|
this.resumeDeltaCursor = await this.syncState.loadDeltaCursor();
|
|
2625
2918
|
}
|
|
2919
|
+
if (this.syncState.loadAllDeliveryWatermarks) {
|
|
2920
|
+
const all = await this.syncState.loadAllDeliveryWatermarks();
|
|
2921
|
+
for (const [signature, watermark] of Object.entries(all)) {
|
|
2922
|
+
this.deliverySignatureWatermarks.set(signature, watermark);
|
|
2923
|
+
}
|
|
2924
|
+
} else if (this.syncState.loadDeliveryWatermark) {
|
|
2925
|
+
this.deliverySignatureWatermarks.set("", await this.syncState.loadDeliveryWatermark(""));
|
|
2926
|
+
}
|
|
2927
|
+
this.deliveryWatermark = this.deliverySignatureWatermarks.get(this.deliverySignature()) ?? 0;
|
|
2928
|
+
this.evictColdViewWatermarks();
|
|
2626
2929
|
}
|
|
2627
2930
|
await this.reconcileOutboundFromOpLog();
|
|
2628
2931
|
await this.refreshPendingCount();
|
|
@@ -2638,7 +2941,12 @@ var SyncEngine = class {
|
|
|
2638
2941
|
this.transitionTo("connecting");
|
|
2639
2942
|
try {
|
|
2640
2943
|
const authToken = this.config.auth ? (await this.config.auth()).token : void 0;
|
|
2944
|
+
if (this.state !== "connecting") return;
|
|
2641
2945
|
await this.transport.connect(this.config.url, { authToken });
|
|
2946
|
+
if (this.state !== "connecting") {
|
|
2947
|
+
await this.transport.disconnect();
|
|
2948
|
+
return;
|
|
2949
|
+
}
|
|
2642
2950
|
this.transitionTo("handshaking");
|
|
2643
2951
|
const localVector = this.store.getVersionVector();
|
|
2644
2952
|
const activeQuerySubsets = this.getActiveQuerySubsets();
|
|
@@ -2652,7 +2960,11 @@ var SyncEngine = class {
|
|
|
2652
2960
|
supportedWireFormats: ["json", "protobuf"],
|
|
2653
2961
|
...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
|
|
2654
2962
|
...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
|
|
2655
|
-
...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {}
|
|
2963
|
+
...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {},
|
|
2964
|
+
// Resume the server's gap-free delivery stream from our watermark. A server
|
|
2965
|
+
// that understands it drives server->client sync from delivery sequences; an
|
|
2966
|
+
// older server ignores it and falls back to the version-vector delta.
|
|
2967
|
+
lastDeliverySequence: this.deliveryWatermark
|
|
2656
2968
|
};
|
|
2657
2969
|
this.transport.send(handshake);
|
|
2658
2970
|
} catch (err) {
|
|
@@ -2664,11 +2976,20 @@ var SyncEngine = class {
|
|
|
2664
2976
|
* Stop the sync engine. Disconnects the transport.
|
|
2665
2977
|
*/
|
|
2666
2978
|
async stop() {
|
|
2979
|
+
if (this.stopPromise) return this.stopPromise;
|
|
2980
|
+
this.stopPromise = this.stopInternal().finally(() => {
|
|
2981
|
+
this.stopPromise = null;
|
|
2982
|
+
});
|
|
2983
|
+
return this.stopPromise;
|
|
2984
|
+
}
|
|
2985
|
+
async stopInternal() {
|
|
2667
2986
|
if (this.state === "disconnected") return;
|
|
2987
|
+
this.clearOutboundAckTimer();
|
|
2668
2988
|
this.awarenessManager.stopCleanupTimer();
|
|
2669
2989
|
if (this.currentBatch) {
|
|
2670
2990
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
2671
2991
|
this.currentBatch = null;
|
|
2992
|
+
this.currentBatchRequiresPartialAck = false;
|
|
2672
2993
|
}
|
|
2673
2994
|
try {
|
|
2674
2995
|
await this.transport.disconnect();
|
|
@@ -2689,7 +3010,7 @@ var SyncEngine = class {
|
|
|
2689
3010
|
* because they should remain local-only and not be sent to the server.
|
|
2690
3011
|
*/
|
|
2691
3012
|
async pushOperation(op) {
|
|
2692
|
-
if (!this.operationAllowedForSync(op)) {
|
|
3013
|
+
if (!await this.operationAllowedForSync(op)) {
|
|
2693
3014
|
return;
|
|
2694
3015
|
}
|
|
2695
3016
|
if (this.syncState) {
|
|
@@ -2718,13 +3039,30 @@ var SyncEngine = class {
|
|
|
2718
3039
|
getStatus() {
|
|
2719
3040
|
const pendingOperations = this.syncState ? this.cachedUnsyncedCount : this.outboundQueue.totalPending;
|
|
2720
3041
|
const base = {
|
|
3042
|
+
phase: this.resolvePhase(),
|
|
3043
|
+
...this.suspensionReason ? { reason: this.suspensionReason } : {},
|
|
3044
|
+
reconnecting: this.reconnecting,
|
|
2721
3045
|
pendingOperations,
|
|
2722
3046
|
lastSyncedAt: this.lastSyncedAt,
|
|
2723
3047
|
lastSuccessfulPush: this.lastSuccessfulPush,
|
|
2724
3048
|
lastSuccessfulPull: this.lastSuccessfulPull,
|
|
2725
3049
|
conflicts: this.conflictCount,
|
|
2726
|
-
clockSkewMs: this.clockSkewMs
|
|
3050
|
+
clockSkewMs: this.clockSkewMs,
|
|
3051
|
+
inFlightUploadOperations: this.currentBatch?.operations.length ?? 0,
|
|
3052
|
+
hasInFlightDeliveryBatch: this.hasInFlightDeliveryBatch,
|
|
3053
|
+
activeViewId: this.deliverySignature(),
|
|
3054
|
+
activeViewComplete: this.state === "streaming" && !this.hasInFlightDeliveryBatch && this.blockedFailure === null && (this.serverFrontier === null || this.deliveryWatermark >= this.serverFrontier),
|
|
3055
|
+
initialSync: {
|
|
3056
|
+
complete: this.deltaReceiveComplete && this.deltaSendComplete,
|
|
3057
|
+
receivedBatches: this.deltaBatchesReceived,
|
|
3058
|
+
totalBatches: this.initialSyncTotalBatches || null,
|
|
3059
|
+
progress: this.initialSyncTotalBatches > 0 ? Math.min(1, this.deltaBatchesReceived / this.initialSyncTotalBatches) : null
|
|
3060
|
+
},
|
|
3061
|
+
deliveryWatermark: this.deliveryWatermark,
|
|
3062
|
+
serverFrontier: this.serverFrontier,
|
|
3063
|
+
blockedFailure: this.blockedFailure
|
|
2727
3064
|
};
|
|
3065
|
+
if (this.suspensionReason) return { ...base, status: "auth-required" };
|
|
2728
3066
|
switch (this.state) {
|
|
2729
3067
|
case "disconnected":
|
|
2730
3068
|
if (this.clockBlocked) {
|
|
@@ -2737,7 +3075,7 @@ var SyncEngine = class {
|
|
|
2737
3075
|
case "connecting":
|
|
2738
3076
|
case "handshaking":
|
|
2739
3077
|
case "syncing":
|
|
2740
|
-
return { ...base, status: this.reconnecting ? "
|
|
3078
|
+
return { ...base, status: this.reconnecting ? "reconnecting" : "syncing" };
|
|
2741
3079
|
case "streaming":
|
|
2742
3080
|
return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
|
|
2743
3081
|
case "error":
|
|
@@ -2747,6 +3085,26 @@ var SyncEngine = class {
|
|
|
2747
3085
|
return { ...base, status: this.schemaBlocked ? "schema-mismatch" : "error" };
|
|
2748
3086
|
}
|
|
2749
3087
|
}
|
|
3088
|
+
resolvePhase() {
|
|
3089
|
+
if (this.suspensionReason) return "suspended";
|
|
3090
|
+
if (this.blockedFailure || this.schemaBlocked || this.clockBlocked) return "blocked";
|
|
3091
|
+
if (this.hasInFlightDeliveryBatch) return "applying";
|
|
3092
|
+
if (this.currentBatch) return "uploading";
|
|
3093
|
+
switch (this.state) {
|
|
3094
|
+
case "disconnected":
|
|
3095
|
+
return "offline";
|
|
3096
|
+
case "connecting":
|
|
3097
|
+
return "connecting";
|
|
3098
|
+
case "handshaking":
|
|
3099
|
+
return "handshaking";
|
|
3100
|
+
case "syncing":
|
|
3101
|
+
return "receiving";
|
|
3102
|
+
case "streaming":
|
|
3103
|
+
return "streaming";
|
|
3104
|
+
case "error":
|
|
3105
|
+
return "blocked";
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
2750
3108
|
/**
|
|
2751
3109
|
* True when the server rejected the client's schema version at handshake.
|
|
2752
3110
|
* Sync stays blocked until the app schema is upgraded or sync config changes.
|
|
@@ -2799,8 +3157,25 @@ var SyncEngine = class {
|
|
|
2799
3157
|
await this.refreshPendingCount();
|
|
2800
3158
|
return this.getStatus().pendingOperations;
|
|
2801
3159
|
}
|
|
2802
|
-
emitApplyFailure(op, result, overrides) {
|
|
3160
|
+
emitApplyFailure(op, result, overrides, blocking = false) {
|
|
2803
3161
|
const reason = defaultApplyFailureReason(result, overrides);
|
|
3162
|
+
if (blocking || reason.retriable) {
|
|
3163
|
+
const prior = this.blockedFailure;
|
|
3164
|
+
this.blockedFailure = {
|
|
3165
|
+
operationId: op.id,
|
|
3166
|
+
collection: op.collection,
|
|
3167
|
+
recordId: op.recordId,
|
|
3168
|
+
code: reason.code,
|
|
3169
|
+
message: reason.message,
|
|
3170
|
+
retriable: reason.retriable,
|
|
3171
|
+
firstSeenAt: prior?.operationId === op.id ? prior.firstSeenAt : Date.now(),
|
|
3172
|
+
retryCount: prior?.operationId === op.id ? prior.retryCount + 1 : 0
|
|
3173
|
+
};
|
|
3174
|
+
this.emitter?.emit({
|
|
3175
|
+
type: prior?.operationId === op.id ? "sync:apply-retrying" : "sync:apply-blocked",
|
|
3176
|
+
failure: this.blockedFailure
|
|
3177
|
+
});
|
|
3178
|
+
}
|
|
2804
3179
|
this.emitter?.emit({
|
|
2805
3180
|
type: "sync:apply-failed",
|
|
2806
3181
|
operationId: op.id,
|
|
@@ -2817,11 +3192,36 @@ var SyncEngine = class {
|
|
|
2817
3192
|
*/
|
|
2818
3193
|
async retryNow() {
|
|
2819
3194
|
if (this.schemaBlocked) return;
|
|
3195
|
+
this.authRejected = false;
|
|
3196
|
+
this.suspensionReason = null;
|
|
2820
3197
|
if (this.state === "disconnected" || this.state === "error") {
|
|
2821
3198
|
this.reconnecting = false;
|
|
2822
3199
|
await this.start();
|
|
2823
3200
|
}
|
|
2824
3201
|
}
|
|
3202
|
+
/** Wake a permanent auth suspension after the binding reports a new auth state. */
|
|
3203
|
+
notifyAuthChanged() {
|
|
3204
|
+
this.authRejected = false;
|
|
3205
|
+
this.suspensionReason = null;
|
|
3206
|
+
}
|
|
3207
|
+
/**
|
|
3208
|
+
* Atomically refresh the transport session. Overlapping callers share one
|
|
3209
|
+
* reconnect, in-flight outbound batches are returned to the queue by stop(), and the
|
|
3210
|
+
* next start handshakes with the latest auth token, sync scope, and query subsets.
|
|
3211
|
+
*/
|
|
3212
|
+
async reconnect() {
|
|
3213
|
+
if (this.schemaBlocked) return;
|
|
3214
|
+
if (this.reconnectPromise) return this.reconnectPromise;
|
|
3215
|
+
this.reconnecting = false;
|
|
3216
|
+
this.reconnectPromise = this.reconnectInternal().finally(() => {
|
|
3217
|
+
this.reconnectPromise = null;
|
|
3218
|
+
});
|
|
3219
|
+
return this.reconnectPromise;
|
|
3220
|
+
}
|
|
3221
|
+
async reconnectInternal() {
|
|
3222
|
+
await this.stop();
|
|
3223
|
+
await this.start();
|
|
3224
|
+
}
|
|
2825
3225
|
/**
|
|
2826
3226
|
* Export a diagnostics snapshot for debugging and support tickets.
|
|
2827
3227
|
* Contains connection state, timing info, and queue metrics.
|
|
@@ -2840,6 +3240,8 @@ var SyncEngine = class {
|
|
|
2840
3240
|
pendingOperations: this.outboundQueue.totalPending,
|
|
2841
3241
|
hasInFlightBatch: this.currentBatch !== null,
|
|
2842
3242
|
reconnecting: this.reconnecting,
|
|
3243
|
+
deliveryWatermark: this.deliveryWatermark,
|
|
3244
|
+
deliveryGapRepeatCount: this.deliveryGapRepeatCount,
|
|
2843
3245
|
timestamp: Date.now()
|
|
2844
3246
|
};
|
|
2845
3247
|
}
|
|
@@ -2868,8 +3270,84 @@ var SyncEngine = class {
|
|
|
2868
3270
|
* @param scopeMap - New per-collection scope filters, or undefined to remove scope
|
|
2869
3271
|
*/
|
|
2870
3272
|
updateScope(scopeMap) {
|
|
3273
|
+
const previousSignature = this.deliverySignature();
|
|
2871
3274
|
this.activeScope = scopeMap;
|
|
2872
3275
|
this.config.scopeMap = scopeMap;
|
|
3276
|
+
this.switchDeliveryView(previousSignature);
|
|
3277
|
+
}
|
|
3278
|
+
/**
|
|
3279
|
+
* A stable identifier for the current sync view: the auth/tenant scope plus the set of
|
|
3280
|
+
* active query subscriptions, canonicalized so the same view always produces the same
|
|
3281
|
+
* string. The delivery watermark is keyed by this, so each distinct view resumes from
|
|
3282
|
+
* its own last position.
|
|
3283
|
+
*/
|
|
3284
|
+
deliverySignature() {
|
|
3285
|
+
const subsets = this.getActiveQuerySubsets();
|
|
3286
|
+
if (!this.activeScope && subsets.length === 0) {
|
|
3287
|
+
return "";
|
|
3288
|
+
}
|
|
3289
|
+
const normalizedSubsets = subsets.map((s) => `${s.collection}:${stableStringify(s.where)}`).sort();
|
|
3290
|
+
return stableStringify({ scope: this.activeScope ?? null, subsets: normalizedSubsets });
|
|
3291
|
+
}
|
|
3292
|
+
/**
|
|
3293
|
+
* Switch the active delivery watermark to the current view after a scope or subscription
|
|
3294
|
+
* change. The prior view's watermark is saved so returning to it resumes; the new view's
|
|
3295
|
+
* watermark is restored from the in-memory cache (0 for a never-synced view, which then
|
|
3296
|
+
* does a one-time resync under that view). No data is lost: a view's watermark is a lower
|
|
3297
|
+
* bound for that specific view's filter.
|
|
3298
|
+
*/
|
|
3299
|
+
switchDeliveryView(previousSignature) {
|
|
3300
|
+
const nextSignature = this.deliverySignature();
|
|
3301
|
+
if (nextSignature === previousSignature) {
|
|
3302
|
+
return;
|
|
3303
|
+
}
|
|
3304
|
+
this.setViewWatermark(previousSignature, this.deliveryWatermark);
|
|
3305
|
+
void this.persistDeliveryWatermark(this.deliveryWatermark, previousSignature);
|
|
3306
|
+
this.deliveryWatermark = this.deliverySignatureWatermarks.get(nextSignature) ?? 0;
|
|
3307
|
+
}
|
|
3308
|
+
/**
|
|
3309
|
+
* Record a view's watermark in the in-memory cache as the most-recently-used entry (the
|
|
3310
|
+
* Map preserves insertion order, so re-inserting moves it to the tail), then evict cold
|
|
3311
|
+
* views past the retention cap. This is the single place the cache is written, so LRU
|
|
3312
|
+
* order and the size bound are always maintained together.
|
|
3313
|
+
*/
|
|
3314
|
+
setViewWatermark(signature, watermark) {
|
|
3315
|
+
this.deliverySignatureWatermarks.delete(signature);
|
|
3316
|
+
this.deliverySignatureWatermarks.set(signature, watermark);
|
|
3317
|
+
this.evictColdViewWatermarks();
|
|
3318
|
+
}
|
|
3319
|
+
/**
|
|
3320
|
+
* Trim the per-view watermark cache to `MAX_DELIVERY_VIEW_WATERMARKS`, evicting the
|
|
3321
|
+
* least-recently-used views. The default view ('') and the live view are never evicted
|
|
3322
|
+
* (evicting the live view would force an immediate re-scan of what we are actively
|
|
3323
|
+
* syncing). Each eviction also removes the persisted row, bounding storage. Safe by
|
|
3324
|
+
* construction: an evicted view simply back-fills from 0 (deduplicated) when next seen.
|
|
3325
|
+
*/
|
|
3326
|
+
evictColdViewWatermarks() {
|
|
3327
|
+
if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
|
|
3328
|
+
return;
|
|
3329
|
+
}
|
|
3330
|
+
const liveSignature = this.deliverySignature();
|
|
3331
|
+
for (const signature of this.deliverySignatureWatermarks.keys()) {
|
|
3332
|
+
if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
|
|
3333
|
+
break;
|
|
3334
|
+
}
|
|
3335
|
+
if (signature === "" || signature === liveSignature) {
|
|
3336
|
+
continue;
|
|
3337
|
+
}
|
|
3338
|
+
this.deliverySignatureWatermarks.delete(signature);
|
|
3339
|
+
void this.deleteDeliveryWatermark(signature);
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
/**
|
|
3343
|
+
* Remove a view's persisted watermark row (best-effort; a persistence layer that omits
|
|
3344
|
+
* the delete simply keeps the row, which is harmless).
|
|
3345
|
+
*/
|
|
3346
|
+
async deleteDeliveryWatermark(signature) {
|
|
3347
|
+
if (!this.syncState?.deleteDeliveryWatermark) {
|
|
3348
|
+
return;
|
|
3349
|
+
}
|
|
3350
|
+
await this.syncState.deleteDeliveryWatermark(signature);
|
|
2873
3351
|
}
|
|
2874
3352
|
/**
|
|
2875
3353
|
* Get the currently active scope map. Returns undefined if no scope is configured.
|
|
@@ -2882,19 +3360,49 @@ var SyncEngine = class {
|
|
|
2882
3360
|
* Takes effect on the next connection; reconnects when already connected.
|
|
2883
3361
|
*/
|
|
2884
3362
|
registerQuerySubset(subset) {
|
|
3363
|
+
if ((this.config.querySubsets?.mode ?? "reactive") !== "reactive") return () => {
|
|
3364
|
+
};
|
|
2885
3365
|
const id = `query-${nextQuerySubsetId++}`;
|
|
3366
|
+
const previousSignature = this.deliverySignature();
|
|
3367
|
+
const previousEffective = this.getActiveQuerySubsets();
|
|
2886
3368
|
this.querySubsets.set(id, subset);
|
|
2887
|
-
this.
|
|
3369
|
+
this.switchDeliveryViewWithInheritance(previousSignature, previousEffective);
|
|
3370
|
+
if (this.deliverySignature() !== previousSignature) this.scheduleQuerySubsetReconnect();
|
|
2888
3371
|
return () => {
|
|
3372
|
+
const sigBeforeRemove = this.deliverySignature();
|
|
3373
|
+
const effectiveBeforeRemove = this.getActiveQuerySubsets();
|
|
2889
3374
|
this.querySubsets.delete(id);
|
|
2890
|
-
this.
|
|
3375
|
+
this.switchDeliveryViewWithInheritance(sigBeforeRemove, effectiveBeforeRemove);
|
|
3376
|
+
if (this.deliverySignature() !== sigBeforeRemove) this.scheduleQuerySubsetReconnect();
|
|
2891
3377
|
};
|
|
2892
3378
|
}
|
|
3379
|
+
switchDeliveryViewWithInheritance(previousSignature, previousSubsets) {
|
|
3380
|
+
const nextSubsets = this.getActiveQuerySubsets();
|
|
3381
|
+
const previousWatermark = this.deliveryWatermark;
|
|
3382
|
+
this.switchDeliveryView(previousSignature);
|
|
3383
|
+
if (this.deliveryWatermark === 0 && querySubsetContains(previousSubsets, nextSubsets)) {
|
|
3384
|
+
this.deliveryWatermark = previousWatermark;
|
|
3385
|
+
this.setViewWatermark(this.deliverySignature(), previousWatermark);
|
|
3386
|
+
void this.persistDeliveryWatermark(previousWatermark);
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
/** Atomically replace the manifest used by static query-subset mode. */
|
|
3390
|
+
setQuerySubsets(subsets) {
|
|
3391
|
+
if ((this.config.querySubsets?.mode ?? "reactive") !== "static")
|
|
3392
|
+
throw new Error('setQuerySubsets() requires sync.querySubsets.mode = "static"');
|
|
3393
|
+
const previousSignature = this.deliverySignature();
|
|
3394
|
+
const previousEffective = this.getActiveQuerySubsets();
|
|
3395
|
+
this.staticQuerySubsets = dedupeQuerySubsets(subsets);
|
|
3396
|
+
this.switchDeliveryViewWithInheritance(previousSignature, previousEffective);
|
|
3397
|
+
if (this.deliverySignature() !== previousSignature) this.scheduleQuerySubsetReconnect();
|
|
3398
|
+
}
|
|
2893
3399
|
/**
|
|
2894
3400
|
* Returns deduplicated active query subsets from registered subscriptions.
|
|
2895
3401
|
*/
|
|
2896
3402
|
getActiveQuerySubsets() {
|
|
2897
|
-
|
|
3403
|
+
const mode = this.config.querySubsets?.mode ?? "reactive";
|
|
3404
|
+
if (mode === "disabled") return [];
|
|
3405
|
+
return mode === "static" ? this.staticQuerySubsets : dedupeQuerySubsets([...this.querySubsets.values()]);
|
|
2898
3406
|
}
|
|
2899
3407
|
/**
|
|
2900
3408
|
* Get the awareness manager for collaborative presence.
|
|
@@ -2949,11 +3457,14 @@ var SyncEngine = class {
|
|
|
2949
3457
|
await this.handleOperationBatch(message);
|
|
2950
3458
|
break;
|
|
2951
3459
|
case "acknowledgment":
|
|
2952
|
-
this.handleAcknowledgment(message);
|
|
3460
|
+
await this.handleAcknowledgment(message);
|
|
2953
3461
|
break;
|
|
2954
3462
|
case "error":
|
|
2955
3463
|
this.handleError(message);
|
|
2956
3464
|
break;
|
|
3465
|
+
case "operation-rejected":
|
|
3466
|
+
await this.handleOperationRejected(message);
|
|
3467
|
+
break;
|
|
2957
3468
|
case "awareness-update":
|
|
2958
3469
|
this.handleAwarenessUpdate(message);
|
|
2959
3470
|
break;
|
|
@@ -2968,6 +3479,7 @@ var SyncEngine = class {
|
|
|
2968
3479
|
}
|
|
2969
3480
|
}
|
|
2970
3481
|
handleMessageFailure(error) {
|
|
3482
|
+
this.hasInFlightDeliveryBatch = false;
|
|
2971
3483
|
const reason = error instanceof Error ? error.message : "Message handling failed";
|
|
2972
3484
|
this.handleTransportClose(reason);
|
|
2973
3485
|
}
|
|
@@ -3036,6 +3548,12 @@ var SyncEngine = class {
|
|
|
3036
3548
|
}
|
|
3037
3549
|
this.remoteVector = wireToVersionVector(msg.versionVector);
|
|
3038
3550
|
void this.persistLastAckedServerVector(this.remoteVector);
|
|
3551
|
+
if (typeof msg.serverMaxDeliverySequence === "number" && this.deliveryWatermark > msg.serverMaxDeliverySequence) {
|
|
3552
|
+
this.deliveryWatermark = 0;
|
|
3553
|
+
this.setViewWatermark(this.deliverySignature(), 0);
|
|
3554
|
+
await this.persistDeliveryWatermark(0);
|
|
3555
|
+
}
|
|
3556
|
+
this.serverFrontier = msg.serverMaxDeliverySequence ?? null;
|
|
3039
3557
|
if (msg.selectedWireFormat) {
|
|
3040
3558
|
this.setSerializerWireFormat(msg.selectedWireFormat);
|
|
3041
3559
|
}
|
|
@@ -3101,7 +3619,7 @@ var SyncEngine = class {
|
|
|
3101
3619
|
async sendDelta() {
|
|
3102
3620
|
const localVector = this.store.getVersionVector();
|
|
3103
3621
|
const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
|
|
3104
|
-
const missingOps =
|
|
3622
|
+
const missingOps = await this.filterAllowedForSync(allMissingOps);
|
|
3105
3623
|
this.deltaSentOpIds = missingOps.map((op) => op.id);
|
|
3106
3624
|
if (missingOps.length === 0) {
|
|
3107
3625
|
const messageId = generateMessageId();
|
|
@@ -3176,11 +3694,35 @@ var SyncEngine = class {
|
|
|
3176
3694
|
return missing;
|
|
3177
3695
|
}
|
|
3178
3696
|
async handleOperationBatch(msg) {
|
|
3697
|
+
const isDeliveryBatch = msg.maxDeliverySequence !== void 0;
|
|
3698
|
+
if (isDeliveryBatch) {
|
|
3699
|
+
const base = msg.baseDeliverySequence ?? 0;
|
|
3700
|
+
if (base > this.deliveryWatermark) {
|
|
3701
|
+
const gapKey = `${this.deliveryWatermark}:${base}`;
|
|
3702
|
+
this.deliveryGapRepeatCount = this.lastDeliveryGapKey === gapKey ? this.deliveryGapRepeatCount + 1 : 1;
|
|
3703
|
+
this.lastDeliveryGapKey = gapKey;
|
|
3704
|
+
this.emitter?.emit({
|
|
3705
|
+
type: "sync:delivery-gap",
|
|
3706
|
+
expectedBase: this.deliveryWatermark,
|
|
3707
|
+
receivedBase: base,
|
|
3708
|
+
currentWatermark: this.deliveryWatermark,
|
|
3709
|
+
messageId: msg.messageId,
|
|
3710
|
+
repeatCount: this.deliveryGapRepeatCount
|
|
3711
|
+
});
|
|
3712
|
+
return;
|
|
3713
|
+
}
|
|
3714
|
+
if (base < this.deliveryWatermark) {
|
|
3715
|
+
this.sendDeliveryAck(msg.messageId, this.deliveryWatermark);
|
|
3716
|
+
return;
|
|
3717
|
+
}
|
|
3718
|
+
this.hasInFlightDeliveryBatch = true;
|
|
3719
|
+
}
|
|
3179
3720
|
const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
3180
3721
|
const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
|
|
3181
|
-
const inScopeOps =
|
|
3722
|
+
const inScopeOps = await this.filterAllowedForSync(operations);
|
|
3182
3723
|
const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
3183
3724
|
const transforms = this.config.operationTransforms ?? [];
|
|
3725
|
+
let fullyApplied = true;
|
|
3184
3726
|
for (const op of inScopeOps) {
|
|
3185
3727
|
const transformed = transforms.length > 0 ? applyOperationTransforms(op, targetSchemaVersion, transforms) : op;
|
|
3186
3728
|
if (transformed === null) {
|
|
@@ -3192,21 +3734,24 @@ var SyncEngine = class {
|
|
|
3192
3734
|
this.emitApplyFailure(transformed, result);
|
|
3193
3735
|
}
|
|
3194
3736
|
} catch (error) {
|
|
3737
|
+
fullyApplied = false;
|
|
3195
3738
|
if (error instanceof ClockDriftError) {
|
|
3196
|
-
this.emitApplyFailure(
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3739
|
+
this.emitApplyFailure(
|
|
3740
|
+
transformed,
|
|
3741
|
+
"rejected",
|
|
3742
|
+
{
|
|
3743
|
+
code: APPLY_FAILURE_CODES.CLOCK_DRIFT,
|
|
3744
|
+
message: error.message,
|
|
3745
|
+
retriable: false
|
|
3746
|
+
},
|
|
3747
|
+
true
|
|
3748
|
+
);
|
|
3201
3749
|
continue;
|
|
3202
3750
|
}
|
|
3203
3751
|
const message = error instanceof Error ? error.message : "Apply failed";
|
|
3204
3752
|
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;
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
message,
|
|
3208
|
-
retriable: code !== APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY
|
|
3209
|
-
});
|
|
3753
|
+
const retriable = code !== APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY;
|
|
3754
|
+
this.emitApplyFailure(transformed, "rejected", { code, message, retriable }, true);
|
|
3210
3755
|
}
|
|
3211
3756
|
}
|
|
3212
3757
|
if (inScopeOps.length > 0) {
|
|
@@ -3217,18 +3762,35 @@ var SyncEngine = class {
|
|
|
3217
3762
|
batchSize: inScopeOps.length
|
|
3218
3763
|
});
|
|
3219
3764
|
}
|
|
3220
|
-
const
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3765
|
+
const deliveryFullyApplied = isDeliveryBatch && fullyApplied;
|
|
3766
|
+
if (!isDeliveryBatch || deliveryFullyApplied) {
|
|
3767
|
+
const lastOp = operations[operations.length - 1];
|
|
3768
|
+
const ack = {
|
|
3769
|
+
type: "acknowledgment",
|
|
3770
|
+
messageId: generateMessageId(),
|
|
3771
|
+
acknowledgedMessageId: msg.messageId,
|
|
3772
|
+
lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0,
|
|
3773
|
+
...deliveryFullyApplied && msg.maxDeliverySequence !== void 0 ? { deliverySequence: msg.maxDeliverySequence } : {}
|
|
3774
|
+
};
|
|
3775
|
+
this.transport.send(ack);
|
|
3776
|
+
this.emitter?.emit({
|
|
3777
|
+
type: "sync:acknowledged",
|
|
3778
|
+
sequenceNumber: lastOp ? lastOp.sequenceNumber : 0
|
|
3779
|
+
});
|
|
3780
|
+
}
|
|
3781
|
+
if (isDeliveryBatch && fullyApplied && msg.maxDeliverySequence !== void 0) {
|
|
3782
|
+
this.deliveryGapRepeatCount = 0;
|
|
3783
|
+
this.lastDeliveryGapKey = null;
|
|
3784
|
+
this.deliveryWatermark = msg.maxDeliverySequence;
|
|
3785
|
+
this.setViewWatermark(this.deliverySignature(), this.deliveryWatermark);
|
|
3786
|
+
await this.persistDeliveryWatermark(this.deliveryWatermark);
|
|
3787
|
+
if (this.blockedFailure && inScopeOps.some((op) => op.id === this.blockedFailure?.operationId)) {
|
|
3788
|
+
const recovered = this.blockedFailure;
|
|
3789
|
+
this.blockedFailure = null;
|
|
3790
|
+
this.emitter?.emit({ type: "sync:apply-recovered", failure: recovered });
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
this.hasInFlightDeliveryBatch = false;
|
|
3232
3794
|
if (this.state === "syncing") {
|
|
3233
3795
|
this.deltaBatchesReceived++;
|
|
3234
3796
|
const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
|
|
@@ -3236,15 +3798,17 @@ var SyncEngine = class {
|
|
|
3236
3798
|
this.initialSyncTotalBatches = msg.totalBatches;
|
|
3237
3799
|
}
|
|
3238
3800
|
this.metricsCollector.updateInitialSyncProgress(this.deltaBatchesReceived, totalBatches);
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3801
|
+
if (!isDeliveryBatch) {
|
|
3802
|
+
const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
|
|
3803
|
+
inScopeOps.length > 0 ? inScopeOps : operations,
|
|
3804
|
+
msg.batchIndex
|
|
3805
|
+
);
|
|
3806
|
+
if (cursorFromBatch) {
|
|
3807
|
+
this.resumeDeltaCursor = cursorFromBatch;
|
|
3808
|
+
await this.persistDeltaCursor(cursorFromBatch);
|
|
3809
|
+
}
|
|
3246
3810
|
}
|
|
3247
|
-
if (msg.isFinal) {
|
|
3811
|
+
if (msg.isFinal && (!isDeliveryBatch || fullyApplied)) {
|
|
3248
3812
|
this.deltaReceiveComplete = true;
|
|
3249
3813
|
this.resumeDeltaCursor = null;
|
|
3250
3814
|
await this.persistDeltaCursor(null);
|
|
@@ -3253,29 +3817,57 @@ var SyncEngine = class {
|
|
|
3253
3817
|
}
|
|
3254
3818
|
}
|
|
3255
3819
|
}
|
|
3256
|
-
|
|
3820
|
+
/** Acknowledge a delivery batch (used for duplicates) without re-applying it. */
|
|
3821
|
+
sendDeliveryAck(acknowledgedMessageId, deliverySequence) {
|
|
3822
|
+
const ack = {
|
|
3823
|
+
type: "acknowledgment",
|
|
3824
|
+
messageId: generateMessageId(),
|
|
3825
|
+
acknowledgedMessageId,
|
|
3826
|
+
lastSequenceNumber: 0,
|
|
3827
|
+
deliverySequence
|
|
3828
|
+
};
|
|
3829
|
+
this.transport.send(ack);
|
|
3830
|
+
}
|
|
3831
|
+
async handleAcknowledgment(msg) {
|
|
3832
|
+
this.clearOutboundAckTimer();
|
|
3257
3833
|
if (this.state === "syncing" && this.config.strictHandshake) {
|
|
3258
3834
|
this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
|
|
3259
3835
|
this.markDeltaSendCompleteIfReady();
|
|
3260
3836
|
}
|
|
3261
3837
|
if (this.currentBatch) {
|
|
3262
|
-
|
|
3838
|
+
if (this.currentBatchRequiresPartialAck) {
|
|
3839
|
+
await this.outboundQueue.acknowledgeThrough(
|
|
3840
|
+
this.currentBatch.batchId,
|
|
3841
|
+
msg.lastSequenceNumber
|
|
3842
|
+
);
|
|
3843
|
+
} else {
|
|
3844
|
+
await this.outboundQueue.acknowledge(this.currentBatch.batchId);
|
|
3845
|
+
}
|
|
3263
3846
|
this.currentBatch = null;
|
|
3847
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3264
3848
|
const now = Date.now();
|
|
3265
3849
|
this.lastSyncedAt = now;
|
|
3266
3850
|
this.lastSuccessfulPush = now;
|
|
3267
3851
|
}
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
);
|
|
3852
|
+
await this.advanceLastAckedForLocalNode(msg.lastSequenceNumber);
|
|
3853
|
+
await this.refreshPendingCount();
|
|
3271
3854
|
if (this.state === "streaming" && this.outboundQueue.hasOperations) {
|
|
3272
3855
|
this.flushQueue();
|
|
3273
3856
|
}
|
|
3274
3857
|
}
|
|
3275
3858
|
handleError(msg) {
|
|
3859
|
+
this.clearOutboundAckTimer();
|
|
3860
|
+
if (this.currentBatch) {
|
|
3861
|
+
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
3862
|
+
this.currentBatch = null;
|
|
3863
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3864
|
+
}
|
|
3276
3865
|
this.transitionTo("error");
|
|
3277
|
-
if (msg.code === "AUTH_FAILED") {
|
|
3866
|
+
if (msg.code === "AUTH_FAILED" || msg.code === "DEVICE_REVOKED") {
|
|
3867
|
+
this.authRejected = true;
|
|
3868
|
+
this.suspensionReason = msg.code === "DEVICE_REVOKED" ? "device-revoked" : "auth-rejected";
|
|
3278
3869
|
this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
|
|
3870
|
+
this.emitter?.emit({ type: "sync:suspended", reason: this.suspensionReason });
|
|
3279
3871
|
}
|
|
3280
3872
|
if (msg.code === "INVALID_TIMESTAMP") {
|
|
3281
3873
|
this.clockBlocked = true;
|
|
@@ -3291,6 +3883,64 @@ var SyncEngine = class {
|
|
|
3291
3883
|
this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
|
|
3292
3884
|
this.transitionTo("disconnected");
|
|
3293
3885
|
}
|
|
3886
|
+
/**
|
|
3887
|
+
* Handle a per-operation rejection: divert the op out of the pending outbound
|
|
3888
|
+
* queue (so it is never retried or resurrected on reconnect), record it in the
|
|
3889
|
+
* durable rejected store (so it is kept and explainable), and emit an event so
|
|
3890
|
+
* the app can reconcile. Unlike {@link handleError}, this is a normal per-op
|
|
3891
|
+
* signal, so the connection stays up.
|
|
3892
|
+
*/
|
|
3893
|
+
async handleOperationRejected(msg) {
|
|
3894
|
+
if (msg.retriable) {
|
|
3895
|
+
this.currentBatchRequiresPartialAck = true;
|
|
3896
|
+
await this.refreshPendingCount();
|
|
3897
|
+
this.emitter?.emit({
|
|
3898
|
+
type: "sync:operation-rejected",
|
|
3899
|
+
operationId: msg.operationId,
|
|
3900
|
+
collection: msg.collection,
|
|
3901
|
+
recordId: msg.recordId,
|
|
3902
|
+
code: msg.code,
|
|
3903
|
+
message: msg.message,
|
|
3904
|
+
retriable: true
|
|
3905
|
+
});
|
|
3906
|
+
return;
|
|
3907
|
+
}
|
|
3908
|
+
await this.outboundQueue.reject(msg.operationId);
|
|
3909
|
+
await this.rejectedStorage.record({
|
|
3910
|
+
operationId: msg.operationId,
|
|
3911
|
+
collection: msg.collection,
|
|
3912
|
+
recordId: msg.recordId,
|
|
3913
|
+
code: msg.code,
|
|
3914
|
+
message: msg.message,
|
|
3915
|
+
retriable: msg.retriable,
|
|
3916
|
+
rejectedAt: Date.now()
|
|
3917
|
+
});
|
|
3918
|
+
await this.refreshPendingCount();
|
|
3919
|
+
this.emitter?.emit({
|
|
3920
|
+
type: "sync:operation-rejected",
|
|
3921
|
+
operationId: msg.operationId,
|
|
3922
|
+
collection: msg.collection,
|
|
3923
|
+
recordId: msg.recordId,
|
|
3924
|
+
code: msg.code,
|
|
3925
|
+
message: msg.message,
|
|
3926
|
+
retriable: msg.retriable
|
|
3927
|
+
});
|
|
3928
|
+
}
|
|
3929
|
+
/**
|
|
3930
|
+
* Every operation the server rejected that has not yet been reconciled. The app
|
|
3931
|
+
* uses this (alongside the `sync:operation-rejected` event) to surface failed
|
|
3932
|
+
* submissions and decide whether to roll back or resubmit.
|
|
3933
|
+
*/
|
|
3934
|
+
getRejectedOperations() {
|
|
3935
|
+
return this.rejectedStorage.list();
|
|
3936
|
+
}
|
|
3937
|
+
/**
|
|
3938
|
+
* Forget rejected operations by id once the app has reconciled them (rolled the
|
|
3939
|
+
* optimistic write back or resubmitted a corrected op).
|
|
3940
|
+
*/
|
|
3941
|
+
clearRejectedOperations(operationIds) {
|
|
3942
|
+
return this.rejectedStorage.remove(operationIds);
|
|
3943
|
+
}
|
|
3294
3944
|
async checkDeltaComplete() {
|
|
3295
3945
|
if (!this.deltaSendComplete || !this.deltaReceiveComplete) {
|
|
3296
3946
|
return;
|
|
@@ -3389,7 +4039,7 @@ var SyncEngine = class {
|
|
|
3389
4039
|
return;
|
|
3390
4040
|
}
|
|
3391
4041
|
const ops = await this.store.getOperationRange(localNodeId, 1, localSeq);
|
|
3392
|
-
const inScope =
|
|
4042
|
+
const inScope = await this.filterAllowedForSync(ops);
|
|
3393
4043
|
for (const op of inScope) {
|
|
3394
4044
|
await this.outboundQueue.enqueue(op);
|
|
3395
4045
|
}
|
|
@@ -3400,6 +4050,7 @@ var SyncEngine = class {
|
|
|
3400
4050
|
const batch = this.outboundQueue.takeBatch(this.batchSize);
|
|
3401
4051
|
if (!batch) return;
|
|
3402
4052
|
this.currentBatch = batch;
|
|
4053
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3403
4054
|
if (this.encryptor) {
|
|
3404
4055
|
this.encryptor.encryptBatch(batch.operations).then(
|
|
3405
4056
|
(encrypted) => {
|
|
@@ -3412,6 +4063,7 @@ var SyncEngine = class {
|
|
|
3412
4063
|
batchIndex: 0
|
|
3413
4064
|
};
|
|
3414
4065
|
this.transport.send(batchMsg);
|
|
4066
|
+
this.startOutboundAckTimer(batch.batchId);
|
|
3415
4067
|
this.emitter?.emit({
|
|
3416
4068
|
type: "sync:sent",
|
|
3417
4069
|
operations: batch.operations,
|
|
@@ -3421,6 +4073,7 @@ var SyncEngine = class {
|
|
|
3421
4073
|
(err) => {
|
|
3422
4074
|
this.outboundQueue.returnBatch(batch.batchId);
|
|
3423
4075
|
this.currentBatch = null;
|
|
4076
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3424
4077
|
this.emitter?.emit({
|
|
3425
4078
|
type: "sync:disconnected",
|
|
3426
4079
|
reason: err instanceof Error ? err.message : "Encryption failed"
|
|
@@ -3437,6 +4090,7 @@ var SyncEngine = class {
|
|
|
3437
4090
|
batchIndex: 0
|
|
3438
4091
|
};
|
|
3439
4092
|
this.transport.send(batchMsg);
|
|
4093
|
+
this.startOutboundAckTimer(batch.batchId);
|
|
3440
4094
|
this.emitter?.emit({
|
|
3441
4095
|
type: "sync:sent",
|
|
3442
4096
|
operations: batch.operations,
|
|
@@ -3444,6 +4098,34 @@ var SyncEngine = class {
|
|
|
3444
4098
|
});
|
|
3445
4099
|
}
|
|
3446
4100
|
}
|
|
4101
|
+
startOutboundAckTimer(batchId) {
|
|
4102
|
+
this.clearOutboundAckTimer();
|
|
4103
|
+
if (this.outboundAckTimeoutMs <= 0) return;
|
|
4104
|
+
this.outboundAckTimer = setTimeout(() => {
|
|
4105
|
+
void this.handleOutboundAckTimeout(batchId);
|
|
4106
|
+
}, this.outboundAckTimeoutMs);
|
|
4107
|
+
}
|
|
4108
|
+
clearOutboundAckTimer() {
|
|
4109
|
+
if (!this.outboundAckTimer) return;
|
|
4110
|
+
clearTimeout(this.outboundAckTimer);
|
|
4111
|
+
this.outboundAckTimer = null;
|
|
4112
|
+
}
|
|
4113
|
+
async handleOutboundAckTimeout(batchId) {
|
|
4114
|
+
if (!this.currentBatch || this.currentBatch.batchId !== batchId) return;
|
|
4115
|
+
this.outboundQueue.returnBatch(batchId);
|
|
4116
|
+
this.currentBatch = null;
|
|
4117
|
+
this.currentBatchRequiresPartialAck = false;
|
|
4118
|
+
this.clearOutboundAckTimer();
|
|
4119
|
+
await this.refreshPendingCount();
|
|
4120
|
+
if (this.state === "disconnected") return;
|
|
4121
|
+
const reason = `Timed out waiting for acknowledgment of outbound batch ${batchId}`;
|
|
4122
|
+
try {
|
|
4123
|
+
await this.transport.disconnect();
|
|
4124
|
+
} finally {
|
|
4125
|
+
this.ensureDisconnected();
|
|
4126
|
+
this.emitter?.emit({ type: "sync:disconnected", reason });
|
|
4127
|
+
}
|
|
4128
|
+
}
|
|
3447
4129
|
handleAwarenessUpdate(msg) {
|
|
3448
4130
|
const awarenessMessage = {
|
|
3449
4131
|
type: "awareness",
|
|
@@ -3453,9 +4135,11 @@ var SyncEngine = class {
|
|
|
3453
4135
|
this.awarenessManager.handleRemoteMessage(awarenessMessage);
|
|
3454
4136
|
}
|
|
3455
4137
|
handleTransportClose(reason) {
|
|
4138
|
+
this.clearOutboundAckTimer();
|
|
3456
4139
|
if (this.currentBatch) {
|
|
3457
4140
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
3458
4141
|
this.currentBatch = null;
|
|
4142
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3459
4143
|
}
|
|
3460
4144
|
if (this.schemaBlocked) {
|
|
3461
4145
|
return;
|
|
@@ -3487,11 +4171,40 @@ var SyncEngine = class {
|
|
|
3487
4171
|
this.serializer.setWireFormat(format);
|
|
3488
4172
|
}
|
|
3489
4173
|
}
|
|
3490
|
-
operationAllowedForSync(op) {
|
|
3491
|
-
if (
|
|
4174
|
+
async operationAllowedForSync(op) {
|
|
4175
|
+
if (this.matchesScopeAndSubsets(op)) {
|
|
4176
|
+
return true;
|
|
4177
|
+
}
|
|
4178
|
+
const fullRecord = await this.readRecordForBackfill(op);
|
|
4179
|
+
if (!fullRecord) {
|
|
3492
4180
|
return false;
|
|
3493
4181
|
}
|
|
3494
|
-
return
|
|
4182
|
+
return this.matchesScopeAndSubsets(op, fullRecord);
|
|
4183
|
+
}
|
|
4184
|
+
matchesScopeAndSubsets(op, fullRecord) {
|
|
4185
|
+
if (!operationMatchesScope(op, this.activeScope, fullRecord)) {
|
|
4186
|
+
return false;
|
|
4187
|
+
}
|
|
4188
|
+
return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets(), fullRecord);
|
|
4189
|
+
}
|
|
4190
|
+
async readRecordForBackfill(op) {
|
|
4191
|
+
if (!this.store.readRecordFields) {
|
|
4192
|
+
return null;
|
|
4193
|
+
}
|
|
4194
|
+
try {
|
|
4195
|
+
return await this.store.readRecordFields(op.collection, op.recordId);
|
|
4196
|
+
} catch {
|
|
4197
|
+
return null;
|
|
4198
|
+
}
|
|
4199
|
+
}
|
|
4200
|
+
async filterAllowedForSync(ops) {
|
|
4201
|
+
const allowed = [];
|
|
4202
|
+
for (const op of ops) {
|
|
4203
|
+
if (await this.operationAllowedForSync(op)) {
|
|
4204
|
+
allowed.push(op);
|
|
4205
|
+
}
|
|
4206
|
+
}
|
|
4207
|
+
return allowed;
|
|
3495
4208
|
}
|
|
3496
4209
|
async persistDeltaCursor(cursor) {
|
|
3497
4210
|
if (!this.syncState?.saveDeltaCursor) {
|
|
@@ -3499,6 +4212,12 @@ var SyncEngine = class {
|
|
|
3499
4212
|
}
|
|
3500
4213
|
await this.syncState.saveDeltaCursor(cursor);
|
|
3501
4214
|
}
|
|
4215
|
+
async persistDeliveryWatermark(watermark, signature = this.deliverySignature()) {
|
|
4216
|
+
if (!this.syncState?.saveDeliveryWatermark) {
|
|
4217
|
+
return;
|
|
4218
|
+
}
|
|
4219
|
+
await this.syncState.saveDeliveryWatermark(signature, watermark);
|
|
4220
|
+
}
|
|
3502
4221
|
scheduleQuerySubsetReconnect() {
|
|
3503
4222
|
if (this.querySubsetReconnectTimer) {
|
|
3504
4223
|
clearTimeout(this.querySubsetReconnectTimer);
|
|
@@ -3511,8 +4230,7 @@ var SyncEngine = class {
|
|
|
3511
4230
|
}, 500);
|
|
3512
4231
|
}
|
|
3513
4232
|
async reconnectForQuerySubsets() {
|
|
3514
|
-
await this.
|
|
3515
|
-
await this.start();
|
|
4233
|
+
await this.reconnect();
|
|
3516
4234
|
}
|
|
3517
4235
|
};
|
|
3518
4236
|
function awarenessStatesToWire(states) {
|
|
@@ -4193,12 +4911,22 @@ function fromBase642(str) {
|
|
|
4193
4911
|
// src/reactivity/sync-status-controller.ts
|
|
4194
4912
|
var OFFLINE_SYNC_STATUS = Object.freeze({
|
|
4195
4913
|
status: "offline",
|
|
4914
|
+
phase: "offline",
|
|
4915
|
+
reconnecting: false,
|
|
4196
4916
|
pendingOperations: 0,
|
|
4197
4917
|
lastSyncedAt: null,
|
|
4198
4918
|
lastSuccessfulPush: null,
|
|
4199
4919
|
lastSuccessfulPull: null,
|
|
4200
4920
|
conflicts: 0,
|
|
4201
|
-
clockSkewMs: null
|
|
4921
|
+
clockSkewMs: null,
|
|
4922
|
+
inFlightUploadOperations: 0,
|
|
4923
|
+
hasInFlightDeliveryBatch: false,
|
|
4924
|
+
activeViewId: "",
|
|
4925
|
+
activeViewComplete: false,
|
|
4926
|
+
initialSync: { complete: false, receivedBatches: 0, totalBatches: null, progress: null },
|
|
4927
|
+
deliveryWatermark: 0,
|
|
4928
|
+
serverFrontier: null,
|
|
4929
|
+
blockedFailure: null
|
|
4202
4930
|
});
|
|
4203
4931
|
var SYNC_STATUS_EVENT_TYPES = [
|
|
4204
4932
|
"sync:connected",
|
|
@@ -4210,6 +4938,11 @@ var SYNC_STATUS_EVENT_TYPES = [
|
|
|
4210
4938
|
"sync:received",
|
|
4211
4939
|
"sync:acknowledged",
|
|
4212
4940
|
"sync:apply-failed",
|
|
4941
|
+
"sync:suspended",
|
|
4942
|
+
"sync:apply-blocked",
|
|
4943
|
+
"sync:apply-retrying",
|
|
4944
|
+
"sync:apply-recovered",
|
|
4945
|
+
"sync:delivery-gap",
|
|
4213
4946
|
"sync:diagnostics",
|
|
4214
4947
|
"sync:initial-sync-progress"
|
|
4215
4948
|
];
|
|
@@ -4355,6 +5088,7 @@ export {
|
|
|
4355
5088
|
InvalidScopeError,
|
|
4356
5089
|
JsonMessageSerializer,
|
|
4357
5090
|
KeyDerivationError,
|
|
5091
|
+
MemoryRejectedOperationStorage,
|
|
4358
5092
|
NegotiatedMessageSerializer,
|
|
4359
5093
|
OFFLINE_SYNC_STATUS,
|
|
4360
5094
|
OutboundQueue,
|
|
@@ -4393,11 +5127,13 @@ export {
|
|
|
4393
5127
|
isHandshakeMessage,
|
|
4394
5128
|
isHandshakeResponseMessage,
|
|
4395
5129
|
isOperationBatchMessage,
|
|
5130
|
+
isOperationRejectedMessage,
|
|
4396
5131
|
isSchemaMismatchReject,
|
|
4397
5132
|
isSyncMessage,
|
|
4398
5133
|
isYjsDocUpdateMessage,
|
|
4399
5134
|
operationMatchesQuerySubsets,
|
|
4400
5135
|
operationMatchesScope,
|
|
5136
|
+
querySubsetContains,
|
|
4401
5137
|
richtextDocKey,
|
|
4402
5138
|
sliceOperationsAfterCursor,
|
|
4403
5139
|
subscribeRemoteAwarenessStates,
|