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