@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.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,11 +80,13 @@ __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,
|
|
85
87
|
operationMatchesQuerySubsets: () => operationMatchesQuerySubsets,
|
|
86
88
|
operationMatchesScope: () => operationMatchesScope,
|
|
89
|
+
querySubsetContains: () => querySubsetContains,
|
|
87
90
|
richtextDocKey: () => richtextDocKey,
|
|
88
91
|
sliceOperationsAfterCursor: () => sliceOperationsAfterCursor,
|
|
89
92
|
subscribeRemoteAwarenessStates: () => subscribeRemoteAwarenessStates,
|
|
@@ -104,13 +107,29 @@ var SYNC_STATES = [
|
|
|
104
107
|
];
|
|
105
108
|
var SYNC_STATUSES = [
|
|
106
109
|
"connected",
|
|
110
|
+
"reconnecting",
|
|
107
111
|
"syncing",
|
|
108
112
|
"synced",
|
|
109
113
|
"offline",
|
|
110
114
|
"clock-error",
|
|
111
115
|
"error",
|
|
112
|
-
"schema-mismatch"
|
|
116
|
+
"schema-mismatch",
|
|
117
|
+
"auth-required"
|
|
113
118
|
];
|
|
119
|
+
var MemoryRejectedOperationStorage = class {
|
|
120
|
+
rejected = /* @__PURE__ */ new Map();
|
|
121
|
+
async record(rejected) {
|
|
122
|
+
this.rejected.set(rejected.operationId, rejected);
|
|
123
|
+
}
|
|
124
|
+
async list() {
|
|
125
|
+
return [...this.rejected.values()];
|
|
126
|
+
}
|
|
127
|
+
async remove(operationIds) {
|
|
128
|
+
for (const id of operationIds) {
|
|
129
|
+
this.rejected.delete(id);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
114
133
|
var ScopeViolationError = class extends import_core.KoraError {
|
|
115
134
|
constructor(operationId, collection, scope, message) {
|
|
116
135
|
super(
|
|
@@ -143,12 +162,19 @@ function operationMatchesScope(op, scopeMap, fullRecord) {
|
|
|
143
162
|
const snapshot = buildSnapshot(op, fullRecord ?? void 0);
|
|
144
163
|
if (!snapshot) return false;
|
|
145
164
|
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
146
|
-
if (snapshot[field]
|
|
165
|
+
if (!matchesPredicate(snapshot[field], expected)) {
|
|
147
166
|
return false;
|
|
148
167
|
}
|
|
149
168
|
}
|
|
150
169
|
return true;
|
|
151
170
|
}
|
|
171
|
+
function matchesPredicate(actual, expected) {
|
|
172
|
+
if (expected && typeof expected === "object" && !Array.isArray(expected) && "$in" in expected) {
|
|
173
|
+
const values = expected.$in;
|
|
174
|
+
return Array.isArray(values) && values.some((value) => Object.is(actual, value));
|
|
175
|
+
}
|
|
176
|
+
return Object.is(actual, expected);
|
|
177
|
+
}
|
|
152
178
|
function filterOperationsByScope(operations, scopeMap) {
|
|
153
179
|
if (!scopeMap) return operations;
|
|
154
180
|
return operations.filter((op) => operationMatchesScope(op, scopeMap));
|
|
@@ -205,24 +231,77 @@ function operationMatchesQuerySubsets(op, subsets, fullRecord) {
|
|
|
205
231
|
if (collectionSubsets.length === 0) {
|
|
206
232
|
return true;
|
|
207
233
|
}
|
|
234
|
+
if (collectionSubsets.some((subset) => Object.keys(subset.where).length === 0)) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
208
237
|
const snapshot = buildSnapshot2(op, fullRecord);
|
|
209
238
|
if (!snapshot) {
|
|
210
239
|
return false;
|
|
211
240
|
}
|
|
212
241
|
return collectionSubsets.some((subset) => recordMatchesWhere(snapshot, subset.where));
|
|
213
242
|
}
|
|
243
|
+
function stableValue(value) {
|
|
244
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
245
|
+
if (value && typeof value === "object") {
|
|
246
|
+
return Object.fromEntries(
|
|
247
|
+
Object.entries(value).filter(([, entry]) => entry !== void 0).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => [key, stableValue(entry)])
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return value;
|
|
251
|
+
}
|
|
252
|
+
function whereContains(broad, narrow) {
|
|
253
|
+
return Object.entries(broad).every(
|
|
254
|
+
([field, expected]) => JSON.stringify(stableValue(narrow[field])) === JSON.stringify(stableValue(expected))
|
|
255
|
+
);
|
|
256
|
+
}
|
|
214
257
|
function dedupeQuerySubsets(subsets) {
|
|
215
|
-
const
|
|
258
|
+
const normalized = subsets.map((subset) => ({
|
|
259
|
+
collection: subset.collection,
|
|
260
|
+
where: stableValue(subset.where)
|
|
261
|
+
}));
|
|
216
262
|
const result = [];
|
|
217
|
-
for (const
|
|
218
|
-
|
|
219
|
-
|
|
263
|
+
for (const candidate of normalized) {
|
|
264
|
+
if (normalized.some(
|
|
265
|
+
(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))
|
|
266
|
+
)) {
|
|
267
|
+
const duplicateAlreadyKept = result.some(
|
|
268
|
+
(item) => item.collection === candidate.collection && JSON.stringify(item.where) === JSON.stringify(candidate.where)
|
|
269
|
+
);
|
|
270
|
+
if (duplicateAlreadyKept || normalized.some(
|
|
271
|
+
(other) => other !== candidate && other.collection === candidate.collection && Object.keys(other.where).length < Object.keys(candidate.where).length && whereContains(other.where, candidate.where)
|
|
272
|
+
))
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (!result.some(
|
|
276
|
+
(item) => item.collection === candidate.collection && JSON.stringify(item.where) === JSON.stringify(candidate.where)
|
|
277
|
+
))
|
|
278
|
+
result.push(candidate);
|
|
279
|
+
}
|
|
280
|
+
return result.sort(
|
|
281
|
+
(a, b) => `${a.collection}:${JSON.stringify(a.where)}`.localeCompare(
|
|
282
|
+
`${b.collection}:${JSON.stringify(b.where)}`
|
|
283
|
+
)
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
function querySubsetContains(broad, narrow) {
|
|
287
|
+
const collections = /* @__PURE__ */ new Set([
|
|
288
|
+
...broad.map((subset) => subset.collection),
|
|
289
|
+
...narrow.map((subset) => subset.collection)
|
|
290
|
+
]);
|
|
291
|
+
for (const collection of collections) {
|
|
292
|
+
const broadForCollection = broad.filter((subset) => subset.collection === collection);
|
|
293
|
+
const narrowForCollection = narrow.filter((subset) => subset.collection === collection);
|
|
294
|
+
if (narrowForCollection.length === 0) {
|
|
295
|
+
if (broadForCollection.length > 0) return false;
|
|
220
296
|
continue;
|
|
221
297
|
}
|
|
222
|
-
|
|
223
|
-
|
|
298
|
+
if (broadForCollection.length === 0) continue;
|
|
299
|
+
if (!narrowForCollection.every(
|
|
300
|
+
(candidate) => broadForCollection.some((other) => whereContains(other.where, candidate.where))
|
|
301
|
+
))
|
|
302
|
+
return false;
|
|
224
303
|
}
|
|
225
|
-
return
|
|
304
|
+
return true;
|
|
226
305
|
}
|
|
227
306
|
|
|
228
307
|
// src/delta/delta-cursor.ts
|
|
@@ -308,6 +387,8 @@ function isSyncMessage(value) {
|
|
|
308
387
|
return isAcknowledgmentMessage(value);
|
|
309
388
|
case "error":
|
|
310
389
|
return isErrorMessage(value);
|
|
390
|
+
case "operation-rejected":
|
|
391
|
+
return isOperationRejectedMessage(value);
|
|
311
392
|
case "awareness-update":
|
|
312
393
|
return isAwarenessUpdateMessage(value);
|
|
313
394
|
case "yjs-doc-update":
|
|
@@ -347,6 +428,11 @@ function isErrorMessage(value) {
|
|
|
347
428
|
const msg = value;
|
|
348
429
|
return msg.type === "error" && typeof msg.messageId === "string" && typeof msg.code === "string" && typeof msg.message === "string" && typeof msg.retriable === "boolean";
|
|
349
430
|
}
|
|
431
|
+
function isOperationRejectedMessage(value) {
|
|
432
|
+
if (typeof value !== "object" || value === null) return false;
|
|
433
|
+
const msg = value;
|
|
434
|
+
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";
|
|
435
|
+
}
|
|
350
436
|
function isAwarenessUpdateMessage(value) {
|
|
351
437
|
if (typeof value !== "object" || value === null) return false;
|
|
352
438
|
const msg = value;
|
|
@@ -597,7 +683,8 @@ function toProtoEnvelope(message) {
|
|
|
597
683
|
})),
|
|
598
684
|
schemaVersion: message.schemaVersion,
|
|
599
685
|
authToken: message.authToken,
|
|
600
|
-
supportedWireFormats: message.supportedWireFormats
|
|
686
|
+
supportedWireFormats: message.supportedWireFormats,
|
|
687
|
+
...message.lastDeliverySequence !== void 0 ? { lastDeliverySequence: message.lastDeliverySequence } : {}
|
|
601
688
|
};
|
|
602
689
|
case "handshake-response":
|
|
603
690
|
return {
|
|
@@ -613,7 +700,8 @@ function toProtoEnvelope(message) {
|
|
|
613
700
|
rejectReason: message.rejectReason,
|
|
614
701
|
selectedWireFormat: message.selectedWireFormat,
|
|
615
702
|
serverTime: message.serverTime,
|
|
616
|
-
blobStorageEnabled: message.blobStorageEnabled
|
|
703
|
+
blobStorageEnabled: message.blobStorageEnabled,
|
|
704
|
+
...message.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: message.serverMaxDeliverySequence } : {}
|
|
617
705
|
};
|
|
618
706
|
case "operation-batch":
|
|
619
707
|
return {
|
|
@@ -621,14 +709,17 @@ function toProtoEnvelope(message) {
|
|
|
621
709
|
messageId: message.messageId,
|
|
622
710
|
operations: message.operations.map(serializeProtoOperation),
|
|
623
711
|
isFinal: message.isFinal,
|
|
624
|
-
batchIndex: message.batchIndex
|
|
712
|
+
batchIndex: message.batchIndex,
|
|
713
|
+
...message.baseDeliverySequence !== void 0 ? { baseDeliverySequence: message.baseDeliverySequence } : {},
|
|
714
|
+
...message.maxDeliverySequence !== void 0 ? { maxDeliverySequence: message.maxDeliverySequence } : {}
|
|
625
715
|
};
|
|
626
716
|
case "acknowledgment":
|
|
627
717
|
return {
|
|
628
718
|
type: message.type,
|
|
629
719
|
messageId: message.messageId,
|
|
630
720
|
acknowledgedMessageId: message.acknowledgedMessageId,
|
|
631
|
-
lastSequenceNumber: message.lastSequenceNumber
|
|
721
|
+
lastSequenceNumber: message.lastSequenceNumber,
|
|
722
|
+
...message.deliverySequence !== void 0 ? { deliverySequence: message.deliverySequence } : {}
|
|
632
723
|
};
|
|
633
724
|
case "error":
|
|
634
725
|
return {
|
|
@@ -638,6 +729,17 @@ function toProtoEnvelope(message) {
|
|
|
638
729
|
errorMessage: message.message,
|
|
639
730
|
retriable: message.retriable
|
|
640
731
|
};
|
|
732
|
+
case "operation-rejected":
|
|
733
|
+
return {
|
|
734
|
+
type: message.type,
|
|
735
|
+
messageId: message.messageId,
|
|
736
|
+
operationId: message.operationId,
|
|
737
|
+
collection: message.collection,
|
|
738
|
+
recordId: message.recordId,
|
|
739
|
+
errorCode: message.code,
|
|
740
|
+
errorMessage: message.message,
|
|
741
|
+
retriable: message.retriable
|
|
742
|
+
};
|
|
641
743
|
case "awareness-update":
|
|
642
744
|
case "yjs-doc-update":
|
|
643
745
|
return {
|
|
@@ -682,7 +784,8 @@ function fromProtoEnvelope(envelope) {
|
|
|
682
784
|
authToken: envelope.authToken,
|
|
683
785
|
supportedWireFormats: envelope.supportedWireFormats?.filter(
|
|
684
786
|
(format) => format === "json" || format === "protobuf"
|
|
685
|
-
)
|
|
787
|
+
),
|
|
788
|
+
...envelope.lastDeliverySequence !== void 0 ? { lastDeliverySequence: envelope.lastDeliverySequence } : {}
|
|
686
789
|
};
|
|
687
790
|
case "handshake-response":
|
|
688
791
|
return {
|
|
@@ -699,7 +802,8 @@ function fromProtoEnvelope(envelope) {
|
|
|
699
802
|
// Preserve the server's wall-clock time so clock-skew detection and
|
|
700
803
|
// automatic timestamp rebase work over the protobuf wire, not just JSON.
|
|
701
804
|
...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {},
|
|
702
|
-
...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {}
|
|
805
|
+
...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {},
|
|
806
|
+
...envelope.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: envelope.serverMaxDeliverySequence } : {}
|
|
703
807
|
};
|
|
704
808
|
case "operation-batch":
|
|
705
809
|
return {
|
|
@@ -707,14 +811,17 @@ function fromProtoEnvelope(envelope) {
|
|
|
707
811
|
messageId: envelope.messageId,
|
|
708
812
|
operations: (envelope.operations ?? []).map(deserializeProtoOperation),
|
|
709
813
|
isFinal: envelope.isFinal ?? false,
|
|
710
|
-
batchIndex: envelope.batchIndex ?? 0
|
|
814
|
+
batchIndex: envelope.batchIndex ?? 0,
|
|
815
|
+
...envelope.baseDeliverySequence !== void 0 ? { baseDeliverySequence: envelope.baseDeliverySequence } : {},
|
|
816
|
+
...envelope.maxDeliverySequence !== void 0 ? { maxDeliverySequence: envelope.maxDeliverySequence } : {}
|
|
711
817
|
};
|
|
712
818
|
case "acknowledgment":
|
|
713
819
|
return {
|
|
714
820
|
type: "acknowledgment",
|
|
715
821
|
messageId: envelope.messageId,
|
|
716
822
|
acknowledgedMessageId: envelope.acknowledgedMessageId ?? "",
|
|
717
|
-
lastSequenceNumber: envelope.lastSequenceNumber ?? 0
|
|
823
|
+
lastSequenceNumber: envelope.lastSequenceNumber ?? 0,
|
|
824
|
+
...envelope.deliverySequence !== void 0 ? { deliverySequence: envelope.deliverySequence } : {}
|
|
718
825
|
};
|
|
719
826
|
case "error":
|
|
720
827
|
return {
|
|
@@ -724,6 +831,17 @@ function fromProtoEnvelope(envelope) {
|
|
|
724
831
|
message: envelope.errorMessage ?? "Unknown error",
|
|
725
832
|
retriable: envelope.retriable ?? false
|
|
726
833
|
};
|
|
834
|
+
case "operation-rejected":
|
|
835
|
+
return {
|
|
836
|
+
type: "operation-rejected",
|
|
837
|
+
messageId: envelope.messageId,
|
|
838
|
+
operationId: envelope.operationId ?? "",
|
|
839
|
+
collection: envelope.collection ?? "",
|
|
840
|
+
recordId: envelope.recordId ?? "",
|
|
841
|
+
code: envelope.errorCode ?? "REJECTED",
|
|
842
|
+
message: envelope.errorMessage ?? "",
|
|
843
|
+
retriable: envelope.retriable ?? false
|
|
844
|
+
};
|
|
727
845
|
case "blob-chunk-request":
|
|
728
846
|
return {
|
|
729
847
|
type: "blob-chunk-request",
|
|
@@ -898,6 +1016,21 @@ function encodeEnvelope(envelope) {
|
|
|
898
1016
|
if (envelope.hasBytes !== void 0) writer.uint32(184).bool(envelope.hasBytes);
|
|
899
1017
|
if (envelope.blobStorageEnabled !== void 0)
|
|
900
1018
|
writer.uint32(200).bool(envelope.blobStorageEnabled);
|
|
1019
|
+
if (envelope.operationId && envelope.operationId.length > 0)
|
|
1020
|
+
writer.uint32(210).string(envelope.operationId);
|
|
1021
|
+
if (envelope.collection && envelope.collection.length > 0)
|
|
1022
|
+
writer.uint32(218).string(envelope.collection);
|
|
1023
|
+
if (envelope.recordId && envelope.recordId.length > 0)
|
|
1024
|
+
writer.uint32(226).string(envelope.recordId);
|
|
1025
|
+
if (envelope.lastDeliverySequence !== void 0)
|
|
1026
|
+
writer.uint32(232).int64(envelope.lastDeliverySequence);
|
|
1027
|
+
if (envelope.baseDeliverySequence !== void 0)
|
|
1028
|
+
writer.uint32(240).int64(envelope.baseDeliverySequence);
|
|
1029
|
+
if (envelope.maxDeliverySequence !== void 0)
|
|
1030
|
+
writer.uint32(248).int64(envelope.maxDeliverySequence);
|
|
1031
|
+
if (envelope.deliverySequence !== void 0) writer.uint32(256).int64(envelope.deliverySequence);
|
|
1032
|
+
if (envelope.serverMaxDeliverySequence !== void 0)
|
|
1033
|
+
writer.uint32(264).int64(envelope.serverMaxDeliverySequence);
|
|
901
1034
|
return writer.finish();
|
|
902
1035
|
}
|
|
903
1036
|
function decodeEnvelope(bytes) {
|
|
@@ -984,6 +1117,30 @@ function decodeEnvelope(bytes) {
|
|
|
984
1117
|
case 25:
|
|
985
1118
|
envelope.blobStorageEnabled = reader.bool();
|
|
986
1119
|
break;
|
|
1120
|
+
case 26:
|
|
1121
|
+
envelope.operationId = reader.string();
|
|
1122
|
+
break;
|
|
1123
|
+
case 27:
|
|
1124
|
+
envelope.collection = reader.string();
|
|
1125
|
+
break;
|
|
1126
|
+
case 28:
|
|
1127
|
+
envelope.recordId = reader.string();
|
|
1128
|
+
break;
|
|
1129
|
+
case 29:
|
|
1130
|
+
envelope.lastDeliverySequence = longToNumber(reader.int64());
|
|
1131
|
+
break;
|
|
1132
|
+
case 30:
|
|
1133
|
+
envelope.baseDeliverySequence = longToNumber(reader.int64());
|
|
1134
|
+
break;
|
|
1135
|
+
case 31:
|
|
1136
|
+
envelope.maxDeliverySequence = longToNumber(reader.int64());
|
|
1137
|
+
break;
|
|
1138
|
+
case 32:
|
|
1139
|
+
envelope.deliverySequence = longToNumber(reader.int64());
|
|
1140
|
+
break;
|
|
1141
|
+
case 33:
|
|
1142
|
+
envelope.serverMaxDeliverySequence = longToNumber(reader.int64());
|
|
1143
|
+
break;
|
|
987
1144
|
default:
|
|
988
1145
|
reader.skipType(tag & 7);
|
|
989
1146
|
}
|
|
@@ -1483,6 +1640,7 @@ var ChaosTransport = class {
|
|
|
1483
1640
|
reorderRate;
|
|
1484
1641
|
maxLatency;
|
|
1485
1642
|
random;
|
|
1643
|
+
dropPredicate;
|
|
1486
1644
|
messageHandler = null;
|
|
1487
1645
|
reorderBuffer = [];
|
|
1488
1646
|
pending = [];
|
|
@@ -1493,6 +1651,7 @@ var ChaosTransport = class {
|
|
|
1493
1651
|
this.reorderRate = config?.reorderRate ?? 0;
|
|
1494
1652
|
this.maxLatency = config?.maxLatency ?? 0;
|
|
1495
1653
|
this.random = config?.randomSource ?? Math.random;
|
|
1654
|
+
this.dropPredicate = config?.dropPredicate ?? null;
|
|
1496
1655
|
}
|
|
1497
1656
|
async connect(url, options) {
|
|
1498
1657
|
this.inner.onMessage((msg) => this.handleIncoming(msg));
|
|
@@ -1511,6 +1670,9 @@ var ChaosTransport = class {
|
|
|
1511
1670
|
return this.inner.disconnect();
|
|
1512
1671
|
}
|
|
1513
1672
|
send(message) {
|
|
1673
|
+
if (this.dropPredicate?.(message, "outgoing")) {
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1514
1676
|
if (this.random() < this.dropRate) {
|
|
1515
1677
|
return;
|
|
1516
1678
|
}
|
|
@@ -1538,6 +1700,9 @@ var ChaosTransport = class {
|
|
|
1538
1700
|
}
|
|
1539
1701
|
handleIncoming(message) {
|
|
1540
1702
|
if (!this.messageHandler) return;
|
|
1703
|
+
if (this.dropPredicate?.(message, "incoming")) {
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1541
1706
|
if (this.random() < this.dropRate) {
|
|
1542
1707
|
return;
|
|
1543
1708
|
}
|
|
@@ -2502,6 +2667,32 @@ var OutboundQueue = class {
|
|
|
2502
2667
|
const ids = ops.map((op) => op.id);
|
|
2503
2668
|
await this.storage.dequeue(ids);
|
|
2504
2669
|
}
|
|
2670
|
+
/**
|
|
2671
|
+
* Acknowledge only operations in an in-flight batch whose local sequence number is
|
|
2672
|
+
* covered by the server's ack. Any later operations return to the front of the
|
|
2673
|
+
* queue for retry. This is required when the server accepts a prefix but rejects a
|
|
2674
|
+
* later operation retriably (for example a stale-scope push after auth changed).
|
|
2675
|
+
*/
|
|
2676
|
+
async acknowledgeThrough(batchId, lastSequenceNumber) {
|
|
2677
|
+
const ops = this.inFlight.get(batchId);
|
|
2678
|
+
if (!ops) return;
|
|
2679
|
+
this.inFlight.delete(batchId);
|
|
2680
|
+
const acknowledged = ops.filter((op) => op.sequenceNumber <= lastSequenceNumber);
|
|
2681
|
+
const retry = ops.filter((op) => op.sequenceNumber > lastSequenceNumber);
|
|
2682
|
+
if (retry.length > 0) {
|
|
2683
|
+
this.queue.unshift(...retry);
|
|
2684
|
+
if (this.queue.length > 1) {
|
|
2685
|
+
this.queue = (0, import_internal.topologicalSort)(this.queue);
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
if (acknowledged.length > 0) {
|
|
2689
|
+
const ids = acknowledged.map((op) => op.id);
|
|
2690
|
+
for (const id of ids) {
|
|
2691
|
+
this.seen.delete(id);
|
|
2692
|
+
}
|
|
2693
|
+
await this.storage.dequeue(ids);
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2505
2696
|
/**
|
|
2506
2697
|
* Return a failed batch to the front of the queue for retry.
|
|
2507
2698
|
* Prepends the operations to maintain priority.
|
|
@@ -2584,6 +2775,37 @@ var OutboundQueue = class {
|
|
|
2584
2775
|
get isInitialized() {
|
|
2585
2776
|
return this.initialized;
|
|
2586
2777
|
}
|
|
2778
|
+
/**
|
|
2779
|
+
* Remove a single operation the server permanently rejected — from the queue,
|
|
2780
|
+
* from any in-flight batch, and from durable storage — so a later batch ack or
|
|
2781
|
+
* a reconnect `returnBatch` can never resend or resurrect it. Returns the
|
|
2782
|
+
* removed operation so the caller can record it in a durable rejected store,
|
|
2783
|
+
* or null if it was not present (already acknowledged and removed).
|
|
2784
|
+
*/
|
|
2785
|
+
async reject(opId) {
|
|
2786
|
+
let removed = null;
|
|
2787
|
+
for (const [batchId, ops] of this.inFlight) {
|
|
2788
|
+
const idx = ops.findIndex((op) => op.id === opId);
|
|
2789
|
+
if (idx !== -1) {
|
|
2790
|
+
removed = ops[idx] ?? null;
|
|
2791
|
+
ops.splice(idx, 1);
|
|
2792
|
+
if (ops.length === 0) {
|
|
2793
|
+
this.inFlight.delete(batchId);
|
|
2794
|
+
}
|
|
2795
|
+
break;
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
if (!removed) {
|
|
2799
|
+
const idx = this.queue.findIndex((op) => op.id === opId);
|
|
2800
|
+
if (idx !== -1) {
|
|
2801
|
+
removed = this.queue[idx] ?? null;
|
|
2802
|
+
this.queue.splice(idx, 1);
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
this.seen.delete(opId);
|
|
2806
|
+
await this.storage.dequeue([opId]);
|
|
2807
|
+
return removed;
|
|
2808
|
+
}
|
|
2587
2809
|
/**
|
|
2588
2810
|
* Remove operations by id from queue and persistent storage.
|
|
2589
2811
|
* Used when ops were already sent during handshake delta exchange.
|
|
@@ -2602,6 +2824,7 @@ var OutboundQueue = class {
|
|
|
2602
2824
|
// src/engine/sync-engine.ts
|
|
2603
2825
|
var DEFAULT_BATCH_SIZE = 100;
|
|
2604
2826
|
var DEFAULT_SCHEMA_VERSION = 1;
|
|
2827
|
+
var DEFAULT_OUTBOUND_ACK_TIMEOUT_MS = 3e4;
|
|
2605
2828
|
var VALID_TRANSITIONS = {
|
|
2606
2829
|
disconnected: ["connecting"],
|
|
2607
2830
|
connecting: ["handshaking", "error", "disconnected"],
|
|
@@ -2615,6 +2838,17 @@ var nextQuerySubsetId = 0;
|
|
|
2615
2838
|
function generateMessageId() {
|
|
2616
2839
|
return `msg-${Date.now()}-${nextMessageId++}`;
|
|
2617
2840
|
}
|
|
2841
|
+
var MAX_DELIVERY_VIEW_WATERMARKS = 64;
|
|
2842
|
+
function stableStringify(value) {
|
|
2843
|
+
if (value === null || typeof value !== "object") {
|
|
2844
|
+
return JSON.stringify(value) ?? "null";
|
|
2845
|
+
}
|
|
2846
|
+
if (Array.isArray(value)) {
|
|
2847
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
2848
|
+
}
|
|
2849
|
+
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)}`);
|
|
2850
|
+
return `{${entries.join(",")}}`;
|
|
2851
|
+
}
|
|
2618
2852
|
var SyncEngine = class {
|
|
2619
2853
|
state = "disconnected";
|
|
2620
2854
|
transport;
|
|
@@ -2623,7 +2857,9 @@ var SyncEngine = class {
|
|
|
2623
2857
|
serializer;
|
|
2624
2858
|
emitter;
|
|
2625
2859
|
outboundQueue;
|
|
2860
|
+
rejectedStorage;
|
|
2626
2861
|
batchSize;
|
|
2862
|
+
outboundAckTimeoutMs;
|
|
2627
2863
|
encryptor;
|
|
2628
2864
|
awarenessManager;
|
|
2629
2865
|
richtextDocChannel;
|
|
@@ -2638,11 +2874,21 @@ var SyncEngine = class {
|
|
|
2638
2874
|
lastSuccessfulPull = null;
|
|
2639
2875
|
conflictCount = 0;
|
|
2640
2876
|
currentBatch = null;
|
|
2877
|
+
currentBatchRequiresPartialAck = false;
|
|
2641
2878
|
reconnecting = false;
|
|
2642
2879
|
schemaBlocked = false;
|
|
2643
2880
|
clockBlocked = false;
|
|
2644
2881
|
clockSkewMs = null;
|
|
2645
2882
|
blobStorageEnabled = false;
|
|
2883
|
+
suspensionReason = null;
|
|
2884
|
+
authRejected = false;
|
|
2885
|
+
serverFrontier = null;
|
|
2886
|
+
hasInFlightDeliveryBatch = false;
|
|
2887
|
+
blockedFailure = null;
|
|
2888
|
+
outboundAckTimer = null;
|
|
2889
|
+
startPromise = null;
|
|
2890
|
+
stopPromise = null;
|
|
2891
|
+
reconnectPromise = null;
|
|
2646
2892
|
// Track delta exchange state
|
|
2647
2893
|
deltaBatchesReceived = 0;
|
|
2648
2894
|
deltaReceiveComplete = false;
|
|
@@ -2659,10 +2905,32 @@ var SyncEngine = class {
|
|
|
2659
2905
|
activeScope;
|
|
2660
2906
|
/** Live query subsets registered from reactive subscriptions */
|
|
2661
2907
|
querySubsets = /* @__PURE__ */ new Map();
|
|
2908
|
+
staticQuerySubsets = [];
|
|
2662
2909
|
querySubsetReconnectTimer = null;
|
|
2663
2910
|
/** Resume cursor for paginated initial sync (persisted across reconnects) */
|
|
2664
2911
|
resumeDeltaCursor = null;
|
|
2665
2912
|
initialSyncTotalBatches = 0;
|
|
2913
|
+
/**
|
|
2914
|
+
* The delivery watermark for the CURRENT sync view: the highest server delivery
|
|
2915
|
+
* sequence up to which every operation in the current view (scope + active query
|
|
2916
|
+
* subscriptions) has been contiguously applied. Advanced only through the server's
|
|
2917
|
+
* gap-free delivery stream (never through live relay), so it is a durable lower bound
|
|
2918
|
+
* on what this client holds for this view. Reported at handshake to resume the stream.
|
|
2919
|
+
*
|
|
2920
|
+
* The watermark is keyed by view: switching views (a scope or subscription change)
|
|
2921
|
+
* saves the current view's watermark and loads the target view's, so returning to a
|
|
2922
|
+
* previously-synced view resumes exactly where it left off instead of re-syncing. This
|
|
2923
|
+
* is correct because a view's watermark is a lower bound specific to that view's
|
|
2924
|
+
* filter; a widened view is simply a different (initially unsynced) view.
|
|
2925
|
+
*/
|
|
2926
|
+
deliveryWatermark = 0;
|
|
2927
|
+
/**
|
|
2928
|
+
* In-memory cache of watermark per view signature, preloaded from persistence at start
|
|
2929
|
+
* so a view switch resolves synchronously (no race with the reconnect it triggers).
|
|
2930
|
+
*/
|
|
2931
|
+
deliverySignatureWatermarks = /* @__PURE__ */ new Map();
|
|
2932
|
+
deliveryGapRepeatCount = 0;
|
|
2933
|
+
lastDeliveryGapKey = null;
|
|
2666
2934
|
constructor(options) {
|
|
2667
2935
|
this.transport = options.transport;
|
|
2668
2936
|
this.store = options.store;
|
|
@@ -2670,11 +2938,13 @@ var SyncEngine = class {
|
|
|
2670
2938
|
this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
|
|
2671
2939
|
this.emitter = options.emitter ?? null;
|
|
2672
2940
|
this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2941
|
+
this.outboundAckTimeoutMs = options.config.outboundAckTimeoutMs ?? DEFAULT_OUTBOUND_ACK_TIMEOUT_MS;
|
|
2673
2942
|
this.encryptor = options.encryptor ?? null;
|
|
2674
2943
|
this.syncState = options.syncState ?? null;
|
|
2675
2944
|
this.activeScope = options.config.scopeMap;
|
|
2676
2945
|
const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
|
|
2677
2946
|
this.outboundQueue = new OutboundQueue(queueStorage);
|
|
2947
|
+
this.rejectedStorage = options.rejectedStorage ?? new MemoryRejectedOperationStorage();
|
|
2678
2948
|
this.metricsCollector = new SyncMetricsCollector(options.metricsConfig);
|
|
2679
2949
|
if (this.emitter) {
|
|
2680
2950
|
this.metricsCollector.attachEmitter(this.emitter);
|
|
@@ -2714,17 +2984,53 @@ var SyncEngine = class {
|
|
|
2714
2984
|
* Start the sync engine: connect → handshake → delta exchange → streaming.
|
|
2715
2985
|
*/
|
|
2716
2986
|
async start() {
|
|
2987
|
+
if (this.state === "streaming") return;
|
|
2988
|
+
if (this.startPromise) return this.startPromise;
|
|
2989
|
+
this.startPromise = this.startInternal().finally(() => {
|
|
2990
|
+
this.startPromise = null;
|
|
2991
|
+
});
|
|
2992
|
+
return this.startPromise;
|
|
2993
|
+
}
|
|
2994
|
+
async startInternal() {
|
|
2995
|
+
if (this.stopPromise) {
|
|
2996
|
+
await this.stopPromise;
|
|
2997
|
+
}
|
|
2998
|
+
if (this.state === "streaming") return;
|
|
2717
2999
|
if (this.state !== "disconnected") {
|
|
2718
|
-
|
|
2719
|
-
|
|
3000
|
+
await this.stop();
|
|
3001
|
+
}
|
|
3002
|
+
if (this.authRejected) {
|
|
3003
|
+
this.emitter?.emit({
|
|
3004
|
+
type: "sync:suspended",
|
|
3005
|
+
reason: this.suspensionReason ?? "auth-rejected"
|
|
2720
3006
|
});
|
|
3007
|
+
return;
|
|
2721
3008
|
}
|
|
3009
|
+
if (this.config.authState) {
|
|
3010
|
+
const authState = await this.config.authState();
|
|
3011
|
+
if (authState.state === "loading" || authState.state === "signed-out") {
|
|
3012
|
+
this.suspensionReason = authState.state === "loading" ? "auth-loading" : "auth-required";
|
|
3013
|
+
this.emitter?.emit({ type: "sync:suspended", reason: this.suspensionReason });
|
|
3014
|
+
return;
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
this.suspensionReason = null;
|
|
2722
3018
|
await this.outboundQueue.initialize();
|
|
2723
3019
|
if (this.syncState) {
|
|
2724
3020
|
this.lastAckedServerVector = await this.syncState.loadLastAckedServerVector();
|
|
2725
3021
|
if (this.syncState.loadDeltaCursor) {
|
|
2726
3022
|
this.resumeDeltaCursor = await this.syncState.loadDeltaCursor();
|
|
2727
3023
|
}
|
|
3024
|
+
if (this.syncState.loadAllDeliveryWatermarks) {
|
|
3025
|
+
const all = await this.syncState.loadAllDeliveryWatermarks();
|
|
3026
|
+
for (const [signature, watermark] of Object.entries(all)) {
|
|
3027
|
+
this.deliverySignatureWatermarks.set(signature, watermark);
|
|
3028
|
+
}
|
|
3029
|
+
} else if (this.syncState.loadDeliveryWatermark) {
|
|
3030
|
+
this.deliverySignatureWatermarks.set("", await this.syncState.loadDeliveryWatermark(""));
|
|
3031
|
+
}
|
|
3032
|
+
this.deliveryWatermark = this.deliverySignatureWatermarks.get(this.deliverySignature()) ?? 0;
|
|
3033
|
+
this.evictColdViewWatermarks();
|
|
2728
3034
|
}
|
|
2729
3035
|
await this.reconcileOutboundFromOpLog();
|
|
2730
3036
|
await this.refreshPendingCount();
|
|
@@ -2740,7 +3046,12 @@ var SyncEngine = class {
|
|
|
2740
3046
|
this.transitionTo("connecting");
|
|
2741
3047
|
try {
|
|
2742
3048
|
const authToken = this.config.auth ? (await this.config.auth()).token : void 0;
|
|
3049
|
+
if (this.state !== "connecting") return;
|
|
2743
3050
|
await this.transport.connect(this.config.url, { authToken });
|
|
3051
|
+
if (this.state !== "connecting") {
|
|
3052
|
+
await this.transport.disconnect();
|
|
3053
|
+
return;
|
|
3054
|
+
}
|
|
2744
3055
|
this.transitionTo("handshaking");
|
|
2745
3056
|
const localVector = this.store.getVersionVector();
|
|
2746
3057
|
const activeQuerySubsets = this.getActiveQuerySubsets();
|
|
@@ -2754,7 +3065,11 @@ var SyncEngine = class {
|
|
|
2754
3065
|
supportedWireFormats: ["json", "protobuf"],
|
|
2755
3066
|
...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
|
|
2756
3067
|
...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
|
|
2757
|
-
...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {}
|
|
3068
|
+
...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {},
|
|
3069
|
+
// Resume the server's gap-free delivery stream from our watermark. A server
|
|
3070
|
+
// that understands it drives server->client sync from delivery sequences; an
|
|
3071
|
+
// older server ignores it and falls back to the version-vector delta.
|
|
3072
|
+
lastDeliverySequence: this.deliveryWatermark
|
|
2758
3073
|
};
|
|
2759
3074
|
this.transport.send(handshake);
|
|
2760
3075
|
} catch (err) {
|
|
@@ -2766,11 +3081,20 @@ var SyncEngine = class {
|
|
|
2766
3081
|
* Stop the sync engine. Disconnects the transport.
|
|
2767
3082
|
*/
|
|
2768
3083
|
async stop() {
|
|
3084
|
+
if (this.stopPromise) return this.stopPromise;
|
|
3085
|
+
this.stopPromise = this.stopInternal().finally(() => {
|
|
3086
|
+
this.stopPromise = null;
|
|
3087
|
+
});
|
|
3088
|
+
return this.stopPromise;
|
|
3089
|
+
}
|
|
3090
|
+
async stopInternal() {
|
|
2769
3091
|
if (this.state === "disconnected") return;
|
|
3092
|
+
this.clearOutboundAckTimer();
|
|
2770
3093
|
this.awarenessManager.stopCleanupTimer();
|
|
2771
3094
|
if (this.currentBatch) {
|
|
2772
3095
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
2773
3096
|
this.currentBatch = null;
|
|
3097
|
+
this.currentBatchRequiresPartialAck = false;
|
|
2774
3098
|
}
|
|
2775
3099
|
try {
|
|
2776
3100
|
await this.transport.disconnect();
|
|
@@ -2791,7 +3115,7 @@ var SyncEngine = class {
|
|
|
2791
3115
|
* because they should remain local-only and not be sent to the server.
|
|
2792
3116
|
*/
|
|
2793
3117
|
async pushOperation(op) {
|
|
2794
|
-
if (!this.operationAllowedForSync(op)) {
|
|
3118
|
+
if (!await this.operationAllowedForSync(op)) {
|
|
2795
3119
|
return;
|
|
2796
3120
|
}
|
|
2797
3121
|
if (this.syncState) {
|
|
@@ -2820,13 +3144,30 @@ var SyncEngine = class {
|
|
|
2820
3144
|
getStatus() {
|
|
2821
3145
|
const pendingOperations = this.syncState ? this.cachedUnsyncedCount : this.outboundQueue.totalPending;
|
|
2822
3146
|
const base = {
|
|
3147
|
+
phase: this.resolvePhase(),
|
|
3148
|
+
...this.suspensionReason ? { reason: this.suspensionReason } : {},
|
|
3149
|
+
reconnecting: this.reconnecting,
|
|
2823
3150
|
pendingOperations,
|
|
2824
3151
|
lastSyncedAt: this.lastSyncedAt,
|
|
2825
3152
|
lastSuccessfulPush: this.lastSuccessfulPush,
|
|
2826
3153
|
lastSuccessfulPull: this.lastSuccessfulPull,
|
|
2827
3154
|
conflicts: this.conflictCount,
|
|
2828
|
-
clockSkewMs: this.clockSkewMs
|
|
3155
|
+
clockSkewMs: this.clockSkewMs,
|
|
3156
|
+
inFlightUploadOperations: this.currentBatch?.operations.length ?? 0,
|
|
3157
|
+
hasInFlightDeliveryBatch: this.hasInFlightDeliveryBatch,
|
|
3158
|
+
activeViewId: this.deliverySignature(),
|
|
3159
|
+
activeViewComplete: this.state === "streaming" && !this.hasInFlightDeliveryBatch && this.blockedFailure === null && (this.serverFrontier === null || this.deliveryWatermark >= this.serverFrontier),
|
|
3160
|
+
initialSync: {
|
|
3161
|
+
complete: this.deltaReceiveComplete && this.deltaSendComplete,
|
|
3162
|
+
receivedBatches: this.deltaBatchesReceived,
|
|
3163
|
+
totalBatches: this.initialSyncTotalBatches || null,
|
|
3164
|
+
progress: this.initialSyncTotalBatches > 0 ? Math.min(1, this.deltaBatchesReceived / this.initialSyncTotalBatches) : null
|
|
3165
|
+
},
|
|
3166
|
+
deliveryWatermark: this.deliveryWatermark,
|
|
3167
|
+
serverFrontier: this.serverFrontier,
|
|
3168
|
+
blockedFailure: this.blockedFailure
|
|
2829
3169
|
};
|
|
3170
|
+
if (this.suspensionReason) return { ...base, status: "auth-required" };
|
|
2830
3171
|
switch (this.state) {
|
|
2831
3172
|
case "disconnected":
|
|
2832
3173
|
if (this.clockBlocked) {
|
|
@@ -2839,7 +3180,7 @@ var SyncEngine = class {
|
|
|
2839
3180
|
case "connecting":
|
|
2840
3181
|
case "handshaking":
|
|
2841
3182
|
case "syncing":
|
|
2842
|
-
return { ...base, status: this.reconnecting ? "
|
|
3183
|
+
return { ...base, status: this.reconnecting ? "reconnecting" : "syncing" };
|
|
2843
3184
|
case "streaming":
|
|
2844
3185
|
return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
|
|
2845
3186
|
case "error":
|
|
@@ -2849,6 +3190,26 @@ var SyncEngine = class {
|
|
|
2849
3190
|
return { ...base, status: this.schemaBlocked ? "schema-mismatch" : "error" };
|
|
2850
3191
|
}
|
|
2851
3192
|
}
|
|
3193
|
+
resolvePhase() {
|
|
3194
|
+
if (this.suspensionReason) return "suspended";
|
|
3195
|
+
if (this.blockedFailure || this.schemaBlocked || this.clockBlocked) return "blocked";
|
|
3196
|
+
if (this.hasInFlightDeliveryBatch) return "applying";
|
|
3197
|
+
if (this.currentBatch) return "uploading";
|
|
3198
|
+
switch (this.state) {
|
|
3199
|
+
case "disconnected":
|
|
3200
|
+
return "offline";
|
|
3201
|
+
case "connecting":
|
|
3202
|
+
return "connecting";
|
|
3203
|
+
case "handshaking":
|
|
3204
|
+
return "handshaking";
|
|
3205
|
+
case "syncing":
|
|
3206
|
+
return "receiving";
|
|
3207
|
+
case "streaming":
|
|
3208
|
+
return "streaming";
|
|
3209
|
+
case "error":
|
|
3210
|
+
return "blocked";
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
2852
3213
|
/**
|
|
2853
3214
|
* True when the server rejected the client's schema version at handshake.
|
|
2854
3215
|
* Sync stays blocked until the app schema is upgraded or sync config changes.
|
|
@@ -2901,8 +3262,25 @@ var SyncEngine = class {
|
|
|
2901
3262
|
await this.refreshPendingCount();
|
|
2902
3263
|
return this.getStatus().pendingOperations;
|
|
2903
3264
|
}
|
|
2904
|
-
emitApplyFailure(op, result, overrides) {
|
|
3265
|
+
emitApplyFailure(op, result, overrides, blocking = false) {
|
|
2905
3266
|
const reason = (0, import_core7.defaultApplyFailureReason)(result, overrides);
|
|
3267
|
+
if (blocking || reason.retriable) {
|
|
3268
|
+
const prior = this.blockedFailure;
|
|
3269
|
+
this.blockedFailure = {
|
|
3270
|
+
operationId: op.id,
|
|
3271
|
+
collection: op.collection,
|
|
3272
|
+
recordId: op.recordId,
|
|
3273
|
+
code: reason.code,
|
|
3274
|
+
message: reason.message,
|
|
3275
|
+
retriable: reason.retriable,
|
|
3276
|
+
firstSeenAt: prior?.operationId === op.id ? prior.firstSeenAt : Date.now(),
|
|
3277
|
+
retryCount: prior?.operationId === op.id ? prior.retryCount + 1 : 0
|
|
3278
|
+
};
|
|
3279
|
+
this.emitter?.emit({
|
|
3280
|
+
type: prior?.operationId === op.id ? "sync:apply-retrying" : "sync:apply-blocked",
|
|
3281
|
+
failure: this.blockedFailure
|
|
3282
|
+
});
|
|
3283
|
+
}
|
|
2906
3284
|
this.emitter?.emit({
|
|
2907
3285
|
type: "sync:apply-failed",
|
|
2908
3286
|
operationId: op.id,
|
|
@@ -2919,11 +3297,36 @@ var SyncEngine = class {
|
|
|
2919
3297
|
*/
|
|
2920
3298
|
async retryNow() {
|
|
2921
3299
|
if (this.schemaBlocked) return;
|
|
3300
|
+
this.authRejected = false;
|
|
3301
|
+
this.suspensionReason = null;
|
|
2922
3302
|
if (this.state === "disconnected" || this.state === "error") {
|
|
2923
3303
|
this.reconnecting = false;
|
|
2924
3304
|
await this.start();
|
|
2925
3305
|
}
|
|
2926
3306
|
}
|
|
3307
|
+
/** Wake a permanent auth suspension after the binding reports a new auth state. */
|
|
3308
|
+
notifyAuthChanged() {
|
|
3309
|
+
this.authRejected = false;
|
|
3310
|
+
this.suspensionReason = null;
|
|
3311
|
+
}
|
|
3312
|
+
/**
|
|
3313
|
+
* Atomically refresh the transport session. Overlapping callers share one
|
|
3314
|
+
* reconnect, in-flight outbound batches are returned to the queue by stop(), and the
|
|
3315
|
+
* next start handshakes with the latest auth token, sync scope, and query subsets.
|
|
3316
|
+
*/
|
|
3317
|
+
async reconnect() {
|
|
3318
|
+
if (this.schemaBlocked) return;
|
|
3319
|
+
if (this.reconnectPromise) return this.reconnectPromise;
|
|
3320
|
+
this.reconnecting = false;
|
|
3321
|
+
this.reconnectPromise = this.reconnectInternal().finally(() => {
|
|
3322
|
+
this.reconnectPromise = null;
|
|
3323
|
+
});
|
|
3324
|
+
return this.reconnectPromise;
|
|
3325
|
+
}
|
|
3326
|
+
async reconnectInternal() {
|
|
3327
|
+
await this.stop();
|
|
3328
|
+
await this.start();
|
|
3329
|
+
}
|
|
2927
3330
|
/**
|
|
2928
3331
|
* Export a diagnostics snapshot for debugging and support tickets.
|
|
2929
3332
|
* Contains connection state, timing info, and queue metrics.
|
|
@@ -2942,6 +3345,8 @@ var SyncEngine = class {
|
|
|
2942
3345
|
pendingOperations: this.outboundQueue.totalPending,
|
|
2943
3346
|
hasInFlightBatch: this.currentBatch !== null,
|
|
2944
3347
|
reconnecting: this.reconnecting,
|
|
3348
|
+
deliveryWatermark: this.deliveryWatermark,
|
|
3349
|
+
deliveryGapRepeatCount: this.deliveryGapRepeatCount,
|
|
2945
3350
|
timestamp: Date.now()
|
|
2946
3351
|
};
|
|
2947
3352
|
}
|
|
@@ -2970,8 +3375,84 @@ var SyncEngine = class {
|
|
|
2970
3375
|
* @param scopeMap - New per-collection scope filters, or undefined to remove scope
|
|
2971
3376
|
*/
|
|
2972
3377
|
updateScope(scopeMap) {
|
|
3378
|
+
const previousSignature = this.deliverySignature();
|
|
2973
3379
|
this.activeScope = scopeMap;
|
|
2974
3380
|
this.config.scopeMap = scopeMap;
|
|
3381
|
+
this.switchDeliveryView(previousSignature);
|
|
3382
|
+
}
|
|
3383
|
+
/**
|
|
3384
|
+
* A stable identifier for the current sync view: the auth/tenant scope plus the set of
|
|
3385
|
+
* active query subscriptions, canonicalized so the same view always produces the same
|
|
3386
|
+
* string. The delivery watermark is keyed by this, so each distinct view resumes from
|
|
3387
|
+
* its own last position.
|
|
3388
|
+
*/
|
|
3389
|
+
deliverySignature() {
|
|
3390
|
+
const subsets = this.getActiveQuerySubsets();
|
|
3391
|
+
if (!this.activeScope && subsets.length === 0) {
|
|
3392
|
+
return "";
|
|
3393
|
+
}
|
|
3394
|
+
const normalizedSubsets = subsets.map((s) => `${s.collection}:${stableStringify(s.where)}`).sort();
|
|
3395
|
+
return stableStringify({ scope: this.activeScope ?? null, subsets: normalizedSubsets });
|
|
3396
|
+
}
|
|
3397
|
+
/**
|
|
3398
|
+
* Switch the active delivery watermark to the current view after a scope or subscription
|
|
3399
|
+
* change. The prior view's watermark is saved so returning to it resumes; the new view's
|
|
3400
|
+
* watermark is restored from the in-memory cache (0 for a never-synced view, which then
|
|
3401
|
+
* does a one-time resync under that view). No data is lost: a view's watermark is a lower
|
|
3402
|
+
* bound for that specific view's filter.
|
|
3403
|
+
*/
|
|
3404
|
+
switchDeliveryView(previousSignature) {
|
|
3405
|
+
const nextSignature = this.deliverySignature();
|
|
3406
|
+
if (nextSignature === previousSignature) {
|
|
3407
|
+
return;
|
|
3408
|
+
}
|
|
3409
|
+
this.setViewWatermark(previousSignature, this.deliveryWatermark);
|
|
3410
|
+
void this.persistDeliveryWatermark(this.deliveryWatermark, previousSignature);
|
|
3411
|
+
this.deliveryWatermark = this.deliverySignatureWatermarks.get(nextSignature) ?? 0;
|
|
3412
|
+
}
|
|
3413
|
+
/**
|
|
3414
|
+
* Record a view's watermark in the in-memory cache as the most-recently-used entry (the
|
|
3415
|
+
* Map preserves insertion order, so re-inserting moves it to the tail), then evict cold
|
|
3416
|
+
* views past the retention cap. This is the single place the cache is written, so LRU
|
|
3417
|
+
* order and the size bound are always maintained together.
|
|
3418
|
+
*/
|
|
3419
|
+
setViewWatermark(signature, watermark) {
|
|
3420
|
+
this.deliverySignatureWatermarks.delete(signature);
|
|
3421
|
+
this.deliverySignatureWatermarks.set(signature, watermark);
|
|
3422
|
+
this.evictColdViewWatermarks();
|
|
3423
|
+
}
|
|
3424
|
+
/**
|
|
3425
|
+
* Trim the per-view watermark cache to `MAX_DELIVERY_VIEW_WATERMARKS`, evicting the
|
|
3426
|
+
* least-recently-used views. The default view ('') and the live view are never evicted
|
|
3427
|
+
* (evicting the live view would force an immediate re-scan of what we are actively
|
|
3428
|
+
* syncing). Each eviction also removes the persisted row, bounding storage. Safe by
|
|
3429
|
+
* construction: an evicted view simply back-fills from 0 (deduplicated) when next seen.
|
|
3430
|
+
*/
|
|
3431
|
+
evictColdViewWatermarks() {
|
|
3432
|
+
if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
|
|
3433
|
+
return;
|
|
3434
|
+
}
|
|
3435
|
+
const liveSignature = this.deliverySignature();
|
|
3436
|
+
for (const signature of this.deliverySignatureWatermarks.keys()) {
|
|
3437
|
+
if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
|
|
3438
|
+
break;
|
|
3439
|
+
}
|
|
3440
|
+
if (signature === "" || signature === liveSignature) {
|
|
3441
|
+
continue;
|
|
3442
|
+
}
|
|
3443
|
+
this.deliverySignatureWatermarks.delete(signature);
|
|
3444
|
+
void this.deleteDeliveryWatermark(signature);
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
/**
|
|
3448
|
+
* Remove a view's persisted watermark row (best-effort; a persistence layer that omits
|
|
3449
|
+
* the delete simply keeps the row, which is harmless).
|
|
3450
|
+
*/
|
|
3451
|
+
async deleteDeliveryWatermark(signature) {
|
|
3452
|
+
if (!this.syncState?.deleteDeliveryWatermark) {
|
|
3453
|
+
return;
|
|
3454
|
+
}
|
|
3455
|
+
await this.syncState.deleteDeliveryWatermark(signature);
|
|
2975
3456
|
}
|
|
2976
3457
|
/**
|
|
2977
3458
|
* Get the currently active scope map. Returns undefined if no scope is configured.
|
|
@@ -2984,19 +3465,49 @@ var SyncEngine = class {
|
|
|
2984
3465
|
* Takes effect on the next connection; reconnects when already connected.
|
|
2985
3466
|
*/
|
|
2986
3467
|
registerQuerySubset(subset) {
|
|
3468
|
+
if ((this.config.querySubsets?.mode ?? "reactive") !== "reactive") return () => {
|
|
3469
|
+
};
|
|
2987
3470
|
const id = `query-${nextQuerySubsetId++}`;
|
|
3471
|
+
const previousSignature = this.deliverySignature();
|
|
3472
|
+
const previousEffective = this.getActiveQuerySubsets();
|
|
2988
3473
|
this.querySubsets.set(id, subset);
|
|
2989
|
-
this.
|
|
3474
|
+
this.switchDeliveryViewWithInheritance(previousSignature, previousEffective);
|
|
3475
|
+
if (this.deliverySignature() !== previousSignature) this.scheduleQuerySubsetReconnect();
|
|
2990
3476
|
return () => {
|
|
3477
|
+
const sigBeforeRemove = this.deliverySignature();
|
|
3478
|
+
const effectiveBeforeRemove = this.getActiveQuerySubsets();
|
|
2991
3479
|
this.querySubsets.delete(id);
|
|
2992
|
-
this.
|
|
3480
|
+
this.switchDeliveryViewWithInheritance(sigBeforeRemove, effectiveBeforeRemove);
|
|
3481
|
+
if (this.deliverySignature() !== sigBeforeRemove) this.scheduleQuerySubsetReconnect();
|
|
2993
3482
|
};
|
|
2994
3483
|
}
|
|
3484
|
+
switchDeliveryViewWithInheritance(previousSignature, previousSubsets) {
|
|
3485
|
+
const nextSubsets = this.getActiveQuerySubsets();
|
|
3486
|
+
const previousWatermark = this.deliveryWatermark;
|
|
3487
|
+
this.switchDeliveryView(previousSignature);
|
|
3488
|
+
if (this.deliveryWatermark === 0 && querySubsetContains(previousSubsets, nextSubsets)) {
|
|
3489
|
+
this.deliveryWatermark = previousWatermark;
|
|
3490
|
+
this.setViewWatermark(this.deliverySignature(), previousWatermark);
|
|
3491
|
+
void this.persistDeliveryWatermark(previousWatermark);
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
/** Atomically replace the manifest used by static query-subset mode. */
|
|
3495
|
+
setQuerySubsets(subsets) {
|
|
3496
|
+
if ((this.config.querySubsets?.mode ?? "reactive") !== "static")
|
|
3497
|
+
throw new Error('setQuerySubsets() requires sync.querySubsets.mode = "static"');
|
|
3498
|
+
const previousSignature = this.deliverySignature();
|
|
3499
|
+
const previousEffective = this.getActiveQuerySubsets();
|
|
3500
|
+
this.staticQuerySubsets = dedupeQuerySubsets(subsets);
|
|
3501
|
+
this.switchDeliveryViewWithInheritance(previousSignature, previousEffective);
|
|
3502
|
+
if (this.deliverySignature() !== previousSignature) this.scheduleQuerySubsetReconnect();
|
|
3503
|
+
}
|
|
2995
3504
|
/**
|
|
2996
3505
|
* Returns deduplicated active query subsets from registered subscriptions.
|
|
2997
3506
|
*/
|
|
2998
3507
|
getActiveQuerySubsets() {
|
|
2999
|
-
|
|
3508
|
+
const mode = this.config.querySubsets?.mode ?? "reactive";
|
|
3509
|
+
if (mode === "disabled") return [];
|
|
3510
|
+
return mode === "static" ? this.staticQuerySubsets : dedupeQuerySubsets([...this.querySubsets.values()]);
|
|
3000
3511
|
}
|
|
3001
3512
|
/**
|
|
3002
3513
|
* Get the awareness manager for collaborative presence.
|
|
@@ -3051,11 +3562,14 @@ var SyncEngine = class {
|
|
|
3051
3562
|
await this.handleOperationBatch(message);
|
|
3052
3563
|
break;
|
|
3053
3564
|
case "acknowledgment":
|
|
3054
|
-
this.handleAcknowledgment(message);
|
|
3565
|
+
await this.handleAcknowledgment(message);
|
|
3055
3566
|
break;
|
|
3056
3567
|
case "error":
|
|
3057
3568
|
this.handleError(message);
|
|
3058
3569
|
break;
|
|
3570
|
+
case "operation-rejected":
|
|
3571
|
+
await this.handleOperationRejected(message);
|
|
3572
|
+
break;
|
|
3059
3573
|
case "awareness-update":
|
|
3060
3574
|
this.handleAwarenessUpdate(message);
|
|
3061
3575
|
break;
|
|
@@ -3070,6 +3584,7 @@ var SyncEngine = class {
|
|
|
3070
3584
|
}
|
|
3071
3585
|
}
|
|
3072
3586
|
handleMessageFailure(error) {
|
|
3587
|
+
this.hasInFlightDeliveryBatch = false;
|
|
3073
3588
|
const reason = error instanceof Error ? error.message : "Message handling failed";
|
|
3074
3589
|
this.handleTransportClose(reason);
|
|
3075
3590
|
}
|
|
@@ -3138,6 +3653,12 @@ var SyncEngine = class {
|
|
|
3138
3653
|
}
|
|
3139
3654
|
this.remoteVector = wireToVersionVector(msg.versionVector);
|
|
3140
3655
|
void this.persistLastAckedServerVector(this.remoteVector);
|
|
3656
|
+
if (typeof msg.serverMaxDeliverySequence === "number" && this.deliveryWatermark > msg.serverMaxDeliverySequence) {
|
|
3657
|
+
this.deliveryWatermark = 0;
|
|
3658
|
+
this.setViewWatermark(this.deliverySignature(), 0);
|
|
3659
|
+
await this.persistDeliveryWatermark(0);
|
|
3660
|
+
}
|
|
3661
|
+
this.serverFrontier = msg.serverMaxDeliverySequence ?? null;
|
|
3141
3662
|
if (msg.selectedWireFormat) {
|
|
3142
3663
|
this.setSerializerWireFormat(msg.selectedWireFormat);
|
|
3143
3664
|
}
|
|
@@ -3203,7 +3724,7 @@ var SyncEngine = class {
|
|
|
3203
3724
|
async sendDelta() {
|
|
3204
3725
|
const localVector = this.store.getVersionVector();
|
|
3205
3726
|
const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
|
|
3206
|
-
const missingOps =
|
|
3727
|
+
const missingOps = await this.filterAllowedForSync(allMissingOps);
|
|
3207
3728
|
this.deltaSentOpIds = missingOps.map((op) => op.id);
|
|
3208
3729
|
if (missingOps.length === 0) {
|
|
3209
3730
|
const messageId = generateMessageId();
|
|
@@ -3278,11 +3799,35 @@ var SyncEngine = class {
|
|
|
3278
3799
|
return missing;
|
|
3279
3800
|
}
|
|
3280
3801
|
async handleOperationBatch(msg) {
|
|
3802
|
+
const isDeliveryBatch = msg.maxDeliverySequence !== void 0;
|
|
3803
|
+
if (isDeliveryBatch) {
|
|
3804
|
+
const base = msg.baseDeliverySequence ?? 0;
|
|
3805
|
+
if (base > this.deliveryWatermark) {
|
|
3806
|
+
const gapKey = `${this.deliveryWatermark}:${base}`;
|
|
3807
|
+
this.deliveryGapRepeatCount = this.lastDeliveryGapKey === gapKey ? this.deliveryGapRepeatCount + 1 : 1;
|
|
3808
|
+
this.lastDeliveryGapKey = gapKey;
|
|
3809
|
+
this.emitter?.emit({
|
|
3810
|
+
type: "sync:delivery-gap",
|
|
3811
|
+
expectedBase: this.deliveryWatermark,
|
|
3812
|
+
receivedBase: base,
|
|
3813
|
+
currentWatermark: this.deliveryWatermark,
|
|
3814
|
+
messageId: msg.messageId,
|
|
3815
|
+
repeatCount: this.deliveryGapRepeatCount
|
|
3816
|
+
});
|
|
3817
|
+
return;
|
|
3818
|
+
}
|
|
3819
|
+
if (base < this.deliveryWatermark) {
|
|
3820
|
+
this.sendDeliveryAck(msg.messageId, this.deliveryWatermark);
|
|
3821
|
+
return;
|
|
3822
|
+
}
|
|
3823
|
+
this.hasInFlightDeliveryBatch = true;
|
|
3824
|
+
}
|
|
3281
3825
|
const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
3282
3826
|
const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
|
|
3283
|
-
const inScopeOps =
|
|
3827
|
+
const inScopeOps = await this.filterAllowedForSync(operations);
|
|
3284
3828
|
const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
3285
3829
|
const transforms = this.config.operationTransforms ?? [];
|
|
3830
|
+
let fullyApplied = true;
|
|
3286
3831
|
for (const op of inScopeOps) {
|
|
3287
3832
|
const transformed = transforms.length > 0 ? (0, import_core7.applyOperationTransforms)(op, targetSchemaVersion, transforms) : op;
|
|
3288
3833
|
if (transformed === null) {
|
|
@@ -3294,21 +3839,24 @@ var SyncEngine = class {
|
|
|
3294
3839
|
this.emitApplyFailure(transformed, result);
|
|
3295
3840
|
}
|
|
3296
3841
|
} catch (error) {
|
|
3842
|
+
fullyApplied = false;
|
|
3297
3843
|
if (error instanceof import_core7.ClockDriftError) {
|
|
3298
|
-
this.emitApplyFailure(
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3844
|
+
this.emitApplyFailure(
|
|
3845
|
+
transformed,
|
|
3846
|
+
"rejected",
|
|
3847
|
+
{
|
|
3848
|
+
code: import_core7.APPLY_FAILURE_CODES.CLOCK_DRIFT,
|
|
3849
|
+
message: error.message,
|
|
3850
|
+
retriable: false
|
|
3851
|
+
},
|
|
3852
|
+
true
|
|
3853
|
+
);
|
|
3303
3854
|
continue;
|
|
3304
3855
|
}
|
|
3305
3856
|
const message = error instanceof Error ? error.message : "Apply failed";
|
|
3306
3857
|
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
|
-
});
|
|
3858
|
+
const retriable = code !== import_core7.APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY;
|
|
3859
|
+
this.emitApplyFailure(transformed, "rejected", { code, message, retriable }, true);
|
|
3312
3860
|
}
|
|
3313
3861
|
}
|
|
3314
3862
|
if (inScopeOps.length > 0) {
|
|
@@ -3319,18 +3867,35 @@ var SyncEngine = class {
|
|
|
3319
3867
|
batchSize: inScopeOps.length
|
|
3320
3868
|
});
|
|
3321
3869
|
}
|
|
3322
|
-
const
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3870
|
+
const deliveryFullyApplied = isDeliveryBatch && fullyApplied;
|
|
3871
|
+
if (!isDeliveryBatch || deliveryFullyApplied) {
|
|
3872
|
+
const lastOp = operations[operations.length - 1];
|
|
3873
|
+
const ack = {
|
|
3874
|
+
type: "acknowledgment",
|
|
3875
|
+
messageId: generateMessageId(),
|
|
3876
|
+
acknowledgedMessageId: msg.messageId,
|
|
3877
|
+
lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0,
|
|
3878
|
+
...deliveryFullyApplied && msg.maxDeliverySequence !== void 0 ? { deliverySequence: msg.maxDeliverySequence } : {}
|
|
3879
|
+
};
|
|
3880
|
+
this.transport.send(ack);
|
|
3881
|
+
this.emitter?.emit({
|
|
3882
|
+
type: "sync:acknowledged",
|
|
3883
|
+
sequenceNumber: lastOp ? lastOp.sequenceNumber : 0
|
|
3884
|
+
});
|
|
3885
|
+
}
|
|
3886
|
+
if (isDeliveryBatch && fullyApplied && msg.maxDeliverySequence !== void 0) {
|
|
3887
|
+
this.deliveryGapRepeatCount = 0;
|
|
3888
|
+
this.lastDeliveryGapKey = null;
|
|
3889
|
+
this.deliveryWatermark = msg.maxDeliverySequence;
|
|
3890
|
+
this.setViewWatermark(this.deliverySignature(), this.deliveryWatermark);
|
|
3891
|
+
await this.persistDeliveryWatermark(this.deliveryWatermark);
|
|
3892
|
+
if (this.blockedFailure && inScopeOps.some((op) => op.id === this.blockedFailure?.operationId)) {
|
|
3893
|
+
const recovered = this.blockedFailure;
|
|
3894
|
+
this.blockedFailure = null;
|
|
3895
|
+
this.emitter?.emit({ type: "sync:apply-recovered", failure: recovered });
|
|
3896
|
+
}
|
|
3897
|
+
}
|
|
3898
|
+
this.hasInFlightDeliveryBatch = false;
|
|
3334
3899
|
if (this.state === "syncing") {
|
|
3335
3900
|
this.deltaBatchesReceived++;
|
|
3336
3901
|
const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
|
|
@@ -3338,15 +3903,17 @@ var SyncEngine = class {
|
|
|
3338
3903
|
this.initialSyncTotalBatches = msg.totalBatches;
|
|
3339
3904
|
}
|
|
3340
3905
|
this.metricsCollector.updateInitialSyncProgress(this.deltaBatchesReceived, totalBatches);
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3906
|
+
if (!isDeliveryBatch) {
|
|
3907
|
+
const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
|
|
3908
|
+
inScopeOps.length > 0 ? inScopeOps : operations,
|
|
3909
|
+
msg.batchIndex
|
|
3910
|
+
);
|
|
3911
|
+
if (cursorFromBatch) {
|
|
3912
|
+
this.resumeDeltaCursor = cursorFromBatch;
|
|
3913
|
+
await this.persistDeltaCursor(cursorFromBatch);
|
|
3914
|
+
}
|
|
3348
3915
|
}
|
|
3349
|
-
if (msg.isFinal) {
|
|
3916
|
+
if (msg.isFinal && (!isDeliveryBatch || fullyApplied)) {
|
|
3350
3917
|
this.deltaReceiveComplete = true;
|
|
3351
3918
|
this.resumeDeltaCursor = null;
|
|
3352
3919
|
await this.persistDeltaCursor(null);
|
|
@@ -3355,29 +3922,57 @@ var SyncEngine = class {
|
|
|
3355
3922
|
}
|
|
3356
3923
|
}
|
|
3357
3924
|
}
|
|
3358
|
-
|
|
3925
|
+
/** Acknowledge a delivery batch (used for duplicates) without re-applying it. */
|
|
3926
|
+
sendDeliveryAck(acknowledgedMessageId, deliverySequence) {
|
|
3927
|
+
const ack = {
|
|
3928
|
+
type: "acknowledgment",
|
|
3929
|
+
messageId: generateMessageId(),
|
|
3930
|
+
acknowledgedMessageId,
|
|
3931
|
+
lastSequenceNumber: 0,
|
|
3932
|
+
deliverySequence
|
|
3933
|
+
};
|
|
3934
|
+
this.transport.send(ack);
|
|
3935
|
+
}
|
|
3936
|
+
async handleAcknowledgment(msg) {
|
|
3937
|
+
this.clearOutboundAckTimer();
|
|
3359
3938
|
if (this.state === "syncing" && this.config.strictHandshake) {
|
|
3360
3939
|
this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
|
|
3361
3940
|
this.markDeltaSendCompleteIfReady();
|
|
3362
3941
|
}
|
|
3363
3942
|
if (this.currentBatch) {
|
|
3364
|
-
|
|
3943
|
+
if (this.currentBatchRequiresPartialAck) {
|
|
3944
|
+
await this.outboundQueue.acknowledgeThrough(
|
|
3945
|
+
this.currentBatch.batchId,
|
|
3946
|
+
msg.lastSequenceNumber
|
|
3947
|
+
);
|
|
3948
|
+
} else {
|
|
3949
|
+
await this.outboundQueue.acknowledge(this.currentBatch.batchId);
|
|
3950
|
+
}
|
|
3365
3951
|
this.currentBatch = null;
|
|
3952
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3366
3953
|
const now = Date.now();
|
|
3367
3954
|
this.lastSyncedAt = now;
|
|
3368
3955
|
this.lastSuccessfulPush = now;
|
|
3369
3956
|
}
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
);
|
|
3957
|
+
await this.advanceLastAckedForLocalNode(msg.lastSequenceNumber);
|
|
3958
|
+
await this.refreshPendingCount();
|
|
3373
3959
|
if (this.state === "streaming" && this.outboundQueue.hasOperations) {
|
|
3374
3960
|
this.flushQueue();
|
|
3375
3961
|
}
|
|
3376
3962
|
}
|
|
3377
3963
|
handleError(msg) {
|
|
3964
|
+
this.clearOutboundAckTimer();
|
|
3965
|
+
if (this.currentBatch) {
|
|
3966
|
+
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
3967
|
+
this.currentBatch = null;
|
|
3968
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3969
|
+
}
|
|
3378
3970
|
this.transitionTo("error");
|
|
3379
|
-
if (msg.code === "AUTH_FAILED") {
|
|
3971
|
+
if (msg.code === "AUTH_FAILED" || msg.code === "DEVICE_REVOKED") {
|
|
3972
|
+
this.authRejected = true;
|
|
3973
|
+
this.suspensionReason = msg.code === "DEVICE_REVOKED" ? "device-revoked" : "auth-rejected";
|
|
3380
3974
|
this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
|
|
3975
|
+
this.emitter?.emit({ type: "sync:suspended", reason: this.suspensionReason });
|
|
3381
3976
|
}
|
|
3382
3977
|
if (msg.code === "INVALID_TIMESTAMP") {
|
|
3383
3978
|
this.clockBlocked = true;
|
|
@@ -3393,6 +3988,64 @@ var SyncEngine = class {
|
|
|
3393
3988
|
this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
|
|
3394
3989
|
this.transitionTo("disconnected");
|
|
3395
3990
|
}
|
|
3991
|
+
/**
|
|
3992
|
+
* Handle a per-operation rejection: divert the op out of the pending outbound
|
|
3993
|
+
* queue (so it is never retried or resurrected on reconnect), record it in the
|
|
3994
|
+
* durable rejected store (so it is kept and explainable), and emit an event so
|
|
3995
|
+
* the app can reconcile. Unlike {@link handleError}, this is a normal per-op
|
|
3996
|
+
* signal, so the connection stays up.
|
|
3997
|
+
*/
|
|
3998
|
+
async handleOperationRejected(msg) {
|
|
3999
|
+
if (msg.retriable) {
|
|
4000
|
+
this.currentBatchRequiresPartialAck = true;
|
|
4001
|
+
await this.refreshPendingCount();
|
|
4002
|
+
this.emitter?.emit({
|
|
4003
|
+
type: "sync:operation-rejected",
|
|
4004
|
+
operationId: msg.operationId,
|
|
4005
|
+
collection: msg.collection,
|
|
4006
|
+
recordId: msg.recordId,
|
|
4007
|
+
code: msg.code,
|
|
4008
|
+
message: msg.message,
|
|
4009
|
+
retriable: true
|
|
4010
|
+
});
|
|
4011
|
+
return;
|
|
4012
|
+
}
|
|
4013
|
+
await this.outboundQueue.reject(msg.operationId);
|
|
4014
|
+
await this.rejectedStorage.record({
|
|
4015
|
+
operationId: msg.operationId,
|
|
4016
|
+
collection: msg.collection,
|
|
4017
|
+
recordId: msg.recordId,
|
|
4018
|
+
code: msg.code,
|
|
4019
|
+
message: msg.message,
|
|
4020
|
+
retriable: msg.retriable,
|
|
4021
|
+
rejectedAt: Date.now()
|
|
4022
|
+
});
|
|
4023
|
+
await this.refreshPendingCount();
|
|
4024
|
+
this.emitter?.emit({
|
|
4025
|
+
type: "sync:operation-rejected",
|
|
4026
|
+
operationId: msg.operationId,
|
|
4027
|
+
collection: msg.collection,
|
|
4028
|
+
recordId: msg.recordId,
|
|
4029
|
+
code: msg.code,
|
|
4030
|
+
message: msg.message,
|
|
4031
|
+
retriable: msg.retriable
|
|
4032
|
+
});
|
|
4033
|
+
}
|
|
4034
|
+
/**
|
|
4035
|
+
* Every operation the server rejected that has not yet been reconciled. The app
|
|
4036
|
+
* uses this (alongside the `sync:operation-rejected` event) to surface failed
|
|
4037
|
+
* submissions and decide whether to roll back or resubmit.
|
|
4038
|
+
*/
|
|
4039
|
+
getRejectedOperations() {
|
|
4040
|
+
return this.rejectedStorage.list();
|
|
4041
|
+
}
|
|
4042
|
+
/**
|
|
4043
|
+
* Forget rejected operations by id once the app has reconciled them (rolled the
|
|
4044
|
+
* optimistic write back or resubmitted a corrected op).
|
|
4045
|
+
*/
|
|
4046
|
+
clearRejectedOperations(operationIds) {
|
|
4047
|
+
return this.rejectedStorage.remove(operationIds);
|
|
4048
|
+
}
|
|
3396
4049
|
async checkDeltaComplete() {
|
|
3397
4050
|
if (!this.deltaSendComplete || !this.deltaReceiveComplete) {
|
|
3398
4051
|
return;
|
|
@@ -3491,7 +4144,7 @@ var SyncEngine = class {
|
|
|
3491
4144
|
return;
|
|
3492
4145
|
}
|
|
3493
4146
|
const ops = await this.store.getOperationRange(localNodeId, 1, localSeq);
|
|
3494
|
-
const inScope =
|
|
4147
|
+
const inScope = await this.filterAllowedForSync(ops);
|
|
3495
4148
|
for (const op of inScope) {
|
|
3496
4149
|
await this.outboundQueue.enqueue(op);
|
|
3497
4150
|
}
|
|
@@ -3502,6 +4155,7 @@ var SyncEngine = class {
|
|
|
3502
4155
|
const batch = this.outboundQueue.takeBatch(this.batchSize);
|
|
3503
4156
|
if (!batch) return;
|
|
3504
4157
|
this.currentBatch = batch;
|
|
4158
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3505
4159
|
if (this.encryptor) {
|
|
3506
4160
|
this.encryptor.encryptBatch(batch.operations).then(
|
|
3507
4161
|
(encrypted) => {
|
|
@@ -3514,6 +4168,7 @@ var SyncEngine = class {
|
|
|
3514
4168
|
batchIndex: 0
|
|
3515
4169
|
};
|
|
3516
4170
|
this.transport.send(batchMsg);
|
|
4171
|
+
this.startOutboundAckTimer(batch.batchId);
|
|
3517
4172
|
this.emitter?.emit({
|
|
3518
4173
|
type: "sync:sent",
|
|
3519
4174
|
operations: batch.operations,
|
|
@@ -3523,6 +4178,7 @@ var SyncEngine = class {
|
|
|
3523
4178
|
(err) => {
|
|
3524
4179
|
this.outboundQueue.returnBatch(batch.batchId);
|
|
3525
4180
|
this.currentBatch = null;
|
|
4181
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3526
4182
|
this.emitter?.emit({
|
|
3527
4183
|
type: "sync:disconnected",
|
|
3528
4184
|
reason: err instanceof Error ? err.message : "Encryption failed"
|
|
@@ -3539,6 +4195,7 @@ var SyncEngine = class {
|
|
|
3539
4195
|
batchIndex: 0
|
|
3540
4196
|
};
|
|
3541
4197
|
this.transport.send(batchMsg);
|
|
4198
|
+
this.startOutboundAckTimer(batch.batchId);
|
|
3542
4199
|
this.emitter?.emit({
|
|
3543
4200
|
type: "sync:sent",
|
|
3544
4201
|
operations: batch.operations,
|
|
@@ -3546,6 +4203,34 @@ var SyncEngine = class {
|
|
|
3546
4203
|
});
|
|
3547
4204
|
}
|
|
3548
4205
|
}
|
|
4206
|
+
startOutboundAckTimer(batchId) {
|
|
4207
|
+
this.clearOutboundAckTimer();
|
|
4208
|
+
if (this.outboundAckTimeoutMs <= 0) return;
|
|
4209
|
+
this.outboundAckTimer = setTimeout(() => {
|
|
4210
|
+
void this.handleOutboundAckTimeout(batchId);
|
|
4211
|
+
}, this.outboundAckTimeoutMs);
|
|
4212
|
+
}
|
|
4213
|
+
clearOutboundAckTimer() {
|
|
4214
|
+
if (!this.outboundAckTimer) return;
|
|
4215
|
+
clearTimeout(this.outboundAckTimer);
|
|
4216
|
+
this.outboundAckTimer = null;
|
|
4217
|
+
}
|
|
4218
|
+
async handleOutboundAckTimeout(batchId) {
|
|
4219
|
+
if (!this.currentBatch || this.currentBatch.batchId !== batchId) return;
|
|
4220
|
+
this.outboundQueue.returnBatch(batchId);
|
|
4221
|
+
this.currentBatch = null;
|
|
4222
|
+
this.currentBatchRequiresPartialAck = false;
|
|
4223
|
+
this.clearOutboundAckTimer();
|
|
4224
|
+
await this.refreshPendingCount();
|
|
4225
|
+
if (this.state === "disconnected") return;
|
|
4226
|
+
const reason = `Timed out waiting for acknowledgment of outbound batch ${batchId}`;
|
|
4227
|
+
try {
|
|
4228
|
+
await this.transport.disconnect();
|
|
4229
|
+
} finally {
|
|
4230
|
+
this.ensureDisconnected();
|
|
4231
|
+
this.emitter?.emit({ type: "sync:disconnected", reason });
|
|
4232
|
+
}
|
|
4233
|
+
}
|
|
3549
4234
|
handleAwarenessUpdate(msg) {
|
|
3550
4235
|
const awarenessMessage = {
|
|
3551
4236
|
type: "awareness",
|
|
@@ -3555,9 +4240,11 @@ var SyncEngine = class {
|
|
|
3555
4240
|
this.awarenessManager.handleRemoteMessage(awarenessMessage);
|
|
3556
4241
|
}
|
|
3557
4242
|
handleTransportClose(reason) {
|
|
4243
|
+
this.clearOutboundAckTimer();
|
|
3558
4244
|
if (this.currentBatch) {
|
|
3559
4245
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
3560
4246
|
this.currentBatch = null;
|
|
4247
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3561
4248
|
}
|
|
3562
4249
|
if (this.schemaBlocked) {
|
|
3563
4250
|
return;
|
|
@@ -3589,11 +4276,40 @@ var SyncEngine = class {
|
|
|
3589
4276
|
this.serializer.setWireFormat(format);
|
|
3590
4277
|
}
|
|
3591
4278
|
}
|
|
3592
|
-
operationAllowedForSync(op) {
|
|
3593
|
-
if (
|
|
4279
|
+
async operationAllowedForSync(op) {
|
|
4280
|
+
if (this.matchesScopeAndSubsets(op)) {
|
|
4281
|
+
return true;
|
|
4282
|
+
}
|
|
4283
|
+
const fullRecord = await this.readRecordForBackfill(op);
|
|
4284
|
+
if (!fullRecord) {
|
|
3594
4285
|
return false;
|
|
3595
4286
|
}
|
|
3596
|
-
return
|
|
4287
|
+
return this.matchesScopeAndSubsets(op, fullRecord);
|
|
4288
|
+
}
|
|
4289
|
+
matchesScopeAndSubsets(op, fullRecord) {
|
|
4290
|
+
if (!operationMatchesScope(op, this.activeScope, fullRecord)) {
|
|
4291
|
+
return false;
|
|
4292
|
+
}
|
|
4293
|
+
return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets(), fullRecord);
|
|
4294
|
+
}
|
|
4295
|
+
async readRecordForBackfill(op) {
|
|
4296
|
+
if (!this.store.readRecordFields) {
|
|
4297
|
+
return null;
|
|
4298
|
+
}
|
|
4299
|
+
try {
|
|
4300
|
+
return await this.store.readRecordFields(op.collection, op.recordId);
|
|
4301
|
+
} catch {
|
|
4302
|
+
return null;
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
async filterAllowedForSync(ops) {
|
|
4306
|
+
const allowed = [];
|
|
4307
|
+
for (const op of ops) {
|
|
4308
|
+
if (await this.operationAllowedForSync(op)) {
|
|
4309
|
+
allowed.push(op);
|
|
4310
|
+
}
|
|
4311
|
+
}
|
|
4312
|
+
return allowed;
|
|
3597
4313
|
}
|
|
3598
4314
|
async persistDeltaCursor(cursor) {
|
|
3599
4315
|
if (!this.syncState?.saveDeltaCursor) {
|
|
@@ -3601,6 +4317,12 @@ var SyncEngine = class {
|
|
|
3601
4317
|
}
|
|
3602
4318
|
await this.syncState.saveDeltaCursor(cursor);
|
|
3603
4319
|
}
|
|
4320
|
+
async persistDeliveryWatermark(watermark, signature = this.deliverySignature()) {
|
|
4321
|
+
if (!this.syncState?.saveDeliveryWatermark) {
|
|
4322
|
+
return;
|
|
4323
|
+
}
|
|
4324
|
+
await this.syncState.saveDeliveryWatermark(signature, watermark);
|
|
4325
|
+
}
|
|
3604
4326
|
scheduleQuerySubsetReconnect() {
|
|
3605
4327
|
if (this.querySubsetReconnectTimer) {
|
|
3606
4328
|
clearTimeout(this.querySubsetReconnectTimer);
|
|
@@ -3613,8 +4335,7 @@ var SyncEngine = class {
|
|
|
3613
4335
|
}, 500);
|
|
3614
4336
|
}
|
|
3615
4337
|
async reconnectForQuerySubsets() {
|
|
3616
|
-
await this.
|
|
3617
|
-
await this.start();
|
|
4338
|
+
await this.reconnect();
|
|
3618
4339
|
}
|
|
3619
4340
|
};
|
|
3620
4341
|
function awarenessStatesToWire(states) {
|
|
@@ -4295,12 +5016,22 @@ function fromBase642(str) {
|
|
|
4295
5016
|
// src/reactivity/sync-status-controller.ts
|
|
4296
5017
|
var OFFLINE_SYNC_STATUS = Object.freeze({
|
|
4297
5018
|
status: "offline",
|
|
5019
|
+
phase: "offline",
|
|
5020
|
+
reconnecting: false,
|
|
4298
5021
|
pendingOperations: 0,
|
|
4299
5022
|
lastSyncedAt: null,
|
|
4300
5023
|
lastSuccessfulPush: null,
|
|
4301
5024
|
lastSuccessfulPull: null,
|
|
4302
5025
|
conflicts: 0,
|
|
4303
|
-
clockSkewMs: null
|
|
5026
|
+
clockSkewMs: null,
|
|
5027
|
+
inFlightUploadOperations: 0,
|
|
5028
|
+
hasInFlightDeliveryBatch: false,
|
|
5029
|
+
activeViewId: "",
|
|
5030
|
+
activeViewComplete: false,
|
|
5031
|
+
initialSync: { complete: false, receivedBatches: 0, totalBatches: null, progress: null },
|
|
5032
|
+
deliveryWatermark: 0,
|
|
5033
|
+
serverFrontier: null,
|
|
5034
|
+
blockedFailure: null
|
|
4304
5035
|
});
|
|
4305
5036
|
var SYNC_STATUS_EVENT_TYPES = [
|
|
4306
5037
|
"sync:connected",
|
|
@@ -4312,6 +5043,11 @@ var SYNC_STATUS_EVENT_TYPES = [
|
|
|
4312
5043
|
"sync:received",
|
|
4313
5044
|
"sync:acknowledged",
|
|
4314
5045
|
"sync:apply-failed",
|
|
5046
|
+
"sync:suspended",
|
|
5047
|
+
"sync:apply-blocked",
|
|
5048
|
+
"sync:apply-retrying",
|
|
5049
|
+
"sync:apply-recovered",
|
|
5050
|
+
"sync:delivery-gap",
|
|
4315
5051
|
"sync:diagnostics",
|
|
4316
5052
|
"sync:initial-sync-progress"
|
|
4317
5053
|
];
|
|
@@ -4458,6 +5194,7 @@ function subscribeRemoteAwarenessStates(awareness, listener) {
|
|
|
4458
5194
|
InvalidScopeError,
|
|
4459
5195
|
JsonMessageSerializer,
|
|
4460
5196
|
KeyDerivationError,
|
|
5197
|
+
MemoryRejectedOperationStorage,
|
|
4461
5198
|
NegotiatedMessageSerializer,
|
|
4462
5199
|
OFFLINE_SYNC_STATUS,
|
|
4463
5200
|
OutboundQueue,
|
|
@@ -4496,11 +5233,13 @@ function subscribeRemoteAwarenessStates(awareness, listener) {
|
|
|
4496
5233
|
isHandshakeMessage,
|
|
4497
5234
|
isHandshakeResponseMessage,
|
|
4498
5235
|
isOperationBatchMessage,
|
|
5236
|
+
isOperationRejectedMessage,
|
|
4499
5237
|
isSchemaMismatchReject,
|
|
4500
5238
|
isSyncMessage,
|
|
4501
5239
|
isYjsDocUpdateMessage,
|
|
4502
5240
|
operationMatchesQuerySubsets,
|
|
4503
5241
|
operationMatchesScope,
|
|
5242
|
+
querySubsetContains,
|
|
4504
5243
|
richtextDocKey,
|
|
4505
5244
|
sliceOperationsAfterCursor,
|
|
4506
5245
|
subscribeRemoteAwarenessStates,
|