@korajs/sync 0.3.2 → 0.5.0
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 +2350 -199
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +684 -7
- package/dist/index.d.ts +684 -7
- package/dist/index.js +2334 -205
- package/dist/index.js.map +1 -1
- package/dist/internal.d.cts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/transport-BrdBj7rH.d.cts +465 -0
- package/dist/transport-BrdBj7rH.d.ts +465 -0
- package/package.json +5 -4
- package/dist/transport-B5EFsr5F.d.cts +0 -215
- package/dist/transport-B5EFsr5F.d.ts +0 -215
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
} from "./chunk-TU345YUC.js";
|
|
4
4
|
|
|
5
5
|
// src/types.ts
|
|
6
|
+
import { KoraError } from "@korajs/core";
|
|
6
7
|
var SYNC_STATES = [
|
|
7
8
|
"disconnected",
|
|
8
9
|
"connecting",
|
|
@@ -11,7 +12,194 @@ var SYNC_STATES = [
|
|
|
11
12
|
"streaming",
|
|
12
13
|
"error"
|
|
13
14
|
];
|
|
14
|
-
var SYNC_STATUSES = [
|
|
15
|
+
var SYNC_STATUSES = [
|
|
16
|
+
"connected",
|
|
17
|
+
"syncing",
|
|
18
|
+
"synced",
|
|
19
|
+
"offline",
|
|
20
|
+
"error",
|
|
21
|
+
"schema-mismatch"
|
|
22
|
+
];
|
|
23
|
+
var ScopeViolationError = class extends KoraError {
|
|
24
|
+
constructor(operationId, collection, scope, message) {
|
|
25
|
+
super(
|
|
26
|
+
message ?? `Operation "${operationId}" in collection "${collection}" violates sync scope`,
|
|
27
|
+
"SCOPE_VIOLATION",
|
|
28
|
+
{ operationId, collection, scope }
|
|
29
|
+
);
|
|
30
|
+
this.operationId = operationId;
|
|
31
|
+
this.collection = collection;
|
|
32
|
+
this.scope = scope;
|
|
33
|
+
this.name = "ScopeViolationError";
|
|
34
|
+
}
|
|
35
|
+
operationId;
|
|
36
|
+
collection;
|
|
37
|
+
scope;
|
|
38
|
+
};
|
|
39
|
+
var InvalidScopeError = class extends KoraError {
|
|
40
|
+
constructor(message, context) {
|
|
41
|
+
super(message, "INVALID_SCOPE", context);
|
|
42
|
+
this.name = "InvalidScopeError";
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// src/scopes/scope-filter.ts
|
|
47
|
+
function operationMatchesScope(op, scopeMap, fullRecord) {
|
|
48
|
+
if (!scopeMap) return true;
|
|
49
|
+
const collectionScope = scopeMap[op.collection];
|
|
50
|
+
if (!collectionScope) return false;
|
|
51
|
+
if (Object.keys(collectionScope).length === 0) return true;
|
|
52
|
+
const snapshot = buildSnapshot(op, fullRecord ?? void 0);
|
|
53
|
+
if (!snapshot) return false;
|
|
54
|
+
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
55
|
+
if (snapshot[field] !== expected) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
function filterOperationsByScope(operations, scopeMap) {
|
|
62
|
+
if (!scopeMap) return operations;
|
|
63
|
+
return operations.filter((op) => operationMatchesScope(op, scopeMap));
|
|
64
|
+
}
|
|
65
|
+
function buildSnapshot(op, fullRecord) {
|
|
66
|
+
const previous = asRecord(op.previousData);
|
|
67
|
+
const next = asRecord(op.data);
|
|
68
|
+
if (!previous && !next && !fullRecord) return null;
|
|
69
|
+
return {
|
|
70
|
+
...fullRecord ?? {},
|
|
71
|
+
...previous ?? {},
|
|
72
|
+
...next ?? {}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function asRecord(value) {
|
|
76
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/scopes/query-subset.ts
|
|
83
|
+
function asRecord2(value) {
|
|
84
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
function buildSnapshot2(op, fullRecord) {
|
|
90
|
+
const previous = asRecord2(op.previousData);
|
|
91
|
+
const next = asRecord2(op.data);
|
|
92
|
+
if (!previous && !next && !fullRecord) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
...fullRecord ?? {},
|
|
97
|
+
...previous ?? {},
|
|
98
|
+
...next ?? {}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function recordMatchesWhere(snapshot, where) {
|
|
102
|
+
for (const [field, expected] of Object.entries(where)) {
|
|
103
|
+
if (snapshot[field] !== expected) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
function operationMatchesQuerySubsets(op, subsets, fullRecord) {
|
|
110
|
+
if (!subsets || subsets.length === 0) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
const collectionSubsets = subsets.filter((subset) => subset.collection === op.collection);
|
|
114
|
+
if (collectionSubsets.length === 0) {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
const snapshot = buildSnapshot2(op, fullRecord);
|
|
118
|
+
if (!snapshot) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
return collectionSubsets.some((subset) => recordMatchesWhere(snapshot, subset.where));
|
|
122
|
+
}
|
|
123
|
+
function dedupeQuerySubsets(subsets) {
|
|
124
|
+
const seen = /* @__PURE__ */ new Set();
|
|
125
|
+
const result = [];
|
|
126
|
+
for (const subset of subsets) {
|
|
127
|
+
const key = `${subset.collection}:${JSON.stringify(subset.where)}`;
|
|
128
|
+
if (seen.has(key)) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
seen.add(key);
|
|
132
|
+
result.push(subset);
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/delta/delta-cursor.ts
|
|
138
|
+
function bytesToBase64Url(bytes) {
|
|
139
|
+
let binary = "";
|
|
140
|
+
for (const byte of bytes) {
|
|
141
|
+
binary += String.fromCharCode(byte);
|
|
142
|
+
}
|
|
143
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
144
|
+
}
|
|
145
|
+
function base64UrlToBytes(value) {
|
|
146
|
+
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
147
|
+
const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
|
|
148
|
+
const binary = atob(padded);
|
|
149
|
+
const bytes = new Uint8Array(binary.length);
|
|
150
|
+
for (let i = 0; i < binary.length; i++) {
|
|
151
|
+
bytes[i] = binary.charCodeAt(i);
|
|
152
|
+
}
|
|
153
|
+
return bytes;
|
|
154
|
+
}
|
|
155
|
+
function encodeDeltaCursor(cursor) {
|
|
156
|
+
const json = JSON.stringify(cursor);
|
|
157
|
+
return bytesToBase64Url(new TextEncoder().encode(json));
|
|
158
|
+
}
|
|
159
|
+
function decodeDeltaCursor(value) {
|
|
160
|
+
if (!value) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const json = new TextDecoder().decode(base64UrlToBytes(value));
|
|
165
|
+
const parsed = JSON.parse(json);
|
|
166
|
+
if (typeof parsed !== "object" || parsed === null || typeof parsed.lastOperationId !== "string" || typeof parsed.batchIndex !== "number") {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return parsed;
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function sliceOperationsAfterCursor(operations, cursor) {
|
|
175
|
+
if (!cursor) {
|
|
176
|
+
return operations;
|
|
177
|
+
}
|
|
178
|
+
const index = operations.findIndex((op) => op.id === cursor.lastOperationId);
|
|
179
|
+
if (index === -1) {
|
|
180
|
+
return operations;
|
|
181
|
+
}
|
|
182
|
+
return operations.slice(index + 1);
|
|
183
|
+
}
|
|
184
|
+
function createDeltaCursorFromBatch(operations, batchIndex) {
|
|
185
|
+
const last = operations[operations.length - 1];
|
|
186
|
+
if (!last) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
lastOperationId: last.id,
|
|
191
|
+
batchIndex
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/protocol/schema-version.ts
|
|
196
|
+
var SCHEMA_MISMATCH_PREFIX = "SCHEMA_MISMATCH";
|
|
197
|
+
function isSchemaMismatchReject(rejectReason) {
|
|
198
|
+
return rejectReason?.startsWith(SCHEMA_MISMATCH_PREFIX) === true;
|
|
199
|
+
}
|
|
200
|
+
function isClientSchemaVersionSupported(clientVersion, supported) {
|
|
201
|
+
return clientVersion >= supported.min && clientVersion <= supported.max;
|
|
202
|
+
}
|
|
15
203
|
|
|
16
204
|
// src/protocol/messages.ts
|
|
17
205
|
function isSyncMessage(value) {
|
|
@@ -29,6 +217,10 @@ function isSyncMessage(value) {
|
|
|
29
217
|
return isAcknowledgmentMessage(value);
|
|
30
218
|
case "error":
|
|
31
219
|
return isErrorMessage(value);
|
|
220
|
+
case "awareness-update":
|
|
221
|
+
return isAwarenessUpdateMessage(value);
|
|
222
|
+
case "yjs-doc-update":
|
|
223
|
+
return isYjsDocUpdateMessage(value);
|
|
32
224
|
default:
|
|
33
225
|
return false;
|
|
34
226
|
}
|
|
@@ -58,6 +250,16 @@ function isErrorMessage(value) {
|
|
|
58
250
|
const msg = value;
|
|
59
251
|
return msg.type === "error" && typeof msg.messageId === "string" && typeof msg.code === "string" && typeof msg.message === "string" && typeof msg.retriable === "boolean";
|
|
60
252
|
}
|
|
253
|
+
function isAwarenessUpdateMessage(value) {
|
|
254
|
+
if (typeof value !== "object" || value === null) return false;
|
|
255
|
+
const msg = value;
|
|
256
|
+
return msg.type === "awareness-update" && typeof msg.messageId === "string" && typeof msg.clientId === "number" && typeof msg.states === "object" && msg.states !== null && !Array.isArray(msg.states);
|
|
257
|
+
}
|
|
258
|
+
function isYjsDocUpdateMessage(value) {
|
|
259
|
+
if (typeof value !== "object" || value === null) return false;
|
|
260
|
+
const msg = value;
|
|
261
|
+
return msg.type === "yjs-doc-update" && typeof msg.messageId === "string" && typeof msg.collection === "string" && typeof msg.recordId === "string" && typeof msg.field === "string" && typeof msg.update === "string";
|
|
262
|
+
}
|
|
61
263
|
|
|
62
264
|
// src/protocol/serializer.ts
|
|
63
265
|
import { SyncError } from "@korajs/core";
|
|
@@ -73,6 +275,84 @@ function versionVectorToWire(vector) {
|
|
|
73
275
|
function wireToVersionVector(wire) {
|
|
74
276
|
return new Map(Object.entries(wire));
|
|
75
277
|
}
|
|
278
|
+
var WIRE_BYTES_KEY = "__kora_bytes__";
|
|
279
|
+
function toBase64(bytes) {
|
|
280
|
+
let binary = "";
|
|
281
|
+
for (const byte of bytes) {
|
|
282
|
+
binary += String.fromCharCode(byte);
|
|
283
|
+
}
|
|
284
|
+
return btoa(binary);
|
|
285
|
+
}
|
|
286
|
+
function fromBase64(value) {
|
|
287
|
+
const binary = atob(value);
|
|
288
|
+
const bytes = new Uint8Array(binary.length);
|
|
289
|
+
for (let index = 0; index < binary.length; index++) {
|
|
290
|
+
bytes[index] = binary.charCodeAt(index);
|
|
291
|
+
}
|
|
292
|
+
return bytes;
|
|
293
|
+
}
|
|
294
|
+
function serializeWireRecord(record) {
|
|
295
|
+
if (!record) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
const out = {};
|
|
299
|
+
for (const [key, value] of Object.entries(record)) {
|
|
300
|
+
out[key] = serializeWireValue(value);
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
function serializeWireValue(value) {
|
|
305
|
+
if (value instanceof Uint8Array) {
|
|
306
|
+
return { [WIRE_BYTES_KEY]: toBase64(value) };
|
|
307
|
+
}
|
|
308
|
+
if (value instanceof ArrayBuffer) {
|
|
309
|
+
return { [WIRE_BYTES_KEY]: toBase64(new Uint8Array(value)) };
|
|
310
|
+
}
|
|
311
|
+
return value;
|
|
312
|
+
}
|
|
313
|
+
function deserializeWireRecord(record) {
|
|
314
|
+
if (!record) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
const out = {};
|
|
318
|
+
for (const [key, value] of Object.entries(record)) {
|
|
319
|
+
out[key] = deserializeWireValue(value);
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
function deserializeWireValue(value) {
|
|
324
|
+
if (typeof value === "object" && value !== null && WIRE_BYTES_KEY in value) {
|
|
325
|
+
const encoded = value[WIRE_BYTES_KEY];
|
|
326
|
+
if (typeof encoded === "string") {
|
|
327
|
+
return fromBase64(encoded);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (isLegacyIndexedByteRecord(value)) {
|
|
331
|
+
return legacyIndexedByteRecordToUint8Array(value);
|
|
332
|
+
}
|
|
333
|
+
return value;
|
|
334
|
+
}
|
|
335
|
+
function isLegacyIndexedByteRecord(value) {
|
|
336
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
const entries = Object.entries(value);
|
|
340
|
+
if (entries.length === 0) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
return entries.every(([key, entryValue]) => {
|
|
344
|
+
const index = Number(key);
|
|
345
|
+
return Number.isInteger(index) && index >= 0 && typeof entryValue === "number";
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
function legacyIndexedByteRecordToUint8Array(value) {
|
|
349
|
+
const maxIndex = Math.max(...Object.keys(value).map((key) => Number(key)));
|
|
350
|
+
const bytes = new Uint8Array(maxIndex + 1);
|
|
351
|
+
for (const [key, entryValue] of Object.entries(value)) {
|
|
352
|
+
bytes[Number(key)] = entryValue;
|
|
353
|
+
}
|
|
354
|
+
return bytes;
|
|
355
|
+
}
|
|
76
356
|
var JsonMessageSerializer = class {
|
|
77
357
|
encode(message) {
|
|
78
358
|
return JSON.stringify(message);
|
|
@@ -101,8 +381,8 @@ var JsonMessageSerializer = class {
|
|
|
101
381
|
type: op.type,
|
|
102
382
|
collection: op.collection,
|
|
103
383
|
recordId: op.recordId,
|
|
104
|
-
data: op.data,
|
|
105
|
-
previousData: op.previousData,
|
|
384
|
+
data: serializeWireRecord(op.data),
|
|
385
|
+
previousData: serializeWireRecord(op.previousData),
|
|
106
386
|
timestamp: {
|
|
107
387
|
wallTime: op.timestamp.wallTime,
|
|
108
388
|
logical: op.timestamp.logical,
|
|
@@ -110,7 +390,10 @@ var JsonMessageSerializer = class {
|
|
|
110
390
|
},
|
|
111
391
|
sequenceNumber: op.sequenceNumber,
|
|
112
392
|
causalDeps: [...op.causalDeps],
|
|
113
|
-
schemaVersion: op.schemaVersion
|
|
393
|
+
schemaVersion: op.schemaVersion,
|
|
394
|
+
...op.atomicOps !== void 0 ? { atomicOps: op.atomicOps } : {},
|
|
395
|
+
...op.transactionId !== void 0 ? { transactionId: op.transactionId } : {},
|
|
396
|
+
...op.mutationName !== void 0 ? { mutationName: op.mutationName } : {}
|
|
114
397
|
};
|
|
115
398
|
}
|
|
116
399
|
decodeOperation(serialized) {
|
|
@@ -120,8 +403,8 @@ var JsonMessageSerializer = class {
|
|
|
120
403
|
type: serialized.type,
|
|
121
404
|
collection: serialized.collection,
|
|
122
405
|
recordId: serialized.recordId,
|
|
123
|
-
data: serialized.data,
|
|
124
|
-
previousData: serialized.previousData,
|
|
406
|
+
data: deserializeWireRecord(serialized.data),
|
|
407
|
+
previousData: deserializeWireRecord(serialized.previousData),
|
|
125
408
|
timestamp: {
|
|
126
409
|
wallTime: serialized.timestamp.wallTime,
|
|
127
410
|
logical: serialized.timestamp.logical,
|
|
@@ -129,7 +412,10 @@ var JsonMessageSerializer = class {
|
|
|
129
412
|
},
|
|
130
413
|
sequenceNumber: serialized.sequenceNumber,
|
|
131
414
|
causalDeps: [...serialized.causalDeps],
|
|
132
|
-
schemaVersion: serialized.schemaVersion
|
|
415
|
+
schemaVersion: serialized.schemaVersion,
|
|
416
|
+
...serialized.atomicOps !== void 0 ? { atomicOps: serialized.atomicOps } : {},
|
|
417
|
+
...serialized.transactionId !== void 0 ? { transactionId: serialized.transactionId } : {},
|
|
418
|
+
...serialized.mutationName !== void 0 ? { mutationName: serialized.mutationName } : {}
|
|
133
419
|
};
|
|
134
420
|
}
|
|
135
421
|
};
|
|
@@ -193,7 +479,10 @@ function toProtoEnvelope(message) {
|
|
|
193
479
|
type: message.type,
|
|
194
480
|
messageId: message.messageId,
|
|
195
481
|
nodeId: message.nodeId,
|
|
196
|
-
versionVector: Object.entries(message.versionVector).map(([key, value]) => ({
|
|
482
|
+
versionVector: Object.entries(message.versionVector).map(([key, value]) => ({
|
|
483
|
+
key,
|
|
484
|
+
value
|
|
485
|
+
})),
|
|
197
486
|
schemaVersion: message.schemaVersion,
|
|
198
487
|
authToken: message.authToken,
|
|
199
488
|
supportedWireFormats: message.supportedWireFormats
|
|
@@ -203,7 +492,10 @@ function toProtoEnvelope(message) {
|
|
|
203
492
|
type: message.type,
|
|
204
493
|
messageId: message.messageId,
|
|
205
494
|
nodeId: message.nodeId,
|
|
206
|
-
versionVector: Object.entries(message.versionVector).map(([key, value]) => ({
|
|
495
|
+
versionVector: Object.entries(message.versionVector).map(([key, value]) => ({
|
|
496
|
+
key,
|
|
497
|
+
value
|
|
498
|
+
})),
|
|
207
499
|
schemaVersion: message.schemaVersion,
|
|
208
500
|
accepted: message.accepted,
|
|
209
501
|
rejectReason: message.rejectReason,
|
|
@@ -232,6 +524,12 @@ function toProtoEnvelope(message) {
|
|
|
232
524
|
errorMessage: message.message,
|
|
233
525
|
retriable: message.retriable
|
|
234
526
|
};
|
|
527
|
+
case "awareness-update":
|
|
528
|
+
case "yjs-doc-update":
|
|
529
|
+
return {
|
|
530
|
+
type: message.type,
|
|
531
|
+
messageId: message.messageId
|
|
532
|
+
};
|
|
235
533
|
}
|
|
236
534
|
}
|
|
237
535
|
function fromProtoEnvelope(envelope) {
|
|
@@ -241,7 +539,9 @@ function fromProtoEnvelope(envelope) {
|
|
|
241
539
|
type: "handshake",
|
|
242
540
|
messageId: envelope.messageId,
|
|
243
541
|
nodeId: envelope.nodeId ?? "",
|
|
244
|
-
versionVector: Object.fromEntries(
|
|
542
|
+
versionVector: Object.fromEntries(
|
|
543
|
+
(envelope.versionVector ?? []).map((entry) => [entry.key, entry.value])
|
|
544
|
+
),
|
|
245
545
|
schemaVersion: envelope.schemaVersion ?? 0,
|
|
246
546
|
authToken: envelope.authToken,
|
|
247
547
|
supportedWireFormats: envelope.supportedWireFormats?.filter(
|
|
@@ -253,7 +553,9 @@ function fromProtoEnvelope(envelope) {
|
|
|
253
553
|
type: "handshake-response",
|
|
254
554
|
messageId: envelope.messageId,
|
|
255
555
|
nodeId: envelope.nodeId ?? "",
|
|
256
|
-
versionVector: Object.fromEntries(
|
|
556
|
+
versionVector: Object.fromEntries(
|
|
557
|
+
(envelope.versionVector ?? []).map((entry) => [entry.key, entry.value])
|
|
558
|
+
),
|
|
257
559
|
schemaVersion: envelope.schemaVersion ?? 0,
|
|
258
560
|
accepted: envelope.accepted ?? false,
|
|
259
561
|
rejectReason: envelope.rejectReason,
|
|
@@ -289,13 +591,33 @@ function fromProtoEnvelope(envelope) {
|
|
|
289
591
|
}
|
|
290
592
|
}
|
|
291
593
|
function serializeProtoOperation(operation) {
|
|
594
|
+
const hasMetadata = operation.transactionId !== void 0 || operation.mutationName !== void 0;
|
|
595
|
+
let dataJson = "";
|
|
596
|
+
if (operation.data !== null) {
|
|
597
|
+
const dataPayload = { ...operation.data };
|
|
598
|
+
if (operation.atomicOps !== void 0 && Object.keys(operation.atomicOps).length > 0) {
|
|
599
|
+
dataPayload.__kora_atomic_ops__ = operation.atomicOps;
|
|
600
|
+
}
|
|
601
|
+
if (operation.transactionId !== void 0) {
|
|
602
|
+
dataPayload.__kora_tx_id__ = operation.transactionId;
|
|
603
|
+
}
|
|
604
|
+
if (operation.mutationName !== void 0) {
|
|
605
|
+
dataPayload.__kora_mutation__ = operation.mutationName;
|
|
606
|
+
}
|
|
607
|
+
dataJson = JSON.stringify(dataPayload);
|
|
608
|
+
} else if (hasMetadata) {
|
|
609
|
+
const meta = {};
|
|
610
|
+
if (operation.transactionId !== void 0) meta.__kora_tx_id__ = operation.transactionId;
|
|
611
|
+
if (operation.mutationName !== void 0) meta.__kora_mutation__ = operation.mutationName;
|
|
612
|
+
dataJson = JSON.stringify(meta);
|
|
613
|
+
}
|
|
292
614
|
return {
|
|
293
615
|
id: operation.id,
|
|
294
616
|
nodeId: operation.nodeId,
|
|
295
617
|
type: operation.type,
|
|
296
618
|
collection: operation.collection,
|
|
297
619
|
recordId: operation.recordId,
|
|
298
|
-
dataJson
|
|
620
|
+
dataJson,
|
|
299
621
|
previousDataJson: operation.previousData === null ? "" : JSON.stringify(operation.previousData),
|
|
300
622
|
timestamp: {
|
|
301
623
|
wallTime: operation.timestamp.wallTime,
|
|
@@ -310,13 +632,31 @@ function serializeProtoOperation(operation) {
|
|
|
310
632
|
};
|
|
311
633
|
}
|
|
312
634
|
function deserializeProtoOperation(operation) {
|
|
635
|
+
let data = null;
|
|
636
|
+
let atomicOps;
|
|
637
|
+
let transactionId;
|
|
638
|
+
let mutationName;
|
|
639
|
+
if (operation.hasData || operation.dataJson.length > 0) {
|
|
640
|
+
const parsed = JSON.parse(operation.dataJson);
|
|
641
|
+
if ("__kora_atomic_ops__" in parsed) {
|
|
642
|
+
atomicOps = parsed.__kora_atomic_ops__;
|
|
643
|
+
}
|
|
644
|
+
if ("__kora_tx_id__" in parsed) {
|
|
645
|
+
transactionId = parsed.__kora_tx_id__;
|
|
646
|
+
}
|
|
647
|
+
if ("__kora_mutation__" in parsed) {
|
|
648
|
+
mutationName = parsed.__kora_mutation__;
|
|
649
|
+
}
|
|
650
|
+
const { __kora_atomic_ops__: _a, __kora_tx_id__: _t, __kora_mutation__: _m, ...rest } = parsed;
|
|
651
|
+
data = operation.hasData && Object.keys(rest).length > 0 ? rest : null;
|
|
652
|
+
}
|
|
313
653
|
return {
|
|
314
654
|
id: operation.id,
|
|
315
655
|
nodeId: operation.nodeId,
|
|
316
656
|
type: operation.type,
|
|
317
657
|
collection: operation.collection,
|
|
318
658
|
recordId: operation.recordId,
|
|
319
|
-
data
|
|
659
|
+
data,
|
|
320
660
|
previousData: operation.hasPreviousData ? JSON.parse(operation.previousDataJson) : null,
|
|
321
661
|
timestamp: {
|
|
322
662
|
wallTime: operation.timestamp.wallTime,
|
|
@@ -325,7 +665,10 @@ function deserializeProtoOperation(operation) {
|
|
|
325
665
|
},
|
|
326
666
|
sequenceNumber: operation.sequenceNumber,
|
|
327
667
|
causalDeps: [...operation.causalDeps],
|
|
328
|
-
schemaVersion: operation.schemaVersion
|
|
668
|
+
schemaVersion: operation.schemaVersion,
|
|
669
|
+
...atomicOps !== void 0 ? { atomicOps } : {},
|
|
670
|
+
...transactionId !== void 0 ? { transactionId } : {},
|
|
671
|
+
...mutationName !== void 0 ? { mutationName } : {}
|
|
329
672
|
};
|
|
330
673
|
}
|
|
331
674
|
function decodeTextPayload(data) {
|
|
@@ -356,12 +699,14 @@ function encodeEnvelope(envelope) {
|
|
|
356
699
|
writer.ldelim();
|
|
357
700
|
}
|
|
358
701
|
if (envelope.schemaVersion !== void 0) writer.uint32(40).int32(envelope.schemaVersion);
|
|
359
|
-
if (envelope.authToken && envelope.authToken.length > 0)
|
|
702
|
+
if (envelope.authToken && envelope.authToken.length > 0)
|
|
703
|
+
writer.uint32(50).string(envelope.authToken);
|
|
360
704
|
for (const format of envelope.supportedWireFormats ?? []) {
|
|
361
705
|
writer.uint32(58).string(format);
|
|
362
706
|
}
|
|
363
707
|
if (envelope.accepted !== void 0) writer.uint32(64).bool(envelope.accepted);
|
|
364
|
-
if (envelope.rejectReason && envelope.rejectReason.length > 0)
|
|
708
|
+
if (envelope.rejectReason && envelope.rejectReason.length > 0)
|
|
709
|
+
writer.uint32(74).string(envelope.rejectReason);
|
|
365
710
|
if (envelope.selectedWireFormat && envelope.selectedWireFormat.length > 0) {
|
|
366
711
|
writer.uint32(82).string(envelope.selectedWireFormat);
|
|
367
712
|
}
|
|
@@ -375,8 +720,10 @@ function encodeEnvelope(envelope) {
|
|
|
375
720
|
if (envelope.acknowledgedMessageId && envelope.acknowledgedMessageId.length > 0) {
|
|
376
721
|
writer.uint32(114).string(envelope.acknowledgedMessageId);
|
|
377
722
|
}
|
|
378
|
-
if (envelope.lastSequenceNumber !== void 0)
|
|
379
|
-
|
|
723
|
+
if (envelope.lastSequenceNumber !== void 0)
|
|
724
|
+
writer.uint32(120).int64(envelope.lastSequenceNumber);
|
|
725
|
+
if (envelope.errorCode && envelope.errorCode.length > 0)
|
|
726
|
+
writer.uint32(130).string(envelope.errorCode);
|
|
380
727
|
if (envelope.errorMessage && envelope.errorMessage.length > 0) {
|
|
381
728
|
writer.uint32(138).string(envelope.errorMessage);
|
|
382
729
|
}
|
|
@@ -399,7 +746,10 @@ function decodeEnvelope(bytes) {
|
|
|
399
746
|
envelope.nodeId = reader.string();
|
|
400
747
|
break;
|
|
401
748
|
case 4:
|
|
402
|
-
envelope.versionVector = [
|
|
749
|
+
envelope.versionVector = [
|
|
750
|
+
...envelope.versionVector ?? [],
|
|
751
|
+
decodeVectorEntry(reader, reader.uint32())
|
|
752
|
+
];
|
|
403
753
|
break;
|
|
404
754
|
case 5:
|
|
405
755
|
envelope.schemaVersion = reader.int32();
|
|
@@ -420,7 +770,10 @@ function decodeEnvelope(bytes) {
|
|
|
420
770
|
envelope.selectedWireFormat = reader.string();
|
|
421
771
|
break;
|
|
422
772
|
case 11:
|
|
423
|
-
envelope.operations = [
|
|
773
|
+
envelope.operations = [
|
|
774
|
+
...envelope.operations ?? [],
|
|
775
|
+
decodeProtoOperation(reader, reader.uint32())
|
|
776
|
+
];
|
|
424
777
|
break;
|
|
425
778
|
case 12:
|
|
426
779
|
envelope.isFinal = reader.bool();
|
|
@@ -1029,184 +1382,1027 @@ var ChaosTransport = class {
|
|
|
1029
1382
|
};
|
|
1030
1383
|
|
|
1031
1384
|
// src/engine/sync-engine.ts
|
|
1032
|
-
import {
|
|
1385
|
+
import {
|
|
1386
|
+
APPLY_FAILURE_CODES,
|
|
1387
|
+
ClockDriftError,
|
|
1388
|
+
KoraError as KoraError2,
|
|
1389
|
+
SyncError as SyncError4,
|
|
1390
|
+
applyOperationTransforms,
|
|
1391
|
+
defaultApplyFailureReason
|
|
1392
|
+
} from "@korajs/core";
|
|
1033
1393
|
import { topologicalSort as topologicalSort2 } from "@korajs/core/internal";
|
|
1034
1394
|
|
|
1035
|
-
// src/
|
|
1036
|
-
|
|
1037
|
-
var
|
|
1038
|
-
|
|
1039
|
-
|
|
1395
|
+
// src/awareness/awareness-manager.ts
|
|
1396
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
1397
|
+
var nextLocalClientId = 1;
|
|
1398
|
+
var AwarenessManager = class {
|
|
1399
|
+
/** Unique client ID for this instance */
|
|
1400
|
+
clientId;
|
|
1401
|
+
localState = null;
|
|
1402
|
+
remoteStates = /* @__PURE__ */ new Map();
|
|
1403
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1404
|
+
emitter;
|
|
1405
|
+
timeoutMs;
|
|
1406
|
+
// Track when we last received an update from each remote client.
|
|
1407
|
+
// Used for timeout-based cleanup as a safety net in case the server
|
|
1408
|
+
// does not send an explicit removal on disconnect.
|
|
1409
|
+
lastUpdated = /* @__PURE__ */ new Map();
|
|
1410
|
+
cleanupTimer = null;
|
|
1411
|
+
sendHandler = null;
|
|
1412
|
+
destroyed = false;
|
|
1413
|
+
constructor(options) {
|
|
1414
|
+
this.clientId = options?.clientId ?? nextLocalClientId++;
|
|
1415
|
+
this.emitter = options?.emitter ?? null;
|
|
1416
|
+
this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1040
1417
|
}
|
|
1041
|
-
storage;
|
|
1042
|
-
queue = [];
|
|
1043
|
-
seen = /* @__PURE__ */ new Set();
|
|
1044
|
-
inFlight = /* @__PURE__ */ new Map();
|
|
1045
|
-
nextBatchId = 0;
|
|
1046
|
-
initialized = false;
|
|
1047
1418
|
/**
|
|
1048
|
-
*
|
|
1049
|
-
*
|
|
1419
|
+
* Set the local user's awareness state and broadcast to peers.
|
|
1420
|
+
*
|
|
1421
|
+
* @param state - The awareness state to share. Pass null to clear presence.
|
|
1050
1422
|
*/
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
}
|
|
1423
|
+
setLocalState(state) {
|
|
1424
|
+
if (this.destroyed) return;
|
|
1425
|
+
this.localState = state;
|
|
1426
|
+
const message = {
|
|
1427
|
+
type: "awareness",
|
|
1428
|
+
clientId: this.clientId,
|
|
1429
|
+
states: { [this.clientId]: state }
|
|
1430
|
+
};
|
|
1431
|
+
this.sendHandler?.(message);
|
|
1432
|
+
this.emitAwarenessEvent();
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Get the local awareness state.
|
|
1436
|
+
*/
|
|
1437
|
+
getLocalState() {
|
|
1438
|
+
return this.localState;
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Get all known awareness states (local + remote).
|
|
1442
|
+
* Returns a new Map on each call.
|
|
1443
|
+
*/
|
|
1444
|
+
getStates() {
|
|
1445
|
+
const result = /* @__PURE__ */ new Map();
|
|
1446
|
+
if (this.localState) {
|
|
1447
|
+
result.set(this.clientId, this.localState);
|
|
1058
1448
|
}
|
|
1059
|
-
|
|
1060
|
-
|
|
1449
|
+
for (const [id, state] of this.remoteStates) {
|
|
1450
|
+
result.set(id, state);
|
|
1061
1451
|
}
|
|
1062
|
-
|
|
1452
|
+
return result;
|
|
1063
1453
|
}
|
|
1064
1454
|
/**
|
|
1065
|
-
*
|
|
1066
|
-
*
|
|
1455
|
+
* Handle an incoming awareness message from the transport.
|
|
1456
|
+
* This processes remote state updates and notifies listeners.
|
|
1067
1457
|
*/
|
|
1068
|
-
|
|
1069
|
-
if (this.
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1458
|
+
handleRemoteMessage(message) {
|
|
1459
|
+
if (this.destroyed) return;
|
|
1460
|
+
const added = [];
|
|
1461
|
+
const updated = [];
|
|
1462
|
+
const removed = [];
|
|
1463
|
+
const now = Date.now();
|
|
1464
|
+
for (const [clientIdStr, state] of Object.entries(message.states)) {
|
|
1465
|
+
const clientId = Number(clientIdStr);
|
|
1466
|
+
if (clientId === this.clientId) continue;
|
|
1467
|
+
if (state === null) {
|
|
1468
|
+
if (this.remoteStates.has(clientId)) {
|
|
1469
|
+
this.remoteStates.delete(clientId);
|
|
1470
|
+
this.lastUpdated.delete(clientId);
|
|
1471
|
+
removed.push(clientId);
|
|
1472
|
+
}
|
|
1473
|
+
} else {
|
|
1474
|
+
if (this.remoteStates.has(clientId)) {
|
|
1475
|
+
this.remoteStates.set(clientId, state);
|
|
1476
|
+
this.lastUpdated.set(clientId, now);
|
|
1477
|
+
updated.push(clientId);
|
|
1478
|
+
} else {
|
|
1479
|
+
this.remoteStates.set(clientId, state);
|
|
1480
|
+
this.lastUpdated.set(clientId, now);
|
|
1481
|
+
added.push(clientId);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
if (added.length > 0 || updated.length > 0 || removed.length > 0) {
|
|
1486
|
+
const change = { added, updated, removed };
|
|
1487
|
+
this.notifyListeners(change);
|
|
1488
|
+
this.emitAwarenessEvent();
|
|
1075
1489
|
}
|
|
1076
1490
|
}
|
|
1077
1491
|
/**
|
|
1078
|
-
*
|
|
1079
|
-
*
|
|
1080
|
-
*
|
|
1081
|
-
* @param batchSize - Maximum number of operations in the batch
|
|
1492
|
+
* Remove a specific remote client's awareness state.
|
|
1493
|
+
* Called when the server notifies that a client has disconnected.
|
|
1082
1494
|
*/
|
|
1083
|
-
|
|
1084
|
-
if (this.
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
this.
|
|
1088
|
-
|
|
1495
|
+
removeClient(clientId) {
|
|
1496
|
+
if (this.destroyed) return;
|
|
1497
|
+
if (!this.remoteStates.has(clientId)) return;
|
|
1498
|
+
this.remoteStates.delete(clientId);
|
|
1499
|
+
this.lastUpdated.delete(clientId);
|
|
1500
|
+
const change = {
|
|
1501
|
+
added: [],
|
|
1502
|
+
updated: [],
|
|
1503
|
+
removed: [clientId]
|
|
1504
|
+
};
|
|
1505
|
+
this.notifyListeners(change);
|
|
1506
|
+
this.emitAwarenessEvent();
|
|
1089
1507
|
}
|
|
1090
1508
|
/**
|
|
1091
|
-
*
|
|
1509
|
+
* Register a handler for sending awareness messages through the transport.
|
|
1510
|
+
* The sync engine calls this to wire outgoing awareness messages to the transport.
|
|
1092
1511
|
*/
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
if (!ops) return;
|
|
1096
|
-
this.inFlight.delete(batchId);
|
|
1097
|
-
const ids = ops.map((op) => op.id);
|
|
1098
|
-
await this.storage.dequeue(ids);
|
|
1512
|
+
onSend(handler) {
|
|
1513
|
+
this.sendHandler = handler;
|
|
1099
1514
|
}
|
|
1100
1515
|
/**
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1516
|
+
* Register a listener for awareness state changes.
|
|
1517
|
+
* Returns an unsubscribe function.
|
|
1103
1518
|
*/
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1519
|
+
on(_event, listener) {
|
|
1520
|
+
this.listeners.add(listener);
|
|
1521
|
+
return () => {
|
|
1522
|
+
this.listeners.delete(listener);
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* Remove a specific change listener.
|
|
1527
|
+
*/
|
|
1528
|
+
off(_event, listener) {
|
|
1529
|
+
this.listeners.delete(listener);
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Start the cleanup timer that removes stale remote states.
|
|
1533
|
+
* Called when the sync engine transitions to streaming state.
|
|
1534
|
+
*/
|
|
1535
|
+
startCleanupTimer() {
|
|
1536
|
+
if (this.cleanupTimer) return;
|
|
1537
|
+
this.cleanupTimer = setInterval(() => {
|
|
1538
|
+
this.cleanupStaleStates();
|
|
1539
|
+
}, this.timeoutMs);
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Stop the cleanup timer.
|
|
1543
|
+
*/
|
|
1544
|
+
stopCleanupTimer() {
|
|
1545
|
+
if (this.cleanupTimer) {
|
|
1546
|
+
clearInterval(this.cleanupTimer);
|
|
1547
|
+
this.cleanupTimer = null;
|
|
1111
1548
|
}
|
|
1112
1549
|
}
|
|
1113
1550
|
/**
|
|
1114
|
-
*
|
|
1551
|
+
* Clean up all resources. After calling destroy(), the manager
|
|
1552
|
+
* will no longer send or receive awareness updates.
|
|
1553
|
+
* Broadcasts removal of local state before shutting down.
|
|
1115
1554
|
*/
|
|
1116
|
-
|
|
1117
|
-
|
|
1555
|
+
destroy() {
|
|
1556
|
+
if (this.destroyed) return;
|
|
1557
|
+
this.destroyed = true;
|
|
1558
|
+
if (this.localState) {
|
|
1559
|
+
const message = {
|
|
1560
|
+
type: "awareness",
|
|
1561
|
+
clientId: this.clientId,
|
|
1562
|
+
states: { [this.clientId]: null }
|
|
1563
|
+
};
|
|
1564
|
+
this.sendHandler?.(message);
|
|
1565
|
+
}
|
|
1566
|
+
this.localState = null;
|
|
1567
|
+
this.remoteStates.clear();
|
|
1568
|
+
this.lastUpdated.clear();
|
|
1569
|
+
this.listeners.clear();
|
|
1570
|
+
this.sendHandler = null;
|
|
1571
|
+
this.stopCleanupTimer();
|
|
1572
|
+
}
|
|
1573
|
+
// --- Private ---
|
|
1574
|
+
cleanupStaleStates() {
|
|
1575
|
+
const now = Date.now();
|
|
1576
|
+
const removed = [];
|
|
1577
|
+
for (const [clientId, lastTime] of this.lastUpdated) {
|
|
1578
|
+
if (now - lastTime > this.timeoutMs) {
|
|
1579
|
+
this.remoteStates.delete(clientId);
|
|
1580
|
+
this.lastUpdated.delete(clientId);
|
|
1581
|
+
removed.push(clientId);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
if (removed.length > 0) {
|
|
1585
|
+
const change = { added: [], updated: [], removed };
|
|
1586
|
+
this.notifyListeners(change);
|
|
1587
|
+
this.emitAwarenessEvent();
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
notifyListeners(change) {
|
|
1591
|
+
for (const listener of this.listeners) {
|
|
1592
|
+
listener(change);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
emitAwarenessEvent() {
|
|
1596
|
+
const states = this.getStates();
|
|
1597
|
+
this.emitter?.emit({ type: "awareness:updated", states });
|
|
1118
1598
|
}
|
|
1599
|
+
};
|
|
1600
|
+
|
|
1601
|
+
// src/diagnostics/bandwidth-estimator.ts
|
|
1602
|
+
var BandwidthEstimator = class {
|
|
1603
|
+
maxSamples;
|
|
1604
|
+
timeSource;
|
|
1605
|
+
samples = [];
|
|
1119
1606
|
/**
|
|
1120
|
-
*
|
|
1607
|
+
* @param maxSamples - Maximum number of samples to retain. Defaults to 20.
|
|
1608
|
+
* @param timeSource - Injectable time source for deterministic testing.
|
|
1121
1609
|
*/
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1610
|
+
constructor(maxSamples = 20, timeSource) {
|
|
1611
|
+
this.maxSamples = maxSamples;
|
|
1612
|
+
this.timeSource = timeSource ?? { now: () => Date.now() };
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Record a transfer of `bytes` that took `durationMs` to complete.
|
|
1616
|
+
* Samples with zero or negative duration are ignored to avoid division errors.
|
|
1617
|
+
*/
|
|
1618
|
+
recordTransfer(bytes, durationMs) {
|
|
1619
|
+
if (durationMs <= 0 || bytes <= 0) return;
|
|
1620
|
+
this.samples.push({
|
|
1621
|
+
bytes,
|
|
1622
|
+
durationMs,
|
|
1623
|
+
timestamp: this.timeSource.now()
|
|
1624
|
+
});
|
|
1625
|
+
while (this.samples.length > this.maxSamples) {
|
|
1626
|
+
this.samples.shift();
|
|
1126
1627
|
}
|
|
1127
|
-
return this.queue.length + inFlightCount;
|
|
1128
1628
|
}
|
|
1129
1629
|
/**
|
|
1130
|
-
*
|
|
1630
|
+
* Estimate current effective bandwidth in bytes per second.
|
|
1631
|
+
*
|
|
1632
|
+
* Uses exponential weighting so recent samples have more influence.
|
|
1633
|
+
* Returns null if fewer than 2 samples exist (not enough data).
|
|
1131
1634
|
*/
|
|
1132
|
-
|
|
1133
|
-
|
|
1635
|
+
estimate() {
|
|
1636
|
+
if (this.samples.length < 2) return null;
|
|
1637
|
+
let weightedSum = 0;
|
|
1638
|
+
let totalWeight = 0;
|
|
1639
|
+
const decayFactor = 0.9;
|
|
1640
|
+
for (let i = this.samples.length - 1; i >= 0; i--) {
|
|
1641
|
+
const sample = this.samples[i];
|
|
1642
|
+
if (!sample) continue;
|
|
1643
|
+
const bytesPerSec = sample.bytes / sample.durationMs * 1e3;
|
|
1644
|
+
const age = this.samples.length - 1 - i;
|
|
1645
|
+
const weight = decayFactor ** age;
|
|
1646
|
+
weightedSum += bytesPerSec * weight;
|
|
1647
|
+
totalWeight += weight;
|
|
1648
|
+
}
|
|
1649
|
+
if (totalWeight === 0) return null;
|
|
1650
|
+
return Math.round(weightedSum / totalWeight);
|
|
1134
1651
|
}
|
|
1135
1652
|
/**
|
|
1136
|
-
*
|
|
1653
|
+
* Reset all recorded samples.
|
|
1137
1654
|
*/
|
|
1138
|
-
|
|
1139
|
-
|
|
1655
|
+
reset() {
|
|
1656
|
+
this.samples.length = 0;
|
|
1140
1657
|
}
|
|
1141
1658
|
/**
|
|
1142
|
-
*
|
|
1659
|
+
* Get the number of samples currently stored.
|
|
1143
1660
|
*/
|
|
1144
|
-
|
|
1145
|
-
return this.
|
|
1661
|
+
sampleCount() {
|
|
1662
|
+
return this.samples.length;
|
|
1146
1663
|
}
|
|
1147
1664
|
};
|
|
1148
1665
|
|
|
1149
|
-
// src/
|
|
1150
|
-
var
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
handshaking: ["syncing", "error", "disconnected"],
|
|
1156
|
-
syncing: ["streaming", "error", "disconnected"],
|
|
1157
|
-
streaming: ["disconnected", "error"],
|
|
1158
|
-
error: ["disconnected"]
|
|
1159
|
-
};
|
|
1160
|
-
var nextMessageId = 0;
|
|
1161
|
-
function generateMessageId() {
|
|
1162
|
-
return `msg-${Date.now()}-${nextMessageId++}`;
|
|
1163
|
-
}
|
|
1164
|
-
var SyncEngine = class {
|
|
1165
|
-
state = "disconnected";
|
|
1166
|
-
transport;
|
|
1167
|
-
store;
|
|
1168
|
-
config;
|
|
1169
|
-
serializer;
|
|
1170
|
-
emitter;
|
|
1171
|
-
outboundQueue;
|
|
1172
|
-
batchSize;
|
|
1173
|
-
remoteVector = /* @__PURE__ */ new Map();
|
|
1174
|
-
lastSyncedAt = null;
|
|
1175
|
-
currentBatch = null;
|
|
1176
|
-
reconnecting = false;
|
|
1177
|
-
// Track delta exchange state
|
|
1178
|
-
deltaBatchesReceived = 0;
|
|
1179
|
-
deltaReceiveComplete = false;
|
|
1180
|
-
deltaSendComplete = false;
|
|
1181
|
-
constructor(options) {
|
|
1182
|
-
this.transport = options.transport;
|
|
1183
|
-
this.store = options.store;
|
|
1184
|
-
this.config = options.config;
|
|
1185
|
-
this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
|
|
1186
|
-
this.emitter = options.emitter ?? null;
|
|
1187
|
-
this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
1188
|
-
const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
|
|
1189
|
-
this.outboundQueue = new OutboundQueue(queueStorage);
|
|
1190
|
-
}
|
|
1666
|
+
// src/diagnostics/percentile.ts
|
|
1667
|
+
var SlidingWindowPercentile = class {
|
|
1668
|
+
samples;
|
|
1669
|
+
maxSize;
|
|
1670
|
+
writeIndex = 0;
|
|
1671
|
+
count = 0;
|
|
1191
1672
|
/**
|
|
1192
|
-
*
|
|
1673
|
+
* @param maxSize - Maximum number of samples in the sliding window.
|
|
1674
|
+
* Older samples are overwritten when the buffer is full.
|
|
1193
1675
|
*/
|
|
1194
|
-
|
|
1195
|
-
if (
|
|
1196
|
-
throw new
|
|
1197
|
-
|
|
1198
|
-
|
|
1676
|
+
constructor(maxSize) {
|
|
1677
|
+
if (maxSize < 1) {
|
|
1678
|
+
throw new Error(`SlidingWindowPercentile maxSize must be >= 1, got ${maxSize}`);
|
|
1679
|
+
}
|
|
1680
|
+
this.maxSize = maxSize;
|
|
1681
|
+
this.samples = new Array(maxSize);
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Add a sample to the sliding window.
|
|
1685
|
+
* If the window is full, the oldest sample is overwritten.
|
|
1686
|
+
*/
|
|
1687
|
+
addSample(value) {
|
|
1688
|
+
this.samples[this.writeIndex] = value;
|
|
1689
|
+
this.writeIndex = (this.writeIndex + 1) % this.maxSize;
|
|
1690
|
+
if (this.count < this.maxSize) {
|
|
1691
|
+
this.count++;
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* Compute a percentile value from the current window.
|
|
1696
|
+
*
|
|
1697
|
+
* Uses the nearest-rank method: the percentile value is the smallest
|
|
1698
|
+
* value in the dataset such that at least p% of the data is <= that value.
|
|
1699
|
+
*
|
|
1700
|
+
* @param p - Percentile to compute (0-100). E.g., 50 for median, 95 for p95.
|
|
1701
|
+
* @returns The percentile value, or 0 if no samples have been recorded.
|
|
1702
|
+
*/
|
|
1703
|
+
percentile(p) {
|
|
1704
|
+
if (this.count === 0) return 0;
|
|
1705
|
+
const sorted = this.samples.slice(0, this.count).sort((a, b) => a - b);
|
|
1706
|
+
const rank = Math.ceil(p / 100 * sorted.length) - 1;
|
|
1707
|
+
const index = Math.max(0, Math.min(rank, sorted.length - 1));
|
|
1708
|
+
return sorted[index] ?? 0;
|
|
1709
|
+
}
|
|
1710
|
+
/**
|
|
1711
|
+
* Get the most recently added sample, or 0 if no samples exist.
|
|
1712
|
+
*/
|
|
1713
|
+
latest() {
|
|
1714
|
+
if (this.count === 0) return 0;
|
|
1715
|
+
const lastIndex = (this.writeIndex - 1 + this.maxSize) % this.maxSize;
|
|
1716
|
+
return this.samples[lastIndex] ?? 0;
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* Get the number of samples currently in the window.
|
|
1720
|
+
*/
|
|
1721
|
+
size() {
|
|
1722
|
+
return this.count;
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* Reset the sliding window, clearing all samples.
|
|
1726
|
+
*/
|
|
1727
|
+
reset() {
|
|
1728
|
+
this.writeIndex = 0;
|
|
1729
|
+
this.count = 0;
|
|
1730
|
+
}
|
|
1731
|
+
};
|
|
1732
|
+
|
|
1733
|
+
// src/diagnostics/metrics-collector.ts
|
|
1734
|
+
var SyncMetricsCollector = class {
|
|
1735
|
+
rttWindow;
|
|
1736
|
+
inboundBandwidth;
|
|
1737
|
+
outboundBandwidth;
|
|
1738
|
+
timeSource;
|
|
1739
|
+
diagnosticsInterval;
|
|
1740
|
+
// Connection
|
|
1741
|
+
connectedAt = null;
|
|
1742
|
+
disconnectedAt = null;
|
|
1743
|
+
reconnectAttempts = 0;
|
|
1744
|
+
// Throughput
|
|
1745
|
+
operationsSent = 0;
|
|
1746
|
+
operationsReceived = 0;
|
|
1747
|
+
bytesSent = 0;
|
|
1748
|
+
bytesReceived = 0;
|
|
1749
|
+
// Queue
|
|
1750
|
+
pendingOperations = 0;
|
|
1751
|
+
outboundQueueSize = 0;
|
|
1752
|
+
// Sync progress
|
|
1753
|
+
lastSyncedAt = null;
|
|
1754
|
+
syncStartTime = null;
|
|
1755
|
+
syncDuration = null;
|
|
1756
|
+
initialSyncComplete = false;
|
|
1757
|
+
initialSyncTotalBatches = 0;
|
|
1758
|
+
initialSyncReceivedBatches = 0;
|
|
1759
|
+
// Errors
|
|
1760
|
+
lastError = null;
|
|
1761
|
+
errorCount = 0;
|
|
1762
|
+
// Status
|
|
1763
|
+
currentStatus = "offline";
|
|
1764
|
+
currentQuality = "offline";
|
|
1765
|
+
// Periodic emission
|
|
1766
|
+
emitter = null;
|
|
1767
|
+
periodicTimer = null;
|
|
1768
|
+
constructor(config) {
|
|
1769
|
+
this.rttWindow = new SlidingWindowPercentile(config?.rttWindowSize ?? 100);
|
|
1770
|
+
this.inboundBandwidth = new BandwidthEstimator(
|
|
1771
|
+
config?.bandwidthWindowSize ?? 20,
|
|
1772
|
+
config?.timeSource
|
|
1773
|
+
);
|
|
1774
|
+
this.outboundBandwidth = new BandwidthEstimator(
|
|
1775
|
+
config?.bandwidthWindowSize ?? 20,
|
|
1776
|
+
config?.timeSource
|
|
1777
|
+
);
|
|
1778
|
+
this.timeSource = config?.timeSource ?? { now: () => Date.now() };
|
|
1779
|
+
this.diagnosticsInterval = config?.diagnosticsInterval ?? 5e3;
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* Attach an event emitter for periodic diagnostics emission.
|
|
1783
|
+
* Starts emitting `sync:diagnostics` events at the configured interval
|
|
1784
|
+
* while connected.
|
|
1785
|
+
*/
|
|
1786
|
+
attachEmitter(emitter) {
|
|
1787
|
+
this.emitter = emitter;
|
|
1788
|
+
}
|
|
1789
|
+
/**
|
|
1790
|
+
* Record that a connection has been established.
|
|
1791
|
+
*/
|
|
1792
|
+
recordConnected() {
|
|
1793
|
+
this.connectedAt = this.timeSource.now();
|
|
1794
|
+
this.reconnectAttempts = 0;
|
|
1795
|
+
this.startPeriodicEmission();
|
|
1796
|
+
}
|
|
1797
|
+
/**
|
|
1798
|
+
* Record that the connection has been lost.
|
|
1799
|
+
*/
|
|
1800
|
+
recordDisconnected() {
|
|
1801
|
+
this.disconnectedAt = this.timeSource.now();
|
|
1802
|
+
this.stopPeriodicEmission();
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Record a reconnection attempt.
|
|
1806
|
+
*/
|
|
1807
|
+
recordReconnectAttempt() {
|
|
1808
|
+
this.reconnectAttempts++;
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* Record a round-trip time measurement.
|
|
1812
|
+
*/
|
|
1813
|
+
recordRtt(ms) {
|
|
1814
|
+
this.rttWindow.addSample(ms);
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* Record operations sent with their serialized byte size.
|
|
1818
|
+
*/
|
|
1819
|
+
recordSent(operationCount, byteSize, durationMs) {
|
|
1820
|
+
this.operationsSent += operationCount;
|
|
1821
|
+
this.bytesSent += byteSize;
|
|
1822
|
+
if (durationMs > 0 && byteSize > 0) {
|
|
1823
|
+
this.outboundBandwidth.recordTransfer(byteSize, durationMs);
|
|
1824
|
+
this.emitBandwidthEvent("out");
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Record operations received with their serialized byte size.
|
|
1829
|
+
*/
|
|
1830
|
+
recordReceived(operationCount, byteSize, durationMs) {
|
|
1831
|
+
this.operationsReceived += operationCount;
|
|
1832
|
+
this.bytesReceived += byteSize;
|
|
1833
|
+
if (durationMs > 0 && byteSize > 0) {
|
|
1834
|
+
this.inboundBandwidth.recordTransfer(byteSize, durationMs);
|
|
1835
|
+
this.emitBandwidthEvent("in");
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
/**
|
|
1839
|
+
* Update the pending operations count and estimated outbound queue size.
|
|
1840
|
+
*/
|
|
1841
|
+
updateQueue(pendingOps, estimatedBytes) {
|
|
1842
|
+
this.pendingOperations = pendingOps;
|
|
1843
|
+
this.outboundQueueSize = estimatedBytes;
|
|
1844
|
+
}
|
|
1845
|
+
/**
|
|
1846
|
+
* Record that sync has started (for measuring sync duration).
|
|
1847
|
+
*/
|
|
1848
|
+
recordSyncStarted() {
|
|
1849
|
+
this.syncStartTime = this.timeSource.now();
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* Record that a sync cycle has completed.
|
|
1853
|
+
*/
|
|
1854
|
+
recordSyncCompleted() {
|
|
1855
|
+
if (this.syncStartTime !== null) {
|
|
1856
|
+
this.syncDuration = this.timeSource.now() - this.syncStartTime;
|
|
1857
|
+
this.syncStartTime = null;
|
|
1858
|
+
}
|
|
1859
|
+
this.lastSyncedAt = this.timeSource.now();
|
|
1860
|
+
if (!this.initialSyncComplete) {
|
|
1861
|
+
this.initialSyncComplete = true;
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
/**
|
|
1865
|
+
* Update initial sync progress.
|
|
1866
|
+
* @param receivedBatches - Number of delta batches received so far.
|
|
1867
|
+
* @param totalBatches - Estimated total batches (0 if unknown).
|
|
1868
|
+
*/
|
|
1869
|
+
updateInitialSyncProgress(receivedBatches, totalBatches) {
|
|
1870
|
+
this.initialSyncReceivedBatches = receivedBatches;
|
|
1871
|
+
this.initialSyncTotalBatches = totalBatches;
|
|
1872
|
+
const progress = totalBatches > 0 ? Math.min(1, receivedBatches / totalBatches) : receivedBatches > 0 ? 0.5 : 0;
|
|
1873
|
+
this.emitter?.emit({
|
|
1874
|
+
type: "sync:initial-sync-progress",
|
|
1875
|
+
progress,
|
|
1876
|
+
totalBatches,
|
|
1877
|
+
receivedBatches
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Record an error.
|
|
1882
|
+
*/
|
|
1883
|
+
recordError(message) {
|
|
1884
|
+
this.lastError = message;
|
|
1885
|
+
this.errorCount++;
|
|
1886
|
+
}
|
|
1887
|
+
/**
|
|
1888
|
+
* Update the current developer-facing status.
|
|
1889
|
+
*/
|
|
1890
|
+
updateStatus(status) {
|
|
1891
|
+
this.currentStatus = status;
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1894
|
+
* Update the connection quality.
|
|
1895
|
+
*/
|
|
1896
|
+
updateQuality(quality) {
|
|
1897
|
+
this.currentQuality = quality;
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* Compute and return a full diagnostics snapshot.
|
|
1901
|
+
*/
|
|
1902
|
+
getSnapshot() {
|
|
1903
|
+
return {
|
|
1904
|
+
// Connection
|
|
1905
|
+
status: this.currentStatus,
|
|
1906
|
+
connectedAt: this.connectedAt,
|
|
1907
|
+
disconnectedAt: this.disconnectedAt,
|
|
1908
|
+
reconnectAttempts: this.reconnectAttempts,
|
|
1909
|
+
// Latency
|
|
1910
|
+
rttMs: this.rttWindow.latest(),
|
|
1911
|
+
rttP50Ms: this.rttWindow.percentile(50),
|
|
1912
|
+
rttP95Ms: this.rttWindow.percentile(95),
|
|
1913
|
+
rttP99Ms: this.rttWindow.percentile(99),
|
|
1914
|
+
// Throughput
|
|
1915
|
+
operationsSent: this.operationsSent,
|
|
1916
|
+
operationsReceived: this.operationsReceived,
|
|
1917
|
+
bytesSent: this.bytesSent,
|
|
1918
|
+
bytesReceived: this.bytesReceived,
|
|
1919
|
+
// Queue
|
|
1920
|
+
pendingOperations: this.pendingOperations,
|
|
1921
|
+
outboundQueueSize: this.outboundQueueSize,
|
|
1922
|
+
// Sync Progress
|
|
1923
|
+
lastSyncedAt: this.lastSyncedAt,
|
|
1924
|
+
syncDuration: this.syncDuration,
|
|
1925
|
+
initialSyncComplete: this.initialSyncComplete,
|
|
1926
|
+
initialSyncProgress: this.computeInitialSyncProgress(),
|
|
1927
|
+
// Errors
|
|
1928
|
+
lastError: this.lastError,
|
|
1929
|
+
errorCount: this.errorCount,
|
|
1930
|
+
// Connection Quality
|
|
1931
|
+
quality: this.currentQuality,
|
|
1932
|
+
effectiveBandwidth: this.computeEffectiveBandwidth()
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* Assess connection quality from current metrics.
|
|
1937
|
+
*
|
|
1938
|
+
* Quality is derived from RTT percentiles, error rate, and bandwidth:
|
|
1939
|
+
* - excellent: p95 < 100ms, no errors
|
|
1940
|
+
* - good: p95 < 300ms, errorCount <= 1
|
|
1941
|
+
* - fair: p95 < 1000ms, errorCount <= 3
|
|
1942
|
+
* - poor: p95 >= 1000ms or errorCount > 3
|
|
1943
|
+
* - offline: no connection
|
|
1944
|
+
*/
|
|
1945
|
+
assessQuality() {
|
|
1946
|
+
if (this.currentStatus === "offline") return "offline";
|
|
1947
|
+
const p95 = this.rttWindow.percentile(95);
|
|
1948
|
+
const hasSamples = this.rttWindow.size() > 0;
|
|
1949
|
+
if (!hasSamples) {
|
|
1950
|
+
if (this.errorCount > 3) return "poor";
|
|
1951
|
+
if (this.errorCount > 0) return "fair";
|
|
1952
|
+
return "good";
|
|
1953
|
+
}
|
|
1954
|
+
if (this.errorCount > 3) return "poor";
|
|
1955
|
+
if (p95 < 100 && this.errorCount === 0) return "excellent";
|
|
1956
|
+
if (p95 < 300 && this.errorCount <= 1) return "good";
|
|
1957
|
+
if (p95 < 1e3 && this.errorCount <= 3) return "fair";
|
|
1958
|
+
return "poor";
|
|
1959
|
+
}
|
|
1960
|
+
/**
|
|
1961
|
+
* Reset all collected metrics. Call when starting a new session.
|
|
1962
|
+
*/
|
|
1963
|
+
reset() {
|
|
1964
|
+
this.rttWindow.reset();
|
|
1965
|
+
this.inboundBandwidth.reset();
|
|
1966
|
+
this.outboundBandwidth.reset();
|
|
1967
|
+
this.connectedAt = null;
|
|
1968
|
+
this.disconnectedAt = null;
|
|
1969
|
+
this.reconnectAttempts = 0;
|
|
1970
|
+
this.operationsSent = 0;
|
|
1971
|
+
this.operationsReceived = 0;
|
|
1972
|
+
this.bytesSent = 0;
|
|
1973
|
+
this.bytesReceived = 0;
|
|
1974
|
+
this.pendingOperations = 0;
|
|
1975
|
+
this.outboundQueueSize = 0;
|
|
1976
|
+
this.lastSyncedAt = null;
|
|
1977
|
+
this.syncStartTime = null;
|
|
1978
|
+
this.syncDuration = null;
|
|
1979
|
+
this.initialSyncComplete = false;
|
|
1980
|
+
this.initialSyncTotalBatches = 0;
|
|
1981
|
+
this.initialSyncReceivedBatches = 0;
|
|
1982
|
+
this.lastError = null;
|
|
1983
|
+
this.errorCount = 0;
|
|
1984
|
+
this.currentStatus = "offline";
|
|
1985
|
+
this.currentQuality = "offline";
|
|
1986
|
+
this.stopPeriodicEmission();
|
|
1987
|
+
}
|
|
1988
|
+
/**
|
|
1989
|
+
* Stop periodic emission and clean up resources.
|
|
1990
|
+
*/
|
|
1991
|
+
dispose() {
|
|
1992
|
+
this.stopPeriodicEmission();
|
|
1993
|
+
this.emitter = null;
|
|
1994
|
+
}
|
|
1995
|
+
// --- Private helpers ---
|
|
1996
|
+
computeInitialSyncProgress() {
|
|
1997
|
+
if (this.initialSyncComplete) return 1;
|
|
1998
|
+
if (this.initialSyncTotalBatches > 0) {
|
|
1999
|
+
return Math.min(1, this.initialSyncReceivedBatches / this.initialSyncTotalBatches);
|
|
2000
|
+
}
|
|
2001
|
+
return this.initialSyncReceivedBatches > 0 ? 0.5 : 0;
|
|
2002
|
+
}
|
|
2003
|
+
computeEffectiveBandwidth() {
|
|
2004
|
+
const inbound = this.inboundBandwidth.estimate();
|
|
2005
|
+
const outbound = this.outboundBandwidth.estimate();
|
|
2006
|
+
if (inbound === null && outbound === null) return null;
|
|
2007
|
+
if (inbound === null) return outbound;
|
|
2008
|
+
if (outbound === null) return inbound;
|
|
2009
|
+
return Math.min(inbound, outbound);
|
|
2010
|
+
}
|
|
2011
|
+
startPeriodicEmission() {
|
|
2012
|
+
if (this.periodicTimer !== null) return;
|
|
2013
|
+
if (!this.emitter) return;
|
|
2014
|
+
this.periodicTimer = setInterval(() => {
|
|
2015
|
+
this.emitDiagnostics();
|
|
2016
|
+
}, this.diagnosticsInterval);
|
|
2017
|
+
}
|
|
2018
|
+
stopPeriodicEmission() {
|
|
2019
|
+
if (this.periodicTimer !== null) {
|
|
2020
|
+
clearInterval(this.periodicTimer);
|
|
2021
|
+
this.periodicTimer = null;
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
emitDiagnostics() {
|
|
2025
|
+
this.emitter?.emit({
|
|
2026
|
+
type: "sync:diagnostics",
|
|
2027
|
+
diagnostics: this.getSnapshot()
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
emitBandwidthEvent(direction) {
|
|
2031
|
+
const estimator = direction === "in" ? this.inboundBandwidth : this.outboundBandwidth;
|
|
2032
|
+
const bps = estimator.estimate();
|
|
2033
|
+
if (bps !== null) {
|
|
2034
|
+
this.emitter?.emit({
|
|
2035
|
+
type: "sync:bandwidth",
|
|
2036
|
+
bytesPerSecond: bps,
|
|
2037
|
+
direction
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
};
|
|
2042
|
+
|
|
2043
|
+
// src/richtext/richtext-doc-channel.ts
|
|
2044
|
+
import { generateUUIDv7 } from "@korajs/core";
|
|
2045
|
+
|
|
2046
|
+
// src/richtext/doc-channel-wire.ts
|
|
2047
|
+
function encodeYjsUpdate(update) {
|
|
2048
|
+
let binary = "";
|
|
2049
|
+
for (let i = 0; i < update.length; i++) {
|
|
2050
|
+
binary += String.fromCharCode(update[i]);
|
|
2051
|
+
}
|
|
2052
|
+
return btoa(binary);
|
|
2053
|
+
}
|
|
2054
|
+
function decodeYjsUpdate(encoded) {
|
|
2055
|
+
const binary = atob(encoded);
|
|
2056
|
+
const bytes = new Uint8Array(binary.length);
|
|
2057
|
+
for (let i = 0; i < binary.length; i++) {
|
|
2058
|
+
bytes[i] = binary.charCodeAt(i);
|
|
2059
|
+
}
|
|
2060
|
+
return bytes;
|
|
2061
|
+
}
|
|
2062
|
+
function richtextDocKey(collection, recordId, field) {
|
|
2063
|
+
return `${collection}:${recordId}:${field}`;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// src/richtext/richtext-doc-channel.ts
|
|
2067
|
+
var DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD = 4096;
|
|
2068
|
+
var RichtextDocChannel = class {
|
|
2069
|
+
threshold;
|
|
2070
|
+
onSend;
|
|
2071
|
+
listeners = /* @__PURE__ */ new Map();
|
|
2072
|
+
constructor(options) {
|
|
2073
|
+
this.threshold = options?.largeDocThreshold ?? DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD;
|
|
2074
|
+
this.onSend = options?.onSend ?? null;
|
|
2075
|
+
}
|
|
2076
|
+
/**
|
|
2077
|
+
* Whether the doc channel should be used for a field.
|
|
2078
|
+
* @param preference - `true` forces on, `false` forces off, `undefined` uses size threshold
|
|
2079
|
+
*/
|
|
2080
|
+
shouldUseChannel(snapshotByteLength, preference) {
|
|
2081
|
+
if (preference === false) {
|
|
2082
|
+
return false;
|
|
2083
|
+
}
|
|
2084
|
+
if (preference === true) {
|
|
2085
|
+
return true;
|
|
2086
|
+
}
|
|
2087
|
+
return snapshotByteLength >= this.threshold;
|
|
2088
|
+
}
|
|
2089
|
+
getThreshold() {
|
|
2090
|
+
return this.threshold;
|
|
2091
|
+
}
|
|
2092
|
+
/**
|
|
2093
|
+
* Subscribe to incremental updates for a document key.
|
|
2094
|
+
*/
|
|
2095
|
+
subscribe(collection, recordId, field, listener) {
|
|
2096
|
+
const key = richtextDocKey(collection, recordId, field);
|
|
2097
|
+
let set = this.listeners.get(key);
|
|
2098
|
+
if (!set) {
|
|
2099
|
+
set = /* @__PURE__ */ new Set();
|
|
2100
|
+
this.listeners.set(key, set);
|
|
2101
|
+
}
|
|
2102
|
+
set.add(listener);
|
|
2103
|
+
return () => {
|
|
2104
|
+
const current = this.listeners.get(key);
|
|
2105
|
+
if (!current) {
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
current.delete(listener);
|
|
2109
|
+
if (current.size === 0) {
|
|
2110
|
+
this.listeners.delete(key);
|
|
2111
|
+
}
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
2115
|
+
* Publish a local incremental update to peers.
|
|
2116
|
+
*/
|
|
2117
|
+
send(collection, recordId, field, update) {
|
|
2118
|
+
if (!this.onSend || update.length === 0) {
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
this.onSend({
|
|
2122
|
+
type: "yjs-doc-update",
|
|
2123
|
+
messageId: generateUUIDv7(),
|
|
2124
|
+
collection,
|
|
2125
|
+
recordId,
|
|
2126
|
+
field,
|
|
2127
|
+
update: encodeYjsUpdate(update)
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
/**
|
|
2131
|
+
* Deliver a remote incremental update to local subscribers.
|
|
2132
|
+
*/
|
|
2133
|
+
deliver(message) {
|
|
2134
|
+
const key = richtextDocKey(message.collection, message.recordId, message.field);
|
|
2135
|
+
const set = this.listeners.get(key);
|
|
2136
|
+
if (!set || set.size === 0) {
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
const update = decodeYjsUpdate(message.update);
|
|
2140
|
+
for (const listener of set) {
|
|
2141
|
+
listener(update);
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
};
|
|
2145
|
+
|
|
2146
|
+
// src/engine/outbound-queue.ts
|
|
2147
|
+
import { topologicalSort } from "@korajs/core/internal";
|
|
2148
|
+
var OutboundQueue = class {
|
|
2149
|
+
constructor(storage) {
|
|
2150
|
+
this.storage = storage;
|
|
2151
|
+
}
|
|
2152
|
+
storage;
|
|
2153
|
+
queue = [];
|
|
2154
|
+
seen = /* @__PURE__ */ new Set();
|
|
2155
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
2156
|
+
nextBatchId = 0;
|
|
2157
|
+
initialized = false;
|
|
2158
|
+
/**
|
|
2159
|
+
* Load persisted operations from storage.
|
|
2160
|
+
* Must be called before using the queue.
|
|
2161
|
+
*/
|
|
2162
|
+
async initialize() {
|
|
2163
|
+
const stored = await this.storage.load();
|
|
2164
|
+
for (const op of stored) {
|
|
2165
|
+
if (!this.seen.has(op.id)) {
|
|
2166
|
+
this.seen.add(op.id);
|
|
2167
|
+
this.queue.push(op);
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
if (this.queue.length > 1) {
|
|
2171
|
+
this.queue = topologicalSort(this.queue);
|
|
2172
|
+
}
|
|
2173
|
+
this.initialized = true;
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* Add an operation to the outbound queue.
|
|
2177
|
+
* Deduplicates by operation ID. Persists to storage.
|
|
2178
|
+
*/
|
|
2179
|
+
async enqueue(op) {
|
|
2180
|
+
if (this.seen.has(op.id)) return;
|
|
2181
|
+
this.seen.add(op.id);
|
|
2182
|
+
this.queue.push(op);
|
|
2183
|
+
await this.storage.enqueue(op);
|
|
2184
|
+
if (this.queue.length > 1) {
|
|
2185
|
+
this.queue = topologicalSort(this.queue);
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2189
|
+
* Take a batch of operations from the front of the queue.
|
|
2190
|
+
* Moves them to in-flight status. Returns null if queue is empty.
|
|
2191
|
+
*
|
|
2192
|
+
* @param batchSize - Maximum number of operations in the batch
|
|
2193
|
+
*/
|
|
2194
|
+
takeBatch(batchSize) {
|
|
2195
|
+
if (this.queue.length === 0) return null;
|
|
2196
|
+
const ops = this.queue.splice(0, batchSize);
|
|
2197
|
+
const batchId = `batch-${this.nextBatchId++}`;
|
|
2198
|
+
this.inFlight.set(batchId, ops);
|
|
2199
|
+
return { batchId, operations: ops };
|
|
2200
|
+
}
|
|
2201
|
+
/**
|
|
2202
|
+
* Acknowledge a batch, removing its operations permanently.
|
|
2203
|
+
*/
|
|
2204
|
+
async acknowledge(batchId) {
|
|
2205
|
+
const ops = this.inFlight.get(batchId);
|
|
2206
|
+
if (!ops) return;
|
|
2207
|
+
this.inFlight.delete(batchId);
|
|
2208
|
+
const ids = ops.map((op) => op.id);
|
|
2209
|
+
await this.storage.dequeue(ids);
|
|
2210
|
+
}
|
|
2211
|
+
/**
|
|
2212
|
+
* Return a failed batch to the front of the queue for retry.
|
|
2213
|
+
* Prepends the operations to maintain priority.
|
|
2214
|
+
*/
|
|
2215
|
+
returnBatch(batchId) {
|
|
2216
|
+
const ops = this.inFlight.get(batchId);
|
|
2217
|
+
if (!ops) return;
|
|
2218
|
+
this.inFlight.delete(batchId);
|
|
2219
|
+
this.queue.unshift(...ops);
|
|
2220
|
+
if (this.queue.length > 1) {
|
|
2221
|
+
this.queue = topologicalSort(this.queue);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
/**
|
|
2225
|
+
* Number of operations waiting in the queue (not counting in-flight).
|
|
2226
|
+
*/
|
|
2227
|
+
get size() {
|
|
2228
|
+
return this.queue.length;
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* Total operations including in-flight.
|
|
2232
|
+
*/
|
|
2233
|
+
get totalPending() {
|
|
2234
|
+
let inFlightCount = 0;
|
|
2235
|
+
for (const ops of this.inFlight.values()) {
|
|
2236
|
+
inFlightCount += ops.length;
|
|
2237
|
+
}
|
|
2238
|
+
return this.queue.length + inFlightCount;
|
|
2239
|
+
}
|
|
2240
|
+
/**
|
|
2241
|
+
* Whether the queue has any operations to send.
|
|
2242
|
+
*/
|
|
2243
|
+
get hasOperations() {
|
|
2244
|
+
return this.queue.length > 0;
|
|
2245
|
+
}
|
|
2246
|
+
/**
|
|
2247
|
+
* Peek at the first `count` operations without removing them.
|
|
2248
|
+
*/
|
|
2249
|
+
peek(count) {
|
|
2250
|
+
return this.queue.slice(0, count);
|
|
2251
|
+
}
|
|
2252
|
+
/**
|
|
2253
|
+
* Whether initialize() has been called.
|
|
2254
|
+
*/
|
|
2255
|
+
get isInitialized() {
|
|
2256
|
+
return this.initialized;
|
|
2257
|
+
}
|
|
2258
|
+
/**
|
|
2259
|
+
* Remove operations by id from queue and persistent storage.
|
|
2260
|
+
* Used when ops were already sent during handshake delta exchange.
|
|
2261
|
+
*/
|
|
2262
|
+
async removeByIds(ids) {
|
|
2263
|
+
if (ids.length === 0) return;
|
|
2264
|
+
const idSet = new Set(ids);
|
|
2265
|
+
this.queue = this.queue.filter((op) => !idSet.has(op.id));
|
|
2266
|
+
for (const id of ids) {
|
|
2267
|
+
this.seen.delete(id);
|
|
2268
|
+
}
|
|
2269
|
+
await this.storage.dequeue(ids);
|
|
2270
|
+
}
|
|
2271
|
+
};
|
|
2272
|
+
|
|
2273
|
+
// src/engine/sync-engine.ts
|
|
2274
|
+
var DEFAULT_BATCH_SIZE = 100;
|
|
2275
|
+
var DEFAULT_SCHEMA_VERSION = 1;
|
|
2276
|
+
var VALID_TRANSITIONS = {
|
|
2277
|
+
disconnected: ["connecting"],
|
|
2278
|
+
connecting: ["handshaking", "error", "disconnected"],
|
|
2279
|
+
handshaking: ["syncing", "error", "disconnected"],
|
|
2280
|
+
syncing: ["streaming", "error", "disconnected"],
|
|
2281
|
+
streaming: ["disconnected", "error"],
|
|
2282
|
+
error: ["disconnected"]
|
|
2283
|
+
};
|
|
2284
|
+
var nextMessageId = 0;
|
|
2285
|
+
var nextQuerySubsetId = 0;
|
|
2286
|
+
function generateMessageId() {
|
|
2287
|
+
return `msg-${Date.now()}-${nextMessageId++}`;
|
|
2288
|
+
}
|
|
2289
|
+
var SyncEngine = class {
|
|
2290
|
+
state = "disconnected";
|
|
2291
|
+
transport;
|
|
2292
|
+
store;
|
|
2293
|
+
config;
|
|
2294
|
+
serializer;
|
|
2295
|
+
emitter;
|
|
2296
|
+
outboundQueue;
|
|
2297
|
+
batchSize;
|
|
2298
|
+
encryptor;
|
|
2299
|
+
awarenessManager;
|
|
2300
|
+
richtextDocChannel;
|
|
2301
|
+
metricsCollector;
|
|
2302
|
+
syncState;
|
|
2303
|
+
remoteVector = /* @__PURE__ */ new Map();
|
|
2304
|
+
lastAckedServerVector = /* @__PURE__ */ new Map();
|
|
2305
|
+
cachedUnsyncedCount = 0;
|
|
2306
|
+
lastSyncedAt = null;
|
|
2307
|
+
lastSuccessfulPush = null;
|
|
2308
|
+
lastSuccessfulPull = null;
|
|
2309
|
+
conflictCount = 0;
|
|
2310
|
+
currentBatch = null;
|
|
2311
|
+
reconnecting = false;
|
|
2312
|
+
schemaBlocked = false;
|
|
2313
|
+
// Track delta exchange state
|
|
2314
|
+
deltaBatchesReceived = 0;
|
|
2315
|
+
deltaReceiveComplete = false;
|
|
2316
|
+
deltaSendComplete = false;
|
|
2317
|
+
/** Op ids sent during handshake delta — removed from outbound queue to avoid duplicate send */
|
|
2318
|
+
deltaSentOpIds = [];
|
|
2319
|
+
/** Outbound delta batch message IDs awaiting ACK when strictHandshake is enabled */
|
|
2320
|
+
pendingDeltaBatchAcks = /* @__PURE__ */ new Set();
|
|
2321
|
+
/**
|
|
2322
|
+
* The effective scope for this sync session.
|
|
2323
|
+
* Starts as the configured scopeMap. After handshake, may be replaced
|
|
2324
|
+
* with the server-accepted scope (server is authoritative).
|
|
2325
|
+
*/
|
|
2326
|
+
activeScope;
|
|
2327
|
+
/** Live query subsets registered from reactive subscriptions */
|
|
2328
|
+
querySubsets = /* @__PURE__ */ new Map();
|
|
2329
|
+
querySubsetReconnectTimer = null;
|
|
2330
|
+
/** Resume cursor for paginated initial sync (persisted across reconnects) */
|
|
2331
|
+
resumeDeltaCursor = null;
|
|
2332
|
+
initialSyncTotalBatches = 0;
|
|
2333
|
+
constructor(options) {
|
|
2334
|
+
this.transport = options.transport;
|
|
2335
|
+
this.store = options.store;
|
|
2336
|
+
this.config = options.config;
|
|
2337
|
+
this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
|
|
2338
|
+
this.emitter = options.emitter ?? null;
|
|
2339
|
+
this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2340
|
+
this.encryptor = options.encryptor ?? null;
|
|
2341
|
+
this.syncState = options.syncState ?? null;
|
|
2342
|
+
this.activeScope = options.config.scopeMap;
|
|
2343
|
+
const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
|
|
2344
|
+
this.outboundQueue = new OutboundQueue(queueStorage);
|
|
2345
|
+
this.metricsCollector = new SyncMetricsCollector(options.metricsConfig);
|
|
2346
|
+
if (this.emitter) {
|
|
2347
|
+
this.metricsCollector.attachEmitter(this.emitter);
|
|
2348
|
+
}
|
|
2349
|
+
this.awarenessManager = new AwarenessManager({
|
|
2350
|
+
emitter: this.emitter ?? void 0
|
|
2351
|
+
});
|
|
2352
|
+
this.richtextDocChannel = new RichtextDocChannel({
|
|
2353
|
+
largeDocThreshold: options.config.richtextDocChannelThreshold,
|
|
2354
|
+
onSend: (message) => {
|
|
2355
|
+
if (this.state !== "streaming") {
|
|
2356
|
+
return;
|
|
2357
|
+
}
|
|
2358
|
+
this.transport.send(message);
|
|
2359
|
+
}
|
|
2360
|
+
});
|
|
2361
|
+
this.awarenessManager.onSend((message) => {
|
|
2362
|
+
if (this.state !== "streaming") return;
|
|
2363
|
+
const wireMessage = {
|
|
2364
|
+
type: "awareness-update",
|
|
2365
|
+
messageId: generateMessageId(),
|
|
2366
|
+
clientId: message.clientId,
|
|
2367
|
+
states: awarenessStatesToWire(message.states)
|
|
2368
|
+
};
|
|
2369
|
+
this.transport.send(wireMessage);
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
/**
|
|
2373
|
+
* Start the sync engine: connect → handshake → delta exchange → streaming.
|
|
2374
|
+
*/
|
|
2375
|
+
async start() {
|
|
2376
|
+
if (this.state !== "disconnected") {
|
|
2377
|
+
throw new SyncError4("Cannot start sync engine: not in disconnected state", {
|
|
2378
|
+
currentState: this.state
|
|
2379
|
+
});
|
|
1199
2380
|
}
|
|
1200
2381
|
await this.outboundQueue.initialize();
|
|
1201
|
-
this.
|
|
2382
|
+
if (this.syncState) {
|
|
2383
|
+
this.lastAckedServerVector = await this.syncState.loadLastAckedServerVector();
|
|
2384
|
+
if (this.syncState.loadDeltaCursor) {
|
|
2385
|
+
this.resumeDeltaCursor = await this.syncState.loadDeltaCursor();
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
await this.reconcileOutboundFromOpLog();
|
|
2389
|
+
await this.refreshPendingCount();
|
|
2390
|
+
this.transport.onMessage((msg) => this.enqueueMessage(msg));
|
|
1202
2391
|
this.transport.onClose((reason) => this.handleTransportClose(reason));
|
|
1203
2392
|
this.transport.onError((err) => this.handleTransportError(err));
|
|
2393
|
+
if (this.schemaBlocked) {
|
|
2394
|
+
throw new SyncError4(
|
|
2395
|
+
"Sync is blocked due to schema version mismatch. Upgrade the app schema or align sync.schemaVersion with the server.",
|
|
2396
|
+
{ code: "SCHEMA_MISMATCH_BLOCKED" }
|
|
2397
|
+
);
|
|
2398
|
+
}
|
|
1204
2399
|
this.transitionTo("connecting");
|
|
1205
2400
|
try {
|
|
1206
2401
|
const authToken = this.config.auth ? (await this.config.auth()).token : void 0;
|
|
1207
2402
|
await this.transport.connect(this.config.url, { authToken });
|
|
1208
2403
|
this.transitionTo("handshaking");
|
|
1209
2404
|
const localVector = this.store.getVersionVector();
|
|
2405
|
+
const activeQuerySubsets = this.getActiveQuerySubsets();
|
|
1210
2406
|
const handshake = {
|
|
1211
2407
|
type: "handshake",
|
|
1212
2408
|
messageId: generateMessageId(),
|
|
@@ -1214,7 +2410,10 @@ var SyncEngine = class {
|
|
|
1214
2410
|
versionVector: versionVectorToWire(localVector),
|
|
1215
2411
|
schemaVersion: this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION,
|
|
1216
2412
|
authToken,
|
|
1217
|
-
supportedWireFormats: ["json", "protobuf"]
|
|
2413
|
+
supportedWireFormats: ["json", "protobuf"],
|
|
2414
|
+
...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
|
|
2415
|
+
...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
|
|
2416
|
+
...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {}
|
|
1218
2417
|
};
|
|
1219
2418
|
this.transport.send(handshake);
|
|
1220
2419
|
} catch (err) {
|
|
@@ -1227,6 +2426,7 @@ var SyncEngine = class {
|
|
|
1227
2426
|
*/
|
|
1228
2427
|
async stop() {
|
|
1229
2428
|
if (this.state === "disconnected") return;
|
|
2429
|
+
this.awarenessManager.stopCleanupTimer();
|
|
1230
2430
|
if (this.currentBatch) {
|
|
1231
2431
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
1232
2432
|
this.currentBatch = null;
|
|
@@ -1245,9 +2445,21 @@ var SyncEngine = class {
|
|
|
1245
2445
|
/**
|
|
1246
2446
|
* Push a local operation to the outbound queue.
|
|
1247
2447
|
* If streaming, flushes immediately.
|
|
2448
|
+
*
|
|
2449
|
+
* Operations outside the configured sync scope are silently skipped
|
|
2450
|
+
* because they should remain local-only and not be sent to the server.
|
|
1248
2451
|
*/
|
|
1249
2452
|
async pushOperation(op) {
|
|
2453
|
+
if (!this.operationAllowedForSync(op)) {
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
if (this.syncState) {
|
|
2457
|
+
this.cachedUnsyncedCount++;
|
|
2458
|
+
}
|
|
1250
2459
|
await this.outboundQueue.enqueue(op);
|
|
2460
|
+
if (this.syncState) {
|
|
2461
|
+
await this.refreshPendingCount();
|
|
2462
|
+
}
|
|
1251
2463
|
if (this.state === "streaming") {
|
|
1252
2464
|
this.flushQueue();
|
|
1253
2465
|
}
|
|
@@ -1265,48 +2477,181 @@ var SyncEngine = class {
|
|
|
1265
2477
|
* Get the current developer-facing sync status.
|
|
1266
2478
|
*/
|
|
1267
2479
|
getStatus() {
|
|
1268
|
-
const pendingOperations = this.outboundQueue.totalPending;
|
|
2480
|
+
const pendingOperations = this.syncState ? this.cachedUnsyncedCount : this.outboundQueue.totalPending;
|
|
2481
|
+
const base = {
|
|
2482
|
+
pendingOperations,
|
|
2483
|
+
lastSyncedAt: this.lastSyncedAt,
|
|
2484
|
+
lastSuccessfulPush: this.lastSuccessfulPush,
|
|
2485
|
+
lastSuccessfulPull: this.lastSuccessfulPull,
|
|
2486
|
+
conflicts: this.conflictCount
|
|
2487
|
+
};
|
|
1269
2488
|
switch (this.state) {
|
|
1270
2489
|
case "disconnected":
|
|
1271
|
-
return { status: "offline"
|
|
2490
|
+
return { ...base, status: "offline" };
|
|
1272
2491
|
case "connecting":
|
|
1273
2492
|
case "handshaking":
|
|
1274
2493
|
case "syncing":
|
|
1275
|
-
return {
|
|
1276
|
-
status: this.reconnecting ? "offline" : "syncing",
|
|
1277
|
-
pendingOperations,
|
|
1278
|
-
lastSyncedAt: this.lastSyncedAt
|
|
1279
|
-
};
|
|
2494
|
+
return { ...base, status: this.reconnecting ? "offline" : "syncing" };
|
|
1280
2495
|
case "streaming":
|
|
1281
|
-
return {
|
|
1282
|
-
status: pendingOperations > 0 ? "syncing" : "synced",
|
|
1283
|
-
pendingOperations,
|
|
1284
|
-
lastSyncedAt: this.lastSyncedAt
|
|
1285
|
-
};
|
|
2496
|
+
return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
|
|
1286
2497
|
case "error":
|
|
1287
|
-
return { status: "
|
|
2498
|
+
return { ...base, status: this.schemaBlocked ? "schema-mismatch" : "error" };
|
|
1288
2499
|
}
|
|
1289
2500
|
}
|
|
1290
2501
|
/**
|
|
1291
|
-
*
|
|
2502
|
+
* True when the server rejected the client's schema version at handshake.
|
|
2503
|
+
* Sync stays blocked until the app schema is upgraded or sync config changes.
|
|
2504
|
+
*/
|
|
2505
|
+
isSchemaBlocked() {
|
|
2506
|
+
return this.schemaBlocked;
|
|
2507
|
+
}
|
|
2508
|
+
/**
|
|
2509
|
+
* Clears schema-mismatch block after upgrading the local schema / sync config.
|
|
2510
|
+
* Moves the engine back to `disconnected` so `start()` can run again.
|
|
2511
|
+
*/
|
|
2512
|
+
clearSchemaBlock() {
|
|
2513
|
+
this.schemaBlocked = false;
|
|
2514
|
+
if (this.state === "error") {
|
|
2515
|
+
this.transitionTo("disconnected");
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
/**
|
|
2519
|
+
* Record a merge conflict. Called by the merge-aware sync store
|
|
2520
|
+
* to increment the conflict counter for status reporting.
|
|
2521
|
+
*/
|
|
2522
|
+
recordConflict() {
|
|
2523
|
+
this.conflictCount++;
|
|
2524
|
+
}
|
|
2525
|
+
/**
|
|
2526
|
+
* Count of local operations not yet covered by the last acked server vector (op-log source of truth).
|
|
2527
|
+
*/
|
|
2528
|
+
async getUnsyncedOperationCount() {
|
|
2529
|
+
await this.refreshPendingCount();
|
|
2530
|
+
return this.getStatus().pendingOperations;
|
|
2531
|
+
}
|
|
2532
|
+
emitApplyFailure(op, result, overrides) {
|
|
2533
|
+
const reason = defaultApplyFailureReason(result, overrides);
|
|
2534
|
+
this.emitter?.emit({
|
|
2535
|
+
type: "sync:apply-failed",
|
|
2536
|
+
operationId: op.id,
|
|
2537
|
+
collection: op.collection,
|
|
2538
|
+
recordId: op.recordId,
|
|
2539
|
+
code: reason.code,
|
|
2540
|
+
message: reason.message,
|
|
2541
|
+
retriable: reason.retriable
|
|
2542
|
+
});
|
|
2543
|
+
}
|
|
2544
|
+
/**
|
|
2545
|
+
* Force an immediate reconnection attempt. If the engine is disconnected
|
|
2546
|
+
* or in error state, restarts the sync. If already connected, no-op.
|
|
2547
|
+
*/
|
|
2548
|
+
async retryNow() {
|
|
2549
|
+
if (this.schemaBlocked) return;
|
|
2550
|
+
if (this.state === "disconnected" || this.state === "error") {
|
|
2551
|
+
this.reconnecting = false;
|
|
2552
|
+
await this.start();
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
/**
|
|
2556
|
+
* Export a diagnostics snapshot for debugging and support tickets.
|
|
2557
|
+
* Contains connection state, timing info, and queue metrics.
|
|
2558
|
+
*/
|
|
2559
|
+
exportDiagnostics() {
|
|
2560
|
+
return {
|
|
2561
|
+
state: this.state,
|
|
2562
|
+
status: this.getStatus(),
|
|
2563
|
+
nodeId: this.store.getNodeId(),
|
|
2564
|
+
url: this.config.url,
|
|
2565
|
+
schemaVersion: this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION,
|
|
2566
|
+
lastSyncedAt: this.lastSyncedAt,
|
|
2567
|
+
lastSuccessfulPush: this.lastSuccessfulPush,
|
|
2568
|
+
lastSuccessfulPull: this.lastSuccessfulPull,
|
|
2569
|
+
conflicts: this.conflictCount,
|
|
2570
|
+
pendingOperations: this.outboundQueue.totalPending,
|
|
2571
|
+
hasInFlightBatch: this.currentBatch !== null,
|
|
2572
|
+
reconnecting: this.reconnecting,
|
|
2573
|
+
timestamp: Date.now()
|
|
2574
|
+
};
|
|
2575
|
+
}
|
|
2576
|
+
/**
|
|
2577
|
+
* Get the current internal state (for testing).
|
|
2578
|
+
*/
|
|
2579
|
+
getState() {
|
|
2580
|
+
return this.state;
|
|
2581
|
+
}
|
|
2582
|
+
/**
|
|
2583
|
+
* Get the outbound queue (for testing).
|
|
2584
|
+
*/
|
|
2585
|
+
getOutboundQueue() {
|
|
2586
|
+
return this.outboundQueue;
|
|
2587
|
+
}
|
|
2588
|
+
/**
|
|
2589
|
+
* Update the sync scope map. Takes effect on the next connection attempt.
|
|
2590
|
+
*
|
|
2591
|
+
* When the scope changes (e.g., user switches organization), call this method
|
|
2592
|
+
* then reconnect. The new scope will be sent in the handshake, and the server
|
|
2593
|
+
* will send back data matching the new scope.
|
|
2594
|
+
*
|
|
2595
|
+
* Data that no longer matches the new scope is NOT deleted locally.
|
|
2596
|
+
* It simply stops being synced.
|
|
2597
|
+
*
|
|
2598
|
+
* @param scopeMap - New per-collection scope filters, or undefined to remove scope
|
|
2599
|
+
*/
|
|
2600
|
+
updateScope(scopeMap) {
|
|
2601
|
+
this.activeScope = scopeMap;
|
|
2602
|
+
this.config.scopeMap = scopeMap;
|
|
2603
|
+
}
|
|
2604
|
+
/**
|
|
2605
|
+
* Get the currently active scope map. Returns undefined if no scope is configured.
|
|
1292
2606
|
*/
|
|
1293
|
-
|
|
1294
|
-
return this.
|
|
2607
|
+
getActiveScope() {
|
|
2608
|
+
return this.activeScope;
|
|
1295
2609
|
}
|
|
1296
2610
|
/**
|
|
1297
|
-
*
|
|
2611
|
+
* Register a live query subset that narrows synced data for a collection.
|
|
2612
|
+
* Takes effect on the next connection; reconnects when already connected.
|
|
1298
2613
|
*/
|
|
1299
|
-
|
|
1300
|
-
|
|
2614
|
+
registerQuerySubset(subset) {
|
|
2615
|
+
const id = `query-${nextQuerySubsetId++}`;
|
|
2616
|
+
this.querySubsets.set(id, subset);
|
|
2617
|
+
this.scheduleQuerySubsetReconnect();
|
|
2618
|
+
return () => {
|
|
2619
|
+
this.querySubsets.delete(id);
|
|
2620
|
+
this.scheduleQuerySubsetReconnect();
|
|
2621
|
+
};
|
|
2622
|
+
}
|
|
2623
|
+
/**
|
|
2624
|
+
* Returns deduplicated active query subsets from registered subscriptions.
|
|
2625
|
+
*/
|
|
2626
|
+
getActiveQuerySubsets() {
|
|
2627
|
+
return dedupeQuerySubsets([...this.querySubsets.values()]);
|
|
2628
|
+
}
|
|
2629
|
+
/**
|
|
2630
|
+
* Get the awareness manager for collaborative presence.
|
|
2631
|
+
* Use this to set local presence, observe remote collaborators,
|
|
2632
|
+
* and track cursor positions in richtext fields.
|
|
2633
|
+
*/
|
|
2634
|
+
getAwarenessManager() {
|
|
2635
|
+
return this.awarenessManager;
|
|
2636
|
+
}
|
|
2637
|
+
/**
|
|
2638
|
+
* Optional side channel for incremental Yjs updates on large richtext fields.
|
|
2639
|
+
*/
|
|
2640
|
+
getRichtextDocChannel() {
|
|
2641
|
+
return this.richtextDocChannel;
|
|
1301
2642
|
}
|
|
1302
2643
|
// --- Private methods ---
|
|
1303
|
-
|
|
2644
|
+
messageChain = Promise.resolve();
|
|
2645
|
+
enqueueMessage(message) {
|
|
2646
|
+
this.messageChain = this.messageChain.then(() => this.handleMessageAsync(message)).catch((error) => this.handleMessageFailure(error));
|
|
2647
|
+
}
|
|
2648
|
+
async handleMessageAsync(message) {
|
|
1304
2649
|
switch (message.type) {
|
|
1305
2650
|
case "handshake-response":
|
|
1306
2651
|
this.handleHandshakeResponse(message);
|
|
1307
2652
|
break;
|
|
1308
2653
|
case "operation-batch":
|
|
1309
|
-
this.handleOperationBatch(message);
|
|
2654
|
+
await this.handleOperationBatch(message);
|
|
1310
2655
|
break;
|
|
1311
2656
|
case "acknowledgment":
|
|
1312
2657
|
this.handleAcknowledgment(message);
|
|
@@ -1314,58 +2659,114 @@ var SyncEngine = class {
|
|
|
1314
2659
|
case "error":
|
|
1315
2660
|
this.handleError(message);
|
|
1316
2661
|
break;
|
|
2662
|
+
case "awareness-update":
|
|
2663
|
+
this.handleAwarenessUpdate(message);
|
|
2664
|
+
break;
|
|
2665
|
+
case "yjs-doc-update":
|
|
2666
|
+
this.richtextDocChannel.deliver(message);
|
|
2667
|
+
break;
|
|
1317
2668
|
}
|
|
1318
2669
|
}
|
|
2670
|
+
handleMessageFailure(error) {
|
|
2671
|
+
const reason = error instanceof Error ? error.message : "Message handling failed";
|
|
2672
|
+
this.handleTransportClose(reason);
|
|
2673
|
+
}
|
|
1319
2674
|
handleHandshakeResponse(msg) {
|
|
1320
2675
|
if (this.state !== "handshaking") return;
|
|
1321
2676
|
if (!msg.accepted) {
|
|
2677
|
+
const reason = msg.rejectReason ?? "Handshake rejected";
|
|
2678
|
+
if (isSchemaMismatchReject(msg.rejectReason)) {
|
|
2679
|
+
this.schemaBlocked = true;
|
|
2680
|
+
const supportedMin = msg.supportedSchemaMin ?? msg.schemaVersion;
|
|
2681
|
+
const supportedMax = msg.supportedSchemaMax ?? msg.schemaVersion;
|
|
2682
|
+
this.emitter?.emit({
|
|
2683
|
+
type: "sync:schema-mismatch",
|
|
2684
|
+
clientSchemaVersion: this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION,
|
|
2685
|
+
serverSchemaVersion: msg.schemaVersion,
|
|
2686
|
+
supportedMin,
|
|
2687
|
+
supportedMax,
|
|
2688
|
+
reason
|
|
2689
|
+
});
|
|
2690
|
+
this.metricsCollector.updateStatus("error");
|
|
2691
|
+
this.transitionTo("error");
|
|
2692
|
+
void this.transport.disconnect();
|
|
2693
|
+
return;
|
|
2694
|
+
}
|
|
1322
2695
|
this.transitionTo("error");
|
|
1323
2696
|
this.emitter?.emit({
|
|
1324
2697
|
type: "sync:disconnected",
|
|
1325
|
-
reason
|
|
2698
|
+
reason
|
|
1326
2699
|
});
|
|
1327
2700
|
this.transitionTo("disconnected");
|
|
1328
2701
|
return;
|
|
1329
2702
|
}
|
|
1330
2703
|
this.remoteVector = wireToVersionVector(msg.versionVector);
|
|
2704
|
+
void this.persistLastAckedServerVector(this.remoteVector);
|
|
1331
2705
|
if (msg.selectedWireFormat) {
|
|
1332
2706
|
this.setSerializerWireFormat(msg.selectedWireFormat);
|
|
1333
2707
|
}
|
|
2708
|
+
if (msg.acceptedScope) {
|
|
2709
|
+
this.activeScope = msg.acceptedScope;
|
|
2710
|
+
}
|
|
1334
2711
|
this.emitter?.emit({ type: "sync:connected", nodeId: this.store.getNodeId() });
|
|
2712
|
+
this.metricsCollector.recordConnected();
|
|
2713
|
+
this.metricsCollector.updateStatus("syncing");
|
|
2714
|
+
this.metricsCollector.recordSyncStarted();
|
|
1335
2715
|
this.transitionTo("syncing");
|
|
1336
2716
|
this.deltaBatchesReceived = 0;
|
|
1337
2717
|
this.deltaReceiveComplete = false;
|
|
1338
2718
|
this.deltaSendComplete = false;
|
|
2719
|
+
this.deltaSentOpIds = [];
|
|
2720
|
+
this.pendingDeltaBatchAcks.clear();
|
|
2721
|
+
this.initialSyncTotalBatches = 0;
|
|
1339
2722
|
this.sendDelta();
|
|
1340
2723
|
}
|
|
1341
2724
|
async sendDelta() {
|
|
1342
2725
|
const localVector = this.store.getVersionVector();
|
|
1343
|
-
const
|
|
2726
|
+
const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
|
|
2727
|
+
const missingOps = allMissingOps.filter((op) => this.operationAllowedForSync(op));
|
|
2728
|
+
this.deltaSentOpIds = missingOps.map((op) => op.id);
|
|
1344
2729
|
if (missingOps.length === 0) {
|
|
2730
|
+
const messageId = generateMessageId();
|
|
2731
|
+
if (this.config.strictHandshake) {
|
|
2732
|
+
this.pendingDeltaBatchAcks.add(messageId);
|
|
2733
|
+
}
|
|
1345
2734
|
const emptyBatch = {
|
|
1346
2735
|
type: "operation-batch",
|
|
1347
|
-
messageId
|
|
2736
|
+
messageId,
|
|
1348
2737
|
operations: [],
|
|
1349
2738
|
isFinal: true,
|
|
1350
|
-
batchIndex: 0
|
|
2739
|
+
batchIndex: 0,
|
|
2740
|
+
totalBatches: 1
|
|
1351
2741
|
};
|
|
1352
2742
|
this.transport.send(emptyBatch);
|
|
1353
|
-
this.
|
|
1354
|
-
|
|
2743
|
+
if (!this.config.strictHandshake) {
|
|
2744
|
+
this.deltaSendComplete = true;
|
|
2745
|
+
void this.checkDeltaComplete();
|
|
2746
|
+
}
|
|
1355
2747
|
return;
|
|
1356
2748
|
}
|
|
1357
2749
|
const sorted = topologicalSort2(missingOps);
|
|
1358
2750
|
const totalBatches = Math.ceil(sorted.length / this.batchSize);
|
|
2751
|
+
this.initialSyncTotalBatches = Math.max(this.initialSyncTotalBatches, totalBatches);
|
|
1359
2752
|
for (let i = 0; i < totalBatches; i++) {
|
|
1360
2753
|
const start = i * this.batchSize;
|
|
1361
2754
|
const batchOps = sorted.slice(start, start + this.batchSize);
|
|
1362
|
-
const
|
|
2755
|
+
const batchCursor = createDeltaCursorFromBatch(batchOps, i);
|
|
2756
|
+
const opsToSerialize = this.encryptor ? await this.encryptor.encryptBatch(batchOps) : batchOps;
|
|
2757
|
+
const serializedOps = opsToSerialize.map((op) => this.serializer.encodeOperation(op));
|
|
2758
|
+
const messageId = generateMessageId();
|
|
2759
|
+
if (this.config.strictHandshake) {
|
|
2760
|
+
this.pendingDeltaBatchAcks.add(messageId);
|
|
2761
|
+
}
|
|
1363
2762
|
const batchMsg = {
|
|
1364
2763
|
type: "operation-batch",
|
|
1365
|
-
messageId
|
|
2764
|
+
messageId,
|
|
1366
2765
|
operations: serializedOps,
|
|
1367
2766
|
isFinal: i === totalBatches - 1,
|
|
1368
|
-
batchIndex: i
|
|
2767
|
+
batchIndex: i,
|
|
2768
|
+
totalBatches,
|
|
2769
|
+
...batchCursor ? { cursor: encodeDeltaCursor(batchCursor) } : {}
|
|
1369
2770
|
};
|
|
1370
2771
|
this.transport.send(batchMsg);
|
|
1371
2772
|
this.emitter?.emit({
|
|
@@ -1374,8 +2775,17 @@ var SyncEngine = class {
|
|
|
1374
2775
|
batchSize: batchOps.length
|
|
1375
2776
|
});
|
|
1376
2777
|
}
|
|
2778
|
+
if (!this.config.strictHandshake) {
|
|
2779
|
+
this.deltaSendComplete = true;
|
|
2780
|
+
void this.checkDeltaComplete();
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
markDeltaSendCompleteIfReady() {
|
|
2784
|
+
if (this.config.strictHandshake && this.pendingDeltaBatchAcks.size > 0) {
|
|
2785
|
+
return;
|
|
2786
|
+
}
|
|
1377
2787
|
this.deltaSendComplete = true;
|
|
1378
|
-
this.checkDeltaComplete();
|
|
2788
|
+
void this.checkDeltaComplete();
|
|
1379
2789
|
}
|
|
1380
2790
|
async collectDelta(localVector, remoteVector) {
|
|
1381
2791
|
const missing = [];
|
|
@@ -1389,15 +2799,45 @@ var SyncEngine = class {
|
|
|
1389
2799
|
return missing;
|
|
1390
2800
|
}
|
|
1391
2801
|
async handleOperationBatch(msg) {
|
|
1392
|
-
const
|
|
1393
|
-
|
|
1394
|
-
|
|
2802
|
+
const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
2803
|
+
const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
|
|
2804
|
+
const inScopeOps = operations.filter((op) => this.operationAllowedForSync(op));
|
|
2805
|
+
const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
2806
|
+
const transforms = this.config.operationTransforms ?? [];
|
|
2807
|
+
for (const op of inScopeOps) {
|
|
2808
|
+
const transformed = transforms.length > 0 ? applyOperationTransforms(op, targetSchemaVersion, transforms) : op;
|
|
2809
|
+
if (transformed === null) {
|
|
2810
|
+
continue;
|
|
2811
|
+
}
|
|
2812
|
+
try {
|
|
2813
|
+
const result = await this.store.applyRemoteOperation(transformed);
|
|
2814
|
+
if (result === "skipped" || result === "rejected" || result === "deferred") {
|
|
2815
|
+
this.emitApplyFailure(transformed, result);
|
|
2816
|
+
}
|
|
2817
|
+
} catch (error) {
|
|
2818
|
+
if (error instanceof ClockDriftError) {
|
|
2819
|
+
this.emitApplyFailure(transformed, "rejected", {
|
|
2820
|
+
code: APPLY_FAILURE_CODES.CLOCK_DRIFT,
|
|
2821
|
+
message: error.message,
|
|
2822
|
+
retriable: false
|
|
2823
|
+
});
|
|
2824
|
+
continue;
|
|
2825
|
+
}
|
|
2826
|
+
const message = error instanceof Error ? error.message : "Apply failed";
|
|
2827
|
+
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;
|
|
2828
|
+
this.emitApplyFailure(transformed, "rejected", {
|
|
2829
|
+
code,
|
|
2830
|
+
message,
|
|
2831
|
+
retriable: code !== APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY
|
|
2832
|
+
});
|
|
2833
|
+
}
|
|
1395
2834
|
}
|
|
1396
|
-
if (
|
|
2835
|
+
if (inScopeOps.length > 0) {
|
|
2836
|
+
this.lastSuccessfulPull = Date.now();
|
|
1397
2837
|
this.emitter?.emit({
|
|
1398
2838
|
type: "sync:received",
|
|
1399
|
-
operations,
|
|
1400
|
-
batchSize:
|
|
2839
|
+
operations: inScopeOps,
|
|
2840
|
+
batchSize: inScopeOps.length
|
|
1401
2841
|
});
|
|
1402
2842
|
}
|
|
1403
2843
|
const lastOp = operations[operations.length - 1];
|
|
@@ -1414,18 +2854,43 @@ var SyncEngine = class {
|
|
|
1414
2854
|
});
|
|
1415
2855
|
if (this.state === "syncing") {
|
|
1416
2856
|
this.deltaBatchesReceived++;
|
|
2857
|
+
const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
|
|
2858
|
+
if (msg.totalBatches !== void 0) {
|
|
2859
|
+
this.initialSyncTotalBatches = msg.totalBatches;
|
|
2860
|
+
}
|
|
2861
|
+
this.metricsCollector.updateInitialSyncProgress(this.deltaBatchesReceived, totalBatches);
|
|
2862
|
+
const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
|
|
2863
|
+
inScopeOps.length > 0 ? inScopeOps : operations,
|
|
2864
|
+
msg.batchIndex
|
|
2865
|
+
);
|
|
2866
|
+
if (cursorFromBatch) {
|
|
2867
|
+
this.resumeDeltaCursor = cursorFromBatch;
|
|
2868
|
+
await this.persistDeltaCursor(cursorFromBatch);
|
|
2869
|
+
}
|
|
1417
2870
|
if (msg.isFinal) {
|
|
1418
2871
|
this.deltaReceiveComplete = true;
|
|
1419
|
-
this.
|
|
2872
|
+
this.resumeDeltaCursor = null;
|
|
2873
|
+
await this.persistDeltaCursor(null);
|
|
2874
|
+
this.metricsCollector.recordSyncCompleted();
|
|
2875
|
+
void this.checkDeltaComplete();
|
|
1420
2876
|
}
|
|
1421
2877
|
}
|
|
1422
2878
|
}
|
|
1423
2879
|
handleAcknowledgment(msg) {
|
|
2880
|
+
if (this.state === "syncing" && this.config.strictHandshake) {
|
|
2881
|
+
this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
|
|
2882
|
+
this.markDeltaSendCompleteIfReady();
|
|
2883
|
+
}
|
|
1424
2884
|
if (this.currentBatch) {
|
|
1425
2885
|
this.outboundQueue.acknowledge(this.currentBatch.batchId);
|
|
1426
2886
|
this.currentBatch = null;
|
|
1427
|
-
|
|
2887
|
+
const now = Date.now();
|
|
2888
|
+
this.lastSyncedAt = now;
|
|
2889
|
+
this.lastSuccessfulPush = now;
|
|
1428
2890
|
}
|
|
2891
|
+
void this.advanceLastAckedForLocalNode(msg.lastSequenceNumber).then(
|
|
2892
|
+
() => this.refreshPendingCount()
|
|
2893
|
+
);
|
|
1429
2894
|
if (this.state === "streaming" && this.outboundQueue.hasOperations) {
|
|
1430
2895
|
this.flushQueue();
|
|
1431
2896
|
}
|
|
@@ -1438,13 +2903,107 @@ var SyncEngine = class {
|
|
|
1438
2903
|
this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
|
|
1439
2904
|
this.transitionTo("disconnected");
|
|
1440
2905
|
}
|
|
1441
|
-
checkDeltaComplete() {
|
|
1442
|
-
if (this.deltaSendComplete
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
2906
|
+
async checkDeltaComplete() {
|
|
2907
|
+
if (!this.deltaSendComplete || !this.deltaReceiveComplete) {
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
if (this.state !== "syncing") {
|
|
2911
|
+
return;
|
|
2912
|
+
}
|
|
2913
|
+
this.lastSyncedAt = Date.now();
|
|
2914
|
+
this.transitionTo("streaming");
|
|
2915
|
+
this.awarenessManager.startCleanupTimer();
|
|
2916
|
+
const localState = this.awarenessManager.getLocalState();
|
|
2917
|
+
if (localState) {
|
|
2918
|
+
this.awarenessManager.setLocalState(localState);
|
|
2919
|
+
}
|
|
2920
|
+
if (this.deltaSentOpIds.length > 0) {
|
|
2921
|
+
await this.outboundQueue.removeByIds(this.deltaSentOpIds);
|
|
2922
|
+
this.deltaSentOpIds = [];
|
|
2923
|
+
}
|
|
2924
|
+
if (this.syncState) {
|
|
2925
|
+
const localNodeId = this.store.getNodeId();
|
|
2926
|
+
const localSeq = this.store.getVersionVector().get(localNodeId) ?? 0;
|
|
2927
|
+
if (localSeq > 0) {
|
|
2928
|
+
await this.advanceLastAckedForLocalNode(localSeq);
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
if (this.outboundQueue.hasOperations) {
|
|
2932
|
+
this.flushQueue();
|
|
2933
|
+
}
|
|
2934
|
+
await this.refreshPendingCount();
|
|
2935
|
+
}
|
|
2936
|
+
/**
|
|
2937
|
+
* Effective server vector: persisted last-ack merged with live handshake remote vector.
|
|
2938
|
+
*/
|
|
2939
|
+
getEffectiveServerVector() {
|
|
2940
|
+
if (!this.syncState) {
|
|
2941
|
+
return this.remoteVector;
|
|
2942
|
+
}
|
|
2943
|
+
return this.syncState.mergeServerVectors(this.lastAckedServerVector, this.remoteVector);
|
|
2944
|
+
}
|
|
2945
|
+
async persistLastAckedServerVector(vector) {
|
|
2946
|
+
if (!this.syncState) {
|
|
2947
|
+
return;
|
|
2948
|
+
}
|
|
2949
|
+
this.lastAckedServerVector = this.syncState.mergeServerVectors(
|
|
2950
|
+
this.lastAckedServerVector,
|
|
2951
|
+
vector
|
|
2952
|
+
);
|
|
2953
|
+
await this.syncState.saveLastAckedServerVector(this.lastAckedServerVector);
|
|
2954
|
+
}
|
|
2955
|
+
async advanceLastAckedForLocalNode(lastSequenceNumber) {
|
|
2956
|
+
if (!this.syncState || lastSequenceNumber <= 0) {
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
const nodeId = this.store.getNodeId();
|
|
2960
|
+
const merged = new Map(this.lastAckedServerVector);
|
|
2961
|
+
merged.set(nodeId, Math.max(merged.get(nodeId) ?? 0, lastSequenceNumber));
|
|
2962
|
+
this.lastAckedServerVector = merged;
|
|
2963
|
+
await this.syncState.saveLastAckedServerVector(merged);
|
|
2964
|
+
}
|
|
2965
|
+
/**
|
|
2966
|
+
* Refresh cached unsynced count from op log vs effective server vector.
|
|
2967
|
+
*/
|
|
2968
|
+
async refreshPendingCount() {
|
|
2969
|
+
if (!this.syncState) {
|
|
2970
|
+
this.cachedUnsyncedCount = this.outboundQueue.totalPending;
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
this.cachedUnsyncedCount = await this.syncState.countUnsyncedOperations(
|
|
2974
|
+
this.getEffectiveServerVector()
|
|
2975
|
+
);
|
|
2976
|
+
}
|
|
2977
|
+
/**
|
|
2978
|
+
* Returns operations the server has not yet acknowledged (op-log source of truth).
|
|
2979
|
+
*/
|
|
2980
|
+
async getPendingSyncOperations() {
|
|
2981
|
+
if (!this.syncState) {
|
|
2982
|
+
return this.outboundQueue.peek(Number.MAX_SAFE_INTEGER);
|
|
2983
|
+
}
|
|
2984
|
+
return this.syncState.getUnsyncedOperations(this.getEffectiveServerVector());
|
|
2985
|
+
}
|
|
2986
|
+
/**
|
|
2987
|
+
* Hydrate the outbound queue from unsynced ops in the op log (deduped by operation id).
|
|
2988
|
+
*/
|
|
2989
|
+
async reconcileOutboundFromOpLog() {
|
|
2990
|
+
if (this.syncState) {
|
|
2991
|
+
const unsynced = await this.syncState.getUnsyncedOperations(this.getEffectiveServerVector());
|
|
2992
|
+
for (const op of unsynced) {
|
|
2993
|
+
await this.outboundQueue.enqueue(op);
|
|
1447
2994
|
}
|
|
2995
|
+
return;
|
|
2996
|
+
}
|
|
2997
|
+
const localNodeId = this.store.getNodeId();
|
|
2998
|
+
const localVector = this.store.getVersionVector();
|
|
2999
|
+
const localSeq = localVector.get(localNodeId) ?? 0;
|
|
3000
|
+
if (localSeq === 0) {
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
3003
|
+
const ops = await this.store.getOperationRange(localNodeId, 1, localSeq);
|
|
3004
|
+
const inScope = ops.filter((op) => this.operationAllowedForSync(op));
|
|
3005
|
+
for (const op of inScope) {
|
|
3006
|
+
await this.outboundQueue.enqueue(op);
|
|
1448
3007
|
}
|
|
1449
3008
|
}
|
|
1450
3009
|
flushQueue() {
|
|
@@ -1453,26 +3012,66 @@ var SyncEngine = class {
|
|
|
1453
3012
|
const batch = this.outboundQueue.takeBatch(this.batchSize);
|
|
1454
3013
|
if (!batch) return;
|
|
1455
3014
|
this.currentBatch = batch;
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
3015
|
+
if (this.encryptor) {
|
|
3016
|
+
this.encryptor.encryptBatch(batch.operations).then(
|
|
3017
|
+
(encrypted) => {
|
|
3018
|
+
const serializedOps = encrypted.map((op) => this.serializer.encodeOperation(op));
|
|
3019
|
+
const batchMsg = {
|
|
3020
|
+
type: "operation-batch",
|
|
3021
|
+
messageId: generateMessageId(),
|
|
3022
|
+
operations: serializedOps,
|
|
3023
|
+
isFinal: true,
|
|
3024
|
+
batchIndex: 0
|
|
3025
|
+
};
|
|
3026
|
+
this.transport.send(batchMsg);
|
|
3027
|
+
this.emitter?.emit({
|
|
3028
|
+
type: "sync:sent",
|
|
3029
|
+
operations: batch.operations,
|
|
3030
|
+
batchSize: batch.operations.length
|
|
3031
|
+
});
|
|
3032
|
+
},
|
|
3033
|
+
(err) => {
|
|
3034
|
+
this.outboundQueue.returnBatch(batch.batchId);
|
|
3035
|
+
this.currentBatch = null;
|
|
3036
|
+
this.emitter?.emit({
|
|
3037
|
+
type: "sync:disconnected",
|
|
3038
|
+
reason: err instanceof Error ? err.message : "Encryption failed"
|
|
3039
|
+
});
|
|
3040
|
+
}
|
|
3041
|
+
);
|
|
3042
|
+
} else {
|
|
3043
|
+
const serializedOps = batch.operations.map((op) => this.serializer.encodeOperation(op));
|
|
3044
|
+
const batchMsg = {
|
|
3045
|
+
type: "operation-batch",
|
|
3046
|
+
messageId: generateMessageId(),
|
|
3047
|
+
operations: serializedOps,
|
|
3048
|
+
isFinal: true,
|
|
3049
|
+
batchIndex: 0
|
|
3050
|
+
};
|
|
3051
|
+
this.transport.send(batchMsg);
|
|
3052
|
+
this.emitter?.emit({
|
|
3053
|
+
type: "sync:sent",
|
|
3054
|
+
operations: batch.operations,
|
|
3055
|
+
batchSize: batch.operations.length
|
|
3056
|
+
});
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
handleAwarenessUpdate(msg) {
|
|
3060
|
+
const awarenessMessage = {
|
|
3061
|
+
type: "awareness",
|
|
3062
|
+
clientId: msg.clientId,
|
|
3063
|
+
states: wireToAwarenessStates(msg.states)
|
|
1463
3064
|
};
|
|
1464
|
-
this.
|
|
1465
|
-
this.emitter?.emit({
|
|
1466
|
-
type: "sync:sent",
|
|
1467
|
-
operations: batch.operations,
|
|
1468
|
-
batchSize: batch.operations.length
|
|
1469
|
-
});
|
|
3065
|
+
this.awarenessManager.handleRemoteMessage(awarenessMessage);
|
|
1470
3066
|
}
|
|
1471
3067
|
handleTransportClose(reason) {
|
|
1472
3068
|
if (this.currentBatch) {
|
|
1473
3069
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
1474
3070
|
this.currentBatch = null;
|
|
1475
3071
|
}
|
|
3072
|
+
if (this.schemaBlocked) {
|
|
3073
|
+
return;
|
|
3074
|
+
}
|
|
1476
3075
|
if (this.state !== "disconnected") {
|
|
1477
3076
|
this.emitter?.emit({ type: "sync:disconnected", reason });
|
|
1478
3077
|
this.transitionTo("disconnected");
|
|
@@ -1500,7 +3099,68 @@ var SyncEngine = class {
|
|
|
1500
3099
|
this.serializer.setWireFormat(format);
|
|
1501
3100
|
}
|
|
1502
3101
|
}
|
|
3102
|
+
operationAllowedForSync(op) {
|
|
3103
|
+
if (!operationMatchesScope(op, this.activeScope)) {
|
|
3104
|
+
return false;
|
|
3105
|
+
}
|
|
3106
|
+
return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets());
|
|
3107
|
+
}
|
|
3108
|
+
async persistDeltaCursor(cursor) {
|
|
3109
|
+
if (!this.syncState?.saveDeltaCursor) {
|
|
3110
|
+
return;
|
|
3111
|
+
}
|
|
3112
|
+
await this.syncState.saveDeltaCursor(cursor);
|
|
3113
|
+
}
|
|
3114
|
+
scheduleQuerySubsetReconnect() {
|
|
3115
|
+
if (this.querySubsetReconnectTimer) {
|
|
3116
|
+
clearTimeout(this.querySubsetReconnectTimer);
|
|
3117
|
+
}
|
|
3118
|
+
this.querySubsetReconnectTimer = setTimeout(() => {
|
|
3119
|
+
this.querySubsetReconnectTimer = null;
|
|
3120
|
+
if (this.state === "streaming" || this.state === "syncing" || this.state === "handshaking") {
|
|
3121
|
+
void this.reconnectForQuerySubsets();
|
|
3122
|
+
}
|
|
3123
|
+
}, 500);
|
|
3124
|
+
}
|
|
3125
|
+
async reconnectForQuerySubsets() {
|
|
3126
|
+
await this.stop();
|
|
3127
|
+
await this.start();
|
|
3128
|
+
}
|
|
1503
3129
|
};
|
|
3130
|
+
function awarenessStatesToWire(states) {
|
|
3131
|
+
const wire = {};
|
|
3132
|
+
for (const [clientId, state] of Object.entries(states)) {
|
|
3133
|
+
if (state === null) {
|
|
3134
|
+
wire[clientId] = null;
|
|
3135
|
+
} else {
|
|
3136
|
+
const wireState = {
|
|
3137
|
+
user: { ...state.user }
|
|
3138
|
+
};
|
|
3139
|
+
if (state.cursor) {
|
|
3140
|
+
wireState.cursor = { ...state.cursor };
|
|
3141
|
+
}
|
|
3142
|
+
wire[clientId] = wireState;
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
return wire;
|
|
3146
|
+
}
|
|
3147
|
+
function wireToAwarenessStates(wire) {
|
|
3148
|
+
const states = {};
|
|
3149
|
+
for (const [clientId, wireState] of Object.entries(wire)) {
|
|
3150
|
+
if (wireState === null) {
|
|
3151
|
+
states[Number(clientId)] = null;
|
|
3152
|
+
} else {
|
|
3153
|
+
const state = {
|
|
3154
|
+
user: { ...wireState.user }
|
|
3155
|
+
};
|
|
3156
|
+
if (wireState.cursor) {
|
|
3157
|
+
state.cursor = { ...wireState.cursor };
|
|
3158
|
+
}
|
|
3159
|
+
states[Number(clientId)] = state;
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
return states;
|
|
3163
|
+
}
|
|
1504
3164
|
|
|
1505
3165
|
// src/engine/connection-monitor.ts
|
|
1506
3166
|
var ConnectionMonitor = class {
|
|
@@ -1639,6 +3299,20 @@ var ReconnectionManager = class {
|
|
|
1639
3299
|
this.running = false;
|
|
1640
3300
|
}
|
|
1641
3301
|
}
|
|
3302
|
+
/**
|
|
3303
|
+
* Cancel the current backoff wait and proceed to the next reconnect attempt immediately.
|
|
3304
|
+
* No-op if not waiting.
|
|
3305
|
+
*/
|
|
3306
|
+
wake() {
|
|
3307
|
+
if (this.timer !== null) {
|
|
3308
|
+
clearTimeout(this.timer);
|
|
3309
|
+
this.timer = null;
|
|
3310
|
+
}
|
|
3311
|
+
if (this.waitResolve) {
|
|
3312
|
+
this.waitResolve();
|
|
3313
|
+
this.waitResolve = null;
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
1642
3316
|
/**
|
|
1643
3317
|
* Stop any pending reconnection attempt.
|
|
1644
3318
|
*/
|
|
@@ -1693,25 +3367,480 @@ var ReconnectionManager = class {
|
|
|
1693
3367
|
});
|
|
1694
3368
|
}
|
|
1695
3369
|
};
|
|
3370
|
+
|
|
3371
|
+
// src/encryption/sync-encryptor.ts
|
|
3372
|
+
import { SyncError as SyncError6 } from "@korajs/core";
|
|
3373
|
+
|
|
3374
|
+
// src/encryption/key-derivation.ts
|
|
3375
|
+
import { SyncError as SyncError5 } from "@korajs/core";
|
|
3376
|
+
var KeyDerivationError = class extends SyncError5 {
|
|
3377
|
+
constructor(message, context) {
|
|
3378
|
+
super(message, { ...context, errorType: "KEY_DERIVATION_ERROR" });
|
|
3379
|
+
this.name = "KeyDerivationError";
|
|
3380
|
+
}
|
|
3381
|
+
};
|
|
3382
|
+
var SALT_LENGTH = 32;
|
|
3383
|
+
var PBKDF2_ITERATIONS = 6e5;
|
|
3384
|
+
var DERIVED_KEY_LENGTH = 256;
|
|
3385
|
+
function assertCryptoAvailable() {
|
|
3386
|
+
if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
|
|
3387
|
+
throw new KeyDerivationError(
|
|
3388
|
+
"Web Crypto API (crypto.subtle) is not available in this environment. Sync encryption requires crypto.subtle, which is available in modern browsers and Node.js 20+."
|
|
3389
|
+
);
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
function generateSalt() {
|
|
3393
|
+
if (typeof globalThis.crypto === "undefined") {
|
|
3394
|
+
throw new KeyDerivationError(
|
|
3395
|
+
"Web Crypto API (crypto) is not available. Cannot generate random salt."
|
|
3396
|
+
);
|
|
3397
|
+
}
|
|
3398
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
3399
|
+
}
|
|
3400
|
+
async function deriveKey(passphrase, salt) {
|
|
3401
|
+
assertCryptoAvailable();
|
|
3402
|
+
if (passphrase.length === 0) {
|
|
3403
|
+
throw new KeyDerivationError(
|
|
3404
|
+
"Passphrase must not be empty. Provide a non-empty string for encryption key derivation."
|
|
3405
|
+
);
|
|
3406
|
+
}
|
|
3407
|
+
const usedSalt = salt ?? generateSalt();
|
|
3408
|
+
try {
|
|
3409
|
+
const passphraseBytes = new TextEncoder().encode(passphrase);
|
|
3410
|
+
const baseKey = await globalThis.crypto.subtle.importKey(
|
|
3411
|
+
"raw",
|
|
3412
|
+
passphraseBytes,
|
|
3413
|
+
"PBKDF2",
|
|
3414
|
+
false,
|
|
3415
|
+
["deriveBits", "deriveKey"]
|
|
3416
|
+
);
|
|
3417
|
+
const derivedKey = await globalThis.crypto.subtle.deriveKey(
|
|
3418
|
+
{
|
|
3419
|
+
name: "PBKDF2",
|
|
3420
|
+
salt: usedSalt,
|
|
3421
|
+
iterations: PBKDF2_ITERATIONS,
|
|
3422
|
+
hash: "SHA-256"
|
|
3423
|
+
},
|
|
3424
|
+
baseKey,
|
|
3425
|
+
{ name: "AES-GCM", length: DERIVED_KEY_LENGTH },
|
|
3426
|
+
// extractable: true so the derived key can be exported for testing/debugging
|
|
3427
|
+
true,
|
|
3428
|
+
["encrypt", "decrypt"]
|
|
3429
|
+
);
|
|
3430
|
+
return { key: derivedKey, salt: usedSalt };
|
|
3431
|
+
} catch (cause) {
|
|
3432
|
+
if (cause instanceof KeyDerivationError) {
|
|
3433
|
+
throw cause;
|
|
3434
|
+
}
|
|
3435
|
+
throw new KeyDerivationError(
|
|
3436
|
+
"Failed to derive encryption key from passphrase using PBKDF2. Ensure the runtime supports PBKDF2 with SHA-256.",
|
|
3437
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
3438
|
+
);
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
async function deriveVersionedKey(passphrase, version, salt) {
|
|
3442
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
3443
|
+
throw new KeyDerivationError(`Key version must be a positive integer, received: ${version}`, {
|
|
3444
|
+
version
|
|
3445
|
+
});
|
|
3446
|
+
}
|
|
3447
|
+
const { key, salt: usedSalt } = await deriveKey(passphrase, salt);
|
|
3448
|
+
return { version, key, salt: usedSalt };
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
// src/encryption/sync-encryptor.ts
|
|
3452
|
+
var EncryptionError = class extends SyncError6 {
|
|
3453
|
+
constructor(message, context) {
|
|
3454
|
+
super(message, { ...context, errorType: "ENCRYPTION_ERROR" });
|
|
3455
|
+
this.name = "EncryptionError";
|
|
3456
|
+
}
|
|
3457
|
+
};
|
|
3458
|
+
var DecryptionError = class extends SyncError6 {
|
|
3459
|
+
constructor(message, context) {
|
|
3460
|
+
super(message, { ...context, errorType: "DECRYPTION_ERROR" });
|
|
3461
|
+
this.name = "DecryptionError";
|
|
3462
|
+
}
|
|
3463
|
+
};
|
|
3464
|
+
var IV_LENGTH = 12;
|
|
3465
|
+
var ENCRYPTED_MARKER = "__kora_e2e_encrypted";
|
|
3466
|
+
var SyncEncryptor = class _SyncEncryptor {
|
|
3467
|
+
/**
|
|
3468
|
+
* Map of key version -> VersionedKey. The current (latest) version is used
|
|
3469
|
+
* for encryption. All versions are available for decryption (key rotation).
|
|
3470
|
+
*/
|
|
3471
|
+
keys;
|
|
3472
|
+
/** The current key version used for encryption. */
|
|
3473
|
+
currentVersion;
|
|
3474
|
+
constructor(keys, currentVersion) {
|
|
3475
|
+
this.keys = keys;
|
|
3476
|
+
this.currentVersion = currentVersion;
|
|
3477
|
+
}
|
|
3478
|
+
/**
|
|
3479
|
+
* Creates a SyncEncryptor from a {@link SyncEncryptionConfig}.
|
|
3480
|
+
*
|
|
3481
|
+
* Derives the encryption key from the passphrase using PBKDF2. The key
|
|
3482
|
+
* derivation is async because it uses the Web Crypto API.
|
|
3483
|
+
*
|
|
3484
|
+
* @param config - Encryption configuration with passphrase
|
|
3485
|
+
* @param salt - Optional salt for deterministic key derivation (mainly for testing).
|
|
3486
|
+
* If omitted, a random salt is generated.
|
|
3487
|
+
* @returns A configured SyncEncryptor instance
|
|
3488
|
+
* @throws {EncryptionError} If configuration is invalid
|
|
3489
|
+
* @throws {KeyDerivationError} If key derivation fails
|
|
3490
|
+
*/
|
|
3491
|
+
static async create(config, salt) {
|
|
3492
|
+
if (!config.enabled) {
|
|
3493
|
+
throw new EncryptionError(
|
|
3494
|
+
"Cannot create SyncEncryptor with encryption disabled. Set enabled: true in the encryption config."
|
|
3495
|
+
);
|
|
3496
|
+
}
|
|
3497
|
+
const passphrase = typeof config.key === "function" ? await config.key() : config.key;
|
|
3498
|
+
if (passphrase.length === 0) {
|
|
3499
|
+
throw new EncryptionError(
|
|
3500
|
+
"Encryption key/passphrase must not be empty. Provide a non-empty string or key provider function."
|
|
3501
|
+
);
|
|
3502
|
+
}
|
|
3503
|
+
const versionedKey = await deriveVersionedKey(passphrase, 1, salt);
|
|
3504
|
+
const keys = /* @__PURE__ */ new Map();
|
|
3505
|
+
keys.set(1, versionedKey);
|
|
3506
|
+
return new _SyncEncryptor(keys, 1);
|
|
3507
|
+
}
|
|
3508
|
+
/**
|
|
3509
|
+
* Creates a SyncEncryptor from pre-derived versioned keys.
|
|
3510
|
+
*
|
|
3511
|
+
* Use this when you need to support multiple key versions for key rotation,
|
|
3512
|
+
* or when you have already derived the keys externally.
|
|
3513
|
+
*
|
|
3514
|
+
* @param versionedKeys - Array of versioned keys. The highest version is used for encryption.
|
|
3515
|
+
* @returns A configured SyncEncryptor instance
|
|
3516
|
+
* @throws {EncryptionError} If no keys are provided
|
|
3517
|
+
*/
|
|
3518
|
+
static fromKeys(versionedKeys) {
|
|
3519
|
+
if (versionedKeys.length === 0) {
|
|
3520
|
+
throw new EncryptionError("At least one versioned key must be provided.");
|
|
3521
|
+
}
|
|
3522
|
+
const keys = /* @__PURE__ */ new Map();
|
|
3523
|
+
let maxVersion = 0;
|
|
3524
|
+
for (const vk of versionedKeys) {
|
|
3525
|
+
keys.set(vk.version, vk);
|
|
3526
|
+
if (vk.version > maxVersion) {
|
|
3527
|
+
maxVersion = vk.version;
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
return new _SyncEncryptor(keys, maxVersion);
|
|
3531
|
+
}
|
|
3532
|
+
/**
|
|
3533
|
+
* Add a new key version for key rotation.
|
|
3534
|
+
*
|
|
3535
|
+
* After adding, the new key becomes the current version used for encryption
|
|
3536
|
+
* if its version number is higher than the current version. Previously-versioned
|
|
3537
|
+
* keys remain available for decrypting older operations.
|
|
3538
|
+
*
|
|
3539
|
+
* @param key - The new versioned key to add
|
|
3540
|
+
* @throws {EncryptionError} If the key version already exists
|
|
3541
|
+
*/
|
|
3542
|
+
addKey(key) {
|
|
3543
|
+
if (this.keys.has(key.version)) {
|
|
3544
|
+
throw new EncryptionError(
|
|
3545
|
+
`Key version ${key.version} already exists. Use a higher version number for rotation.`,
|
|
3546
|
+
{ existingVersion: key.version }
|
|
3547
|
+
);
|
|
3548
|
+
}
|
|
3549
|
+
this.keys.set(key.version, key);
|
|
3550
|
+
if (key.version > this.currentVersion) {
|
|
3551
|
+
this.currentVersion = key.version;
|
|
3552
|
+
}
|
|
3553
|
+
}
|
|
3554
|
+
/**
|
|
3555
|
+
* Get the current encryption key version number.
|
|
3556
|
+
*/
|
|
3557
|
+
getCurrentKeyVersion() {
|
|
3558
|
+
return this.currentVersion;
|
|
3559
|
+
}
|
|
3560
|
+
/**
|
|
3561
|
+
* Encrypt an operation's `data` and `previousData` fields.
|
|
3562
|
+
*
|
|
3563
|
+
* Returns a new Operation with encrypted field values. The original
|
|
3564
|
+
* operation is not mutated (operations are immutable).
|
|
3565
|
+
*
|
|
3566
|
+
* Fields that are null (e.g., delete operations) remain null.
|
|
3567
|
+
*
|
|
3568
|
+
* @param operation - The operation to encrypt
|
|
3569
|
+
* @returns A new operation with encrypted data fields
|
|
3570
|
+
* @throws {EncryptionError} If encryption fails
|
|
3571
|
+
*/
|
|
3572
|
+
async encryptOperation(operation) {
|
|
3573
|
+
const [encryptedData, encryptedPreviousData] = await Promise.all([
|
|
3574
|
+
this.encryptField(operation.data, operation.id, "data"),
|
|
3575
|
+
this.encryptField(operation.previousData, operation.id, "previousData")
|
|
3576
|
+
]);
|
|
3577
|
+
return {
|
|
3578
|
+
...operation,
|
|
3579
|
+
data: encryptedData,
|
|
3580
|
+
previousData: encryptedPreviousData
|
|
3581
|
+
};
|
|
3582
|
+
}
|
|
3583
|
+
/**
|
|
3584
|
+
* Decrypt an operation's `data` and `previousData` fields.
|
|
3585
|
+
*
|
|
3586
|
+
* Returns a new Operation with the original field values restored.
|
|
3587
|
+
* The original operation is not mutated.
|
|
3588
|
+
*
|
|
3589
|
+
* If a field is null or not encrypted (no marker), it passes through unchanged.
|
|
3590
|
+
* This enables mixed plaintext/encrypted operations during migration.
|
|
3591
|
+
*
|
|
3592
|
+
* @param operation - The operation to decrypt
|
|
3593
|
+
* @returns A new operation with decrypted data fields
|
|
3594
|
+
* @throws {DecryptionError} If decryption fails (wrong key, tampered data, unsupported version)
|
|
3595
|
+
*/
|
|
3596
|
+
async decryptOperation(operation) {
|
|
3597
|
+
const [decryptedData, decryptedPreviousData] = await Promise.all([
|
|
3598
|
+
this.decryptField(operation.data, operation.id, "data"),
|
|
3599
|
+
this.decryptField(operation.previousData, operation.id, "previousData")
|
|
3600
|
+
]);
|
|
3601
|
+
return {
|
|
3602
|
+
...operation,
|
|
3603
|
+
data: decryptedData,
|
|
3604
|
+
previousData: decryptedPreviousData
|
|
3605
|
+
};
|
|
3606
|
+
}
|
|
3607
|
+
/**
|
|
3608
|
+
* Encrypt a serialized operation's `data` and `previousData` fields.
|
|
3609
|
+
*
|
|
3610
|
+
* Same as {@link encryptOperation} but works with the wire-format
|
|
3611
|
+
* {@link SerializedOperation} type used by the serializer.
|
|
3612
|
+
*
|
|
3613
|
+
* @param serialized - The serialized operation to encrypt
|
|
3614
|
+
* @returns A new serialized operation with encrypted data fields
|
|
3615
|
+
* @throws {EncryptionError} If encryption fails
|
|
3616
|
+
*/
|
|
3617
|
+
async encryptSerializedOperation(serialized) {
|
|
3618
|
+
const [encryptedData, encryptedPreviousData] = await Promise.all([
|
|
3619
|
+
this.encryptField(serialized.data, serialized.id, "data"),
|
|
3620
|
+
this.encryptField(serialized.previousData, serialized.id, "previousData")
|
|
3621
|
+
]);
|
|
3622
|
+
return {
|
|
3623
|
+
...serialized,
|
|
3624
|
+
data: encryptedData,
|
|
3625
|
+
previousData: encryptedPreviousData
|
|
3626
|
+
};
|
|
3627
|
+
}
|
|
3628
|
+
/**
|
|
3629
|
+
* Decrypt a serialized operation's `data` and `previousData` fields.
|
|
3630
|
+
*
|
|
3631
|
+
* Same as {@link decryptOperation} but works with the wire-format
|
|
3632
|
+
* {@link SerializedOperation} type used by the serializer.
|
|
3633
|
+
*
|
|
3634
|
+
* @param serialized - The serialized operation to decrypt
|
|
3635
|
+
* @returns A new serialized operation with decrypted data fields
|
|
3636
|
+
* @throws {DecryptionError} If decryption fails
|
|
3637
|
+
*/
|
|
3638
|
+
async decryptSerializedOperation(serialized) {
|
|
3639
|
+
const [decryptedData, decryptedPreviousData] = await Promise.all([
|
|
3640
|
+
this.decryptField(serialized.data, serialized.id, "data"),
|
|
3641
|
+
this.decryptField(serialized.previousData, serialized.id, "previousData")
|
|
3642
|
+
]);
|
|
3643
|
+
return {
|
|
3644
|
+
...serialized,
|
|
3645
|
+
data: decryptedData,
|
|
3646
|
+
previousData: decryptedPreviousData
|
|
3647
|
+
};
|
|
3648
|
+
}
|
|
3649
|
+
/**
|
|
3650
|
+
* Encrypt a batch of operations in parallel.
|
|
3651
|
+
*
|
|
3652
|
+
* @param operations - Operations to encrypt
|
|
3653
|
+
* @returns New operations with encrypted data fields
|
|
3654
|
+
*/
|
|
3655
|
+
async encryptBatch(operations) {
|
|
3656
|
+
return Promise.all(operations.map((op) => this.encryptOperation(op)));
|
|
3657
|
+
}
|
|
3658
|
+
/**
|
|
3659
|
+
* Decrypt a batch of operations in parallel.
|
|
3660
|
+
*
|
|
3661
|
+
* @param operations - Operations to decrypt
|
|
3662
|
+
* @returns New operations with decrypted data fields
|
|
3663
|
+
*/
|
|
3664
|
+
async decryptBatch(operations) {
|
|
3665
|
+
return Promise.all(operations.map((op) => this.decryptOperation(op)));
|
|
3666
|
+
}
|
|
3667
|
+
/**
|
|
3668
|
+
* Check if a field value contains an encrypted payload.
|
|
3669
|
+
*
|
|
3670
|
+
* @param field - An operation's `data` or `previousData` field
|
|
3671
|
+
* @returns true if the field contains an encrypted payload
|
|
3672
|
+
*/
|
|
3673
|
+
static isEncryptedPayload(field) {
|
|
3674
|
+
return isEncryptedPayload(field);
|
|
3675
|
+
}
|
|
3676
|
+
// --- Private helpers ---
|
|
3677
|
+
async encryptField(field, operationId, fieldName) {
|
|
3678
|
+
if (field === null) {
|
|
3679
|
+
return null;
|
|
3680
|
+
}
|
|
3681
|
+
const currentKey = this.keys.get(this.currentVersion);
|
|
3682
|
+
if (!currentKey) {
|
|
3683
|
+
throw new EncryptionError(
|
|
3684
|
+
`Current encryption key version ${this.currentVersion} not found.`,
|
|
3685
|
+
{ operationId, fieldName }
|
|
3686
|
+
);
|
|
3687
|
+
}
|
|
3688
|
+
try {
|
|
3689
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(field));
|
|
3690
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
3691
|
+
const ciphertextBuffer = await globalThis.crypto.subtle.encrypt(
|
|
3692
|
+
{ name: "AES-GCM", iv },
|
|
3693
|
+
currentKey.key,
|
|
3694
|
+
plaintext
|
|
3695
|
+
);
|
|
3696
|
+
const payload = {
|
|
3697
|
+
v: this.currentVersion,
|
|
3698
|
+
iv: toBase642(iv),
|
|
3699
|
+
ct: toBase642(new Uint8Array(ciphertextBuffer)),
|
|
3700
|
+
alg: "aes-256-gcm"
|
|
3701
|
+
};
|
|
3702
|
+
return {
|
|
3703
|
+
[ENCRYPTED_MARKER]: true,
|
|
3704
|
+
...payload
|
|
3705
|
+
};
|
|
3706
|
+
} catch (cause) {
|
|
3707
|
+
if (cause instanceof EncryptionError) {
|
|
3708
|
+
throw cause;
|
|
3709
|
+
}
|
|
3710
|
+
throw new EncryptionError(
|
|
3711
|
+
`Failed to encrypt operation ${fieldName} field. Ensure the encryption key is valid and crypto.subtle is available.`,
|
|
3712
|
+
{
|
|
3713
|
+
operationId,
|
|
3714
|
+
fieldName,
|
|
3715
|
+
cause: cause instanceof Error ? cause.message : String(cause)
|
|
3716
|
+
}
|
|
3717
|
+
);
|
|
3718
|
+
}
|
|
3719
|
+
}
|
|
3720
|
+
async decryptField(field, operationId, fieldName) {
|
|
3721
|
+
if (field === null) {
|
|
3722
|
+
return null;
|
|
3723
|
+
}
|
|
3724
|
+
if (!isEncryptedPayload(field)) {
|
|
3725
|
+
return field;
|
|
3726
|
+
}
|
|
3727
|
+
const payload = field;
|
|
3728
|
+
const keyVersion = payload.v;
|
|
3729
|
+
const key = this.keys.get(keyVersion);
|
|
3730
|
+
if (!key) {
|
|
3731
|
+
throw new DecryptionError(
|
|
3732
|
+
`No encryption key available for version ${keyVersion}. This operation was encrypted with a key that is not registered. If you rotated keys, ensure all previous key versions are provided.`,
|
|
3733
|
+
{ operationId, fieldName, keyVersion, availableVersions: [...this.keys.keys()] }
|
|
3734
|
+
);
|
|
3735
|
+
}
|
|
3736
|
+
if (payload.alg !== "aes-256-gcm") {
|
|
3737
|
+
throw new DecryptionError(
|
|
3738
|
+
`Unsupported encryption algorithm: "${payload.alg}". Only "aes-256-gcm" is supported. Update your @korajs/sync package.`,
|
|
3739
|
+
{ operationId, fieldName, algorithm: payload.alg }
|
|
3740
|
+
);
|
|
3741
|
+
}
|
|
3742
|
+
try {
|
|
3743
|
+
const iv = fromBase642(payload.iv);
|
|
3744
|
+
const ciphertext = fromBase642(payload.ct);
|
|
3745
|
+
const plaintextBuffer = await globalThis.crypto.subtle.decrypt(
|
|
3746
|
+
{ name: "AES-GCM", iv },
|
|
3747
|
+
key.key,
|
|
3748
|
+
ciphertext
|
|
3749
|
+
);
|
|
3750
|
+
const json = new TextDecoder().decode(plaintextBuffer);
|
|
3751
|
+
const parsed = JSON.parse(json);
|
|
3752
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
3753
|
+
throw new DecryptionError(`Decrypted ${fieldName} is not a valid record object.`, {
|
|
3754
|
+
operationId,
|
|
3755
|
+
fieldName
|
|
3756
|
+
});
|
|
3757
|
+
}
|
|
3758
|
+
return parsed;
|
|
3759
|
+
} catch (cause) {
|
|
3760
|
+
if (cause instanceof DecryptionError) {
|
|
3761
|
+
throw cause;
|
|
3762
|
+
}
|
|
3763
|
+
throw new DecryptionError(
|
|
3764
|
+
`Failed to decrypt operation ${fieldName} field. This may indicate a wrong encryption key, tampered ciphertext, or corrupted data.`,
|
|
3765
|
+
{
|
|
3766
|
+
operationId,
|
|
3767
|
+
fieldName,
|
|
3768
|
+
keyVersion,
|
|
3769
|
+
cause: cause instanceof Error ? cause.message : String(cause)
|
|
3770
|
+
}
|
|
3771
|
+
);
|
|
3772
|
+
}
|
|
3773
|
+
}
|
|
3774
|
+
};
|
|
3775
|
+
function isEncryptedPayload(field) {
|
|
3776
|
+
if (field === null || typeof field !== "object") {
|
|
3777
|
+
return false;
|
|
3778
|
+
}
|
|
3779
|
+
return field[ENCRYPTED_MARKER] === true && typeof field.v === "number" && typeof field.iv === "string" && typeof field.ct === "string" && typeof field.alg === "string";
|
|
3780
|
+
}
|
|
3781
|
+
function toBase642(bytes) {
|
|
3782
|
+
let binary = "";
|
|
3783
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
3784
|
+
binary += String.fromCharCode(bytes[i]);
|
|
3785
|
+
}
|
|
3786
|
+
return btoa(binary);
|
|
3787
|
+
}
|
|
3788
|
+
function fromBase642(str) {
|
|
3789
|
+
const binary = atob(str);
|
|
3790
|
+
const bytes = new Uint8Array(binary.length);
|
|
3791
|
+
for (let i = 0; i < binary.length; i++) {
|
|
3792
|
+
bytes[i] = binary.charCodeAt(i);
|
|
3793
|
+
}
|
|
3794
|
+
return bytes;
|
|
3795
|
+
}
|
|
1696
3796
|
export {
|
|
3797
|
+
AwarenessManager,
|
|
1697
3798
|
ChaosTransport,
|
|
1698
3799
|
ConnectionMonitor,
|
|
3800
|
+
DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD,
|
|
3801
|
+
DecryptionError,
|
|
3802
|
+
EncryptionError,
|
|
1699
3803
|
HttpLongPollingTransport,
|
|
3804
|
+
InvalidScopeError,
|
|
1700
3805
|
JsonMessageSerializer,
|
|
3806
|
+
KeyDerivationError,
|
|
1701
3807
|
NegotiatedMessageSerializer,
|
|
1702
3808
|
OutboundQueue,
|
|
1703
3809
|
ProtobufMessageSerializer,
|
|
1704
3810
|
ReconnectionManager,
|
|
3811
|
+
RichtextDocChannel,
|
|
3812
|
+
SCHEMA_MISMATCH_PREFIX,
|
|
1705
3813
|
SYNC_STATES,
|
|
1706
3814
|
SYNC_STATUSES,
|
|
3815
|
+
ScopeViolationError,
|
|
3816
|
+
SyncEncryptor,
|
|
1707
3817
|
SyncEngine,
|
|
1708
3818
|
WebSocketTransport,
|
|
3819
|
+
createDeltaCursorFromBatch,
|
|
3820
|
+
decodeDeltaCursor,
|
|
3821
|
+
decodeYjsUpdate,
|
|
3822
|
+
dedupeQuerySubsets,
|
|
3823
|
+
deriveKey,
|
|
3824
|
+
deriveVersionedKey,
|
|
3825
|
+
encodeDeltaCursor,
|
|
3826
|
+
encodeYjsUpdate,
|
|
3827
|
+
filterOperationsByScope,
|
|
3828
|
+
generateSalt,
|
|
1709
3829
|
isAcknowledgmentMessage,
|
|
3830
|
+
isAwarenessUpdateMessage,
|
|
3831
|
+
isClientSchemaVersionSupported,
|
|
3832
|
+
isEncryptedPayload,
|
|
1710
3833
|
isErrorMessage,
|
|
1711
3834
|
isHandshakeMessage,
|
|
1712
3835
|
isHandshakeResponseMessage,
|
|
1713
3836
|
isOperationBatchMessage,
|
|
3837
|
+
isSchemaMismatchReject,
|
|
1714
3838
|
isSyncMessage,
|
|
3839
|
+
isYjsDocUpdateMessage,
|
|
3840
|
+
operationMatchesQuerySubsets,
|
|
3841
|
+
operationMatchesScope,
|
|
3842
|
+
richtextDocKey,
|
|
3843
|
+
sliceOperationsAfterCursor,
|
|
1715
3844
|
versionVectorToWire,
|
|
1716
3845
|
wireToVersionVector
|
|
1717
3846
|
};
|