@korajs/sync 0.4.0 → 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 +954 -239
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +328 -162
- package/dist/index.d.ts +328 -162
- package/dist/index.js +944 -237
- package/dist/index.js.map +1 -1
- package/dist/internal.d.cts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/{transport-B-BIguq9.d.cts → transport-BrdBj7rH.d.cts} +84 -4
- package/dist/{transport-B-BIguq9.d.ts → transport-BrdBj7rH.d.ts} +84 -4
- package/package.json +5 -4
package/dist/index.js
CHANGED
|
@@ -12,7 +12,14 @@ var SYNC_STATES = [
|
|
|
12
12
|
"streaming",
|
|
13
13
|
"error"
|
|
14
14
|
];
|
|
15
|
-
var SYNC_STATUSES = [
|
|
15
|
+
var SYNC_STATUSES = [
|
|
16
|
+
"connected",
|
|
17
|
+
"syncing",
|
|
18
|
+
"synced",
|
|
19
|
+
"offline",
|
|
20
|
+
"error",
|
|
21
|
+
"schema-mismatch"
|
|
22
|
+
];
|
|
16
23
|
var ScopeViolationError = class extends KoraError {
|
|
17
24
|
constructor(operationId, collection, scope, message) {
|
|
18
25
|
super(
|
|
@@ -37,12 +44,12 @@ var InvalidScopeError = class extends KoraError {
|
|
|
37
44
|
};
|
|
38
45
|
|
|
39
46
|
// src/scopes/scope-filter.ts
|
|
40
|
-
function operationMatchesScope(op, scopeMap) {
|
|
47
|
+
function operationMatchesScope(op, scopeMap, fullRecord) {
|
|
41
48
|
if (!scopeMap) return true;
|
|
42
49
|
const collectionScope = scopeMap[op.collection];
|
|
43
50
|
if (!collectionScope) return false;
|
|
44
51
|
if (Object.keys(collectionScope).length === 0) return true;
|
|
45
|
-
const snapshot = buildSnapshot(op);
|
|
52
|
+
const snapshot = buildSnapshot(op, fullRecord ?? void 0);
|
|
46
53
|
if (!snapshot) return false;
|
|
47
54
|
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
48
55
|
if (snapshot[field] !== expected) {
|
|
@@ -55,11 +62,12 @@ function filterOperationsByScope(operations, scopeMap) {
|
|
|
55
62
|
if (!scopeMap) return operations;
|
|
56
63
|
return operations.filter((op) => operationMatchesScope(op, scopeMap));
|
|
57
64
|
}
|
|
58
|
-
function buildSnapshot(op) {
|
|
65
|
+
function buildSnapshot(op, fullRecord) {
|
|
59
66
|
const previous = asRecord(op.previousData);
|
|
60
67
|
const next = asRecord(op.data);
|
|
61
|
-
if (!previous && !next) return null;
|
|
68
|
+
if (!previous && !next && !fullRecord) return null;
|
|
62
69
|
return {
|
|
70
|
+
...fullRecord ?? {},
|
|
63
71
|
...previous ?? {},
|
|
64
72
|
...next ?? {}
|
|
65
73
|
};
|
|
@@ -71,6 +79,128 @@ function asRecord(value) {
|
|
|
71
79
|
return value;
|
|
72
80
|
}
|
|
73
81
|
|
|
82
|
+
// src/scopes/query-subset.ts
|
|
83
|
+
function asRecord2(value) {
|
|
84
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
function buildSnapshot2(op, fullRecord) {
|
|
90
|
+
const previous = asRecord2(op.previousData);
|
|
91
|
+
const next = asRecord2(op.data);
|
|
92
|
+
if (!previous && !next && !fullRecord) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
...fullRecord ?? {},
|
|
97
|
+
...previous ?? {},
|
|
98
|
+
...next ?? {}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function recordMatchesWhere(snapshot, where) {
|
|
102
|
+
for (const [field, expected] of Object.entries(where)) {
|
|
103
|
+
if (snapshot[field] !== expected) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
function operationMatchesQuerySubsets(op, subsets, fullRecord) {
|
|
110
|
+
if (!subsets || subsets.length === 0) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
const collectionSubsets = subsets.filter((subset) => subset.collection === op.collection);
|
|
114
|
+
if (collectionSubsets.length === 0) {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
const snapshot = buildSnapshot2(op, fullRecord);
|
|
118
|
+
if (!snapshot) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
return collectionSubsets.some((subset) => recordMatchesWhere(snapshot, subset.where));
|
|
122
|
+
}
|
|
123
|
+
function dedupeQuerySubsets(subsets) {
|
|
124
|
+
const seen = /* @__PURE__ */ new Set();
|
|
125
|
+
const result = [];
|
|
126
|
+
for (const subset of subsets) {
|
|
127
|
+
const key = `${subset.collection}:${JSON.stringify(subset.where)}`;
|
|
128
|
+
if (seen.has(key)) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
seen.add(key);
|
|
132
|
+
result.push(subset);
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/delta/delta-cursor.ts
|
|
138
|
+
function bytesToBase64Url(bytes) {
|
|
139
|
+
let binary = "";
|
|
140
|
+
for (const byte of bytes) {
|
|
141
|
+
binary += String.fromCharCode(byte);
|
|
142
|
+
}
|
|
143
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
144
|
+
}
|
|
145
|
+
function base64UrlToBytes(value) {
|
|
146
|
+
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
147
|
+
const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
|
|
148
|
+
const binary = atob(padded);
|
|
149
|
+
const bytes = new Uint8Array(binary.length);
|
|
150
|
+
for (let i = 0; i < binary.length; i++) {
|
|
151
|
+
bytes[i] = binary.charCodeAt(i);
|
|
152
|
+
}
|
|
153
|
+
return bytes;
|
|
154
|
+
}
|
|
155
|
+
function encodeDeltaCursor(cursor) {
|
|
156
|
+
const json = JSON.stringify(cursor);
|
|
157
|
+
return bytesToBase64Url(new TextEncoder().encode(json));
|
|
158
|
+
}
|
|
159
|
+
function decodeDeltaCursor(value) {
|
|
160
|
+
if (!value) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const json = new TextDecoder().decode(base64UrlToBytes(value));
|
|
165
|
+
const parsed = JSON.parse(json);
|
|
166
|
+
if (typeof parsed !== "object" || parsed === null || typeof parsed.lastOperationId !== "string" || typeof parsed.batchIndex !== "number") {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return parsed;
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function sliceOperationsAfterCursor(operations, cursor) {
|
|
175
|
+
if (!cursor) {
|
|
176
|
+
return operations;
|
|
177
|
+
}
|
|
178
|
+
const index = operations.findIndex((op) => op.id === cursor.lastOperationId);
|
|
179
|
+
if (index === -1) {
|
|
180
|
+
return operations;
|
|
181
|
+
}
|
|
182
|
+
return operations.slice(index + 1);
|
|
183
|
+
}
|
|
184
|
+
function createDeltaCursorFromBatch(operations, batchIndex) {
|
|
185
|
+
const last = operations[operations.length - 1];
|
|
186
|
+
if (!last) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
lastOperationId: last.id,
|
|
191
|
+
batchIndex
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/protocol/schema-version.ts
|
|
196
|
+
var SCHEMA_MISMATCH_PREFIX = "SCHEMA_MISMATCH";
|
|
197
|
+
function isSchemaMismatchReject(rejectReason) {
|
|
198
|
+
return rejectReason?.startsWith(SCHEMA_MISMATCH_PREFIX) === true;
|
|
199
|
+
}
|
|
200
|
+
function isClientSchemaVersionSupported(clientVersion, supported) {
|
|
201
|
+
return clientVersion >= supported.min && clientVersion <= supported.max;
|
|
202
|
+
}
|
|
203
|
+
|
|
74
204
|
// src/protocol/messages.ts
|
|
75
205
|
function isSyncMessage(value) {
|
|
76
206
|
if (typeof value !== "object" || value === null) return false;
|
|
@@ -89,6 +219,8 @@ function isSyncMessage(value) {
|
|
|
89
219
|
return isErrorMessage(value);
|
|
90
220
|
case "awareness-update":
|
|
91
221
|
return isAwarenessUpdateMessage(value);
|
|
222
|
+
case "yjs-doc-update":
|
|
223
|
+
return isYjsDocUpdateMessage(value);
|
|
92
224
|
default:
|
|
93
225
|
return false;
|
|
94
226
|
}
|
|
@@ -123,6 +255,11 @@ function isAwarenessUpdateMessage(value) {
|
|
|
123
255
|
const msg = value;
|
|
124
256
|
return msg.type === "awareness-update" && typeof msg.messageId === "string" && typeof msg.clientId === "number" && typeof msg.states === "object" && msg.states !== null && !Array.isArray(msg.states);
|
|
125
257
|
}
|
|
258
|
+
function isYjsDocUpdateMessage(value) {
|
|
259
|
+
if (typeof value !== "object" || value === null) return false;
|
|
260
|
+
const msg = value;
|
|
261
|
+
return msg.type === "yjs-doc-update" && typeof msg.messageId === "string" && typeof msg.collection === "string" && typeof msg.recordId === "string" && typeof msg.field === "string" && typeof msg.update === "string";
|
|
262
|
+
}
|
|
126
263
|
|
|
127
264
|
// src/protocol/serializer.ts
|
|
128
265
|
import { SyncError } from "@korajs/core";
|
|
@@ -138,6 +275,84 @@ function versionVectorToWire(vector) {
|
|
|
138
275
|
function wireToVersionVector(wire) {
|
|
139
276
|
return new Map(Object.entries(wire));
|
|
140
277
|
}
|
|
278
|
+
var WIRE_BYTES_KEY = "__kora_bytes__";
|
|
279
|
+
function toBase64(bytes) {
|
|
280
|
+
let binary = "";
|
|
281
|
+
for (const byte of bytes) {
|
|
282
|
+
binary += String.fromCharCode(byte);
|
|
283
|
+
}
|
|
284
|
+
return btoa(binary);
|
|
285
|
+
}
|
|
286
|
+
function fromBase64(value) {
|
|
287
|
+
const binary = atob(value);
|
|
288
|
+
const bytes = new Uint8Array(binary.length);
|
|
289
|
+
for (let index = 0; index < binary.length; index++) {
|
|
290
|
+
bytes[index] = binary.charCodeAt(index);
|
|
291
|
+
}
|
|
292
|
+
return bytes;
|
|
293
|
+
}
|
|
294
|
+
function serializeWireRecord(record) {
|
|
295
|
+
if (!record) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
const out = {};
|
|
299
|
+
for (const [key, value] of Object.entries(record)) {
|
|
300
|
+
out[key] = serializeWireValue(value);
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
function serializeWireValue(value) {
|
|
305
|
+
if (value instanceof Uint8Array) {
|
|
306
|
+
return { [WIRE_BYTES_KEY]: toBase64(value) };
|
|
307
|
+
}
|
|
308
|
+
if (value instanceof ArrayBuffer) {
|
|
309
|
+
return { [WIRE_BYTES_KEY]: toBase64(new Uint8Array(value)) };
|
|
310
|
+
}
|
|
311
|
+
return value;
|
|
312
|
+
}
|
|
313
|
+
function deserializeWireRecord(record) {
|
|
314
|
+
if (!record) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
const out = {};
|
|
318
|
+
for (const [key, value] of Object.entries(record)) {
|
|
319
|
+
out[key] = deserializeWireValue(value);
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
function deserializeWireValue(value) {
|
|
324
|
+
if (typeof value === "object" && value !== null && WIRE_BYTES_KEY in value) {
|
|
325
|
+
const encoded = value[WIRE_BYTES_KEY];
|
|
326
|
+
if (typeof encoded === "string") {
|
|
327
|
+
return fromBase64(encoded);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (isLegacyIndexedByteRecord(value)) {
|
|
331
|
+
return legacyIndexedByteRecordToUint8Array(value);
|
|
332
|
+
}
|
|
333
|
+
return value;
|
|
334
|
+
}
|
|
335
|
+
function isLegacyIndexedByteRecord(value) {
|
|
336
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
const entries = Object.entries(value);
|
|
340
|
+
if (entries.length === 0) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
return entries.every(([key, entryValue]) => {
|
|
344
|
+
const index = Number(key);
|
|
345
|
+
return Number.isInteger(index) && index >= 0 && typeof entryValue === "number";
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
function legacyIndexedByteRecordToUint8Array(value) {
|
|
349
|
+
const maxIndex = Math.max(...Object.keys(value).map((key) => Number(key)));
|
|
350
|
+
const bytes = new Uint8Array(maxIndex + 1);
|
|
351
|
+
for (const [key, entryValue] of Object.entries(value)) {
|
|
352
|
+
bytes[Number(key)] = entryValue;
|
|
353
|
+
}
|
|
354
|
+
return bytes;
|
|
355
|
+
}
|
|
141
356
|
var JsonMessageSerializer = class {
|
|
142
357
|
encode(message) {
|
|
143
358
|
return JSON.stringify(message);
|
|
@@ -166,8 +381,8 @@ var JsonMessageSerializer = class {
|
|
|
166
381
|
type: op.type,
|
|
167
382
|
collection: op.collection,
|
|
168
383
|
recordId: op.recordId,
|
|
169
|
-
data: op.data,
|
|
170
|
-
previousData: op.previousData,
|
|
384
|
+
data: serializeWireRecord(op.data),
|
|
385
|
+
previousData: serializeWireRecord(op.previousData),
|
|
171
386
|
timestamp: {
|
|
172
387
|
wallTime: op.timestamp.wallTime,
|
|
173
388
|
logical: op.timestamp.logical,
|
|
@@ -188,8 +403,8 @@ var JsonMessageSerializer = class {
|
|
|
188
403
|
type: serialized.type,
|
|
189
404
|
collection: serialized.collection,
|
|
190
405
|
recordId: serialized.recordId,
|
|
191
|
-
data: serialized.data,
|
|
192
|
-
previousData: serialized.previousData,
|
|
406
|
+
data: deserializeWireRecord(serialized.data),
|
|
407
|
+
previousData: deserializeWireRecord(serialized.previousData),
|
|
193
408
|
timestamp: {
|
|
194
409
|
wallTime: serialized.timestamp.wallTime,
|
|
195
410
|
logical: serialized.timestamp.logical,
|
|
@@ -310,6 +525,7 @@ function toProtoEnvelope(message) {
|
|
|
310
525
|
retriable: message.retriable
|
|
311
526
|
};
|
|
312
527
|
case "awareness-update":
|
|
528
|
+
case "yjs-doc-update":
|
|
313
529
|
return {
|
|
314
530
|
type: message.type,
|
|
315
531
|
messageId: message.messageId
|
|
@@ -1166,9 +1382,222 @@ var ChaosTransport = class {
|
|
|
1166
1382
|
};
|
|
1167
1383
|
|
|
1168
1384
|
// src/engine/sync-engine.ts
|
|
1169
|
-
import {
|
|
1385
|
+
import {
|
|
1386
|
+
APPLY_FAILURE_CODES,
|
|
1387
|
+
ClockDriftError,
|
|
1388
|
+
KoraError as KoraError2,
|
|
1389
|
+
SyncError as SyncError4,
|
|
1390
|
+
applyOperationTransforms,
|
|
1391
|
+
defaultApplyFailureReason
|
|
1392
|
+
} from "@korajs/core";
|
|
1170
1393
|
import { topologicalSort as topologicalSort2 } from "@korajs/core/internal";
|
|
1171
1394
|
|
|
1395
|
+
// src/awareness/awareness-manager.ts
|
|
1396
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
1397
|
+
var nextLocalClientId = 1;
|
|
1398
|
+
var AwarenessManager = class {
|
|
1399
|
+
/** Unique client ID for this instance */
|
|
1400
|
+
clientId;
|
|
1401
|
+
localState = null;
|
|
1402
|
+
remoteStates = /* @__PURE__ */ new Map();
|
|
1403
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1404
|
+
emitter;
|
|
1405
|
+
timeoutMs;
|
|
1406
|
+
// Track when we last received an update from each remote client.
|
|
1407
|
+
// Used for timeout-based cleanup as a safety net in case the server
|
|
1408
|
+
// does not send an explicit removal on disconnect.
|
|
1409
|
+
lastUpdated = /* @__PURE__ */ new Map();
|
|
1410
|
+
cleanupTimer = null;
|
|
1411
|
+
sendHandler = null;
|
|
1412
|
+
destroyed = false;
|
|
1413
|
+
constructor(options) {
|
|
1414
|
+
this.clientId = options?.clientId ?? nextLocalClientId++;
|
|
1415
|
+
this.emitter = options?.emitter ?? null;
|
|
1416
|
+
this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Set the local user's awareness state and broadcast to peers.
|
|
1420
|
+
*
|
|
1421
|
+
* @param state - The awareness state to share. Pass null to clear presence.
|
|
1422
|
+
*/
|
|
1423
|
+
setLocalState(state) {
|
|
1424
|
+
if (this.destroyed) return;
|
|
1425
|
+
this.localState = state;
|
|
1426
|
+
const message = {
|
|
1427
|
+
type: "awareness",
|
|
1428
|
+
clientId: this.clientId,
|
|
1429
|
+
states: { [this.clientId]: state }
|
|
1430
|
+
};
|
|
1431
|
+
this.sendHandler?.(message);
|
|
1432
|
+
this.emitAwarenessEvent();
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Get the local awareness state.
|
|
1436
|
+
*/
|
|
1437
|
+
getLocalState() {
|
|
1438
|
+
return this.localState;
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Get all known awareness states (local + remote).
|
|
1442
|
+
* Returns a new Map on each call.
|
|
1443
|
+
*/
|
|
1444
|
+
getStates() {
|
|
1445
|
+
const result = /* @__PURE__ */ new Map();
|
|
1446
|
+
if (this.localState) {
|
|
1447
|
+
result.set(this.clientId, this.localState);
|
|
1448
|
+
}
|
|
1449
|
+
for (const [id, state] of this.remoteStates) {
|
|
1450
|
+
result.set(id, state);
|
|
1451
|
+
}
|
|
1452
|
+
return result;
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* Handle an incoming awareness message from the transport.
|
|
1456
|
+
* This processes remote state updates and notifies listeners.
|
|
1457
|
+
*/
|
|
1458
|
+
handleRemoteMessage(message) {
|
|
1459
|
+
if (this.destroyed) return;
|
|
1460
|
+
const added = [];
|
|
1461
|
+
const updated = [];
|
|
1462
|
+
const removed = [];
|
|
1463
|
+
const now = Date.now();
|
|
1464
|
+
for (const [clientIdStr, state] of Object.entries(message.states)) {
|
|
1465
|
+
const clientId = Number(clientIdStr);
|
|
1466
|
+
if (clientId === this.clientId) continue;
|
|
1467
|
+
if (state === null) {
|
|
1468
|
+
if (this.remoteStates.has(clientId)) {
|
|
1469
|
+
this.remoteStates.delete(clientId);
|
|
1470
|
+
this.lastUpdated.delete(clientId);
|
|
1471
|
+
removed.push(clientId);
|
|
1472
|
+
}
|
|
1473
|
+
} else {
|
|
1474
|
+
if (this.remoteStates.has(clientId)) {
|
|
1475
|
+
this.remoteStates.set(clientId, state);
|
|
1476
|
+
this.lastUpdated.set(clientId, now);
|
|
1477
|
+
updated.push(clientId);
|
|
1478
|
+
} else {
|
|
1479
|
+
this.remoteStates.set(clientId, state);
|
|
1480
|
+
this.lastUpdated.set(clientId, now);
|
|
1481
|
+
added.push(clientId);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
if (added.length > 0 || updated.length > 0 || removed.length > 0) {
|
|
1486
|
+
const change = { added, updated, removed };
|
|
1487
|
+
this.notifyListeners(change);
|
|
1488
|
+
this.emitAwarenessEvent();
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Remove a specific remote client's awareness state.
|
|
1493
|
+
* Called when the server notifies that a client has disconnected.
|
|
1494
|
+
*/
|
|
1495
|
+
removeClient(clientId) {
|
|
1496
|
+
if (this.destroyed) return;
|
|
1497
|
+
if (!this.remoteStates.has(clientId)) return;
|
|
1498
|
+
this.remoteStates.delete(clientId);
|
|
1499
|
+
this.lastUpdated.delete(clientId);
|
|
1500
|
+
const change = {
|
|
1501
|
+
added: [],
|
|
1502
|
+
updated: [],
|
|
1503
|
+
removed: [clientId]
|
|
1504
|
+
};
|
|
1505
|
+
this.notifyListeners(change);
|
|
1506
|
+
this.emitAwarenessEvent();
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Register a handler for sending awareness messages through the transport.
|
|
1510
|
+
* The sync engine calls this to wire outgoing awareness messages to the transport.
|
|
1511
|
+
*/
|
|
1512
|
+
onSend(handler) {
|
|
1513
|
+
this.sendHandler = handler;
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Register a listener for awareness state changes.
|
|
1517
|
+
* Returns an unsubscribe function.
|
|
1518
|
+
*/
|
|
1519
|
+
on(_event, listener) {
|
|
1520
|
+
this.listeners.add(listener);
|
|
1521
|
+
return () => {
|
|
1522
|
+
this.listeners.delete(listener);
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* Remove a specific change listener.
|
|
1527
|
+
*/
|
|
1528
|
+
off(_event, listener) {
|
|
1529
|
+
this.listeners.delete(listener);
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Start the cleanup timer that removes stale remote states.
|
|
1533
|
+
* Called when the sync engine transitions to streaming state.
|
|
1534
|
+
*/
|
|
1535
|
+
startCleanupTimer() {
|
|
1536
|
+
if (this.cleanupTimer) return;
|
|
1537
|
+
this.cleanupTimer = setInterval(() => {
|
|
1538
|
+
this.cleanupStaleStates();
|
|
1539
|
+
}, this.timeoutMs);
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Stop the cleanup timer.
|
|
1543
|
+
*/
|
|
1544
|
+
stopCleanupTimer() {
|
|
1545
|
+
if (this.cleanupTimer) {
|
|
1546
|
+
clearInterval(this.cleanupTimer);
|
|
1547
|
+
this.cleanupTimer = null;
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Clean up all resources. After calling destroy(), the manager
|
|
1552
|
+
* will no longer send or receive awareness updates.
|
|
1553
|
+
* Broadcasts removal of local state before shutting down.
|
|
1554
|
+
*/
|
|
1555
|
+
destroy() {
|
|
1556
|
+
if (this.destroyed) return;
|
|
1557
|
+
this.destroyed = true;
|
|
1558
|
+
if (this.localState) {
|
|
1559
|
+
const message = {
|
|
1560
|
+
type: "awareness",
|
|
1561
|
+
clientId: this.clientId,
|
|
1562
|
+
states: { [this.clientId]: null }
|
|
1563
|
+
};
|
|
1564
|
+
this.sendHandler?.(message);
|
|
1565
|
+
}
|
|
1566
|
+
this.localState = null;
|
|
1567
|
+
this.remoteStates.clear();
|
|
1568
|
+
this.lastUpdated.clear();
|
|
1569
|
+
this.listeners.clear();
|
|
1570
|
+
this.sendHandler = null;
|
|
1571
|
+
this.stopCleanupTimer();
|
|
1572
|
+
}
|
|
1573
|
+
// --- Private ---
|
|
1574
|
+
cleanupStaleStates() {
|
|
1575
|
+
const now = Date.now();
|
|
1576
|
+
const removed = [];
|
|
1577
|
+
for (const [clientId, lastTime] of this.lastUpdated) {
|
|
1578
|
+
if (now - lastTime > this.timeoutMs) {
|
|
1579
|
+
this.remoteStates.delete(clientId);
|
|
1580
|
+
this.lastUpdated.delete(clientId);
|
|
1581
|
+
removed.push(clientId);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
if (removed.length > 0) {
|
|
1585
|
+
const change = { added: [], updated: [], removed };
|
|
1586
|
+
this.notifyListeners(change);
|
|
1587
|
+
this.emitAwarenessEvent();
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
notifyListeners(change) {
|
|
1591
|
+
for (const listener of this.listeners) {
|
|
1592
|
+
listener(change);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
emitAwarenessEvent() {
|
|
1596
|
+
const states = this.getStates();
|
|
1597
|
+
this.emitter?.emit({ type: "awareness:updated", states });
|
|
1598
|
+
}
|
|
1599
|
+
};
|
|
1600
|
+
|
|
1172
1601
|
// src/diagnostics/bandwidth-estimator.ts
|
|
1173
1602
|
var BandwidthEstimator = class {
|
|
1174
1603
|
maxSamples;
|
|
@@ -1210,6 +1639,7 @@ var BandwidthEstimator = class {
|
|
|
1210
1639
|
const decayFactor = 0.9;
|
|
1211
1640
|
for (let i = this.samples.length - 1; i >= 0; i--) {
|
|
1212
1641
|
const sample = this.samples[i];
|
|
1642
|
+
if (!sample) continue;
|
|
1213
1643
|
const bytesPerSec = sample.bytes / sample.durationMs * 1e3;
|
|
1214
1644
|
const age = this.samples.length - 1 - i;
|
|
1215
1645
|
const weight = decayFactor ** age;
|
|
@@ -1275,7 +1705,7 @@ var SlidingWindowPercentile = class {
|
|
|
1275
1705
|
const sorted = this.samples.slice(0, this.count).sort((a, b) => a - b);
|
|
1276
1706
|
const rank = Math.ceil(p / 100 * sorted.length) - 1;
|
|
1277
1707
|
const index = Math.max(0, Math.min(rank, sorted.length - 1));
|
|
1278
|
-
return sorted[index];
|
|
1708
|
+
return sorted[index] ?? 0;
|
|
1279
1709
|
}
|
|
1280
1710
|
/**
|
|
1281
1711
|
* Get the most recently added sample, or 0 if no samples exist.
|
|
@@ -1283,7 +1713,7 @@ var SlidingWindowPercentile = class {
|
|
|
1283
1713
|
latest() {
|
|
1284
1714
|
if (this.count === 0) return 0;
|
|
1285
1715
|
const lastIndex = (this.writeIndex - 1 + this.maxSize) % this.maxSize;
|
|
1286
|
-
return this.samples[lastIndex];
|
|
1716
|
+
return this.samples[lastIndex] ?? 0;
|
|
1287
1717
|
}
|
|
1288
1718
|
/**
|
|
1289
1719
|
* Get the number of samples currently in the window.
|
|
@@ -1604,216 +2034,113 @@ var SyncMetricsCollector = class {
|
|
|
1604
2034
|
this.emitter?.emit({
|
|
1605
2035
|
type: "sync:bandwidth",
|
|
1606
2036
|
bytesPerSecond: bps,
|
|
1607
|
-
direction
|
|
1608
|
-
});
|
|
1609
|
-
}
|
|
1610
|
-
}
|
|
1611
|
-
};
|
|
1612
|
-
|
|
1613
|
-
// src/awareness/awareness-manager.ts
|
|
1614
|
-
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
1615
|
-
var nextLocalClientId = 1;
|
|
1616
|
-
var AwarenessManager = class {
|
|
1617
|
-
/** Unique client ID for this instance */
|
|
1618
|
-
clientId;
|
|
1619
|
-
localState = null;
|
|
1620
|
-
remoteStates = /* @__PURE__ */ new Map();
|
|
1621
|
-
listeners = /* @__PURE__ */ new Set();
|
|
1622
|
-
emitter;
|
|
1623
|
-
timeoutMs;
|
|
1624
|
-
// Track when we last received an update from each remote client.
|
|
1625
|
-
// Used for timeout-based cleanup as a safety net in case the server
|
|
1626
|
-
// does not send an explicit removal on disconnect.
|
|
1627
|
-
lastUpdated = /* @__PURE__ */ new Map();
|
|
1628
|
-
cleanupTimer = null;
|
|
1629
|
-
sendHandler = null;
|
|
1630
|
-
destroyed = false;
|
|
1631
|
-
constructor(options) {
|
|
1632
|
-
this.clientId = options?.clientId ?? nextLocalClientId++;
|
|
1633
|
-
this.emitter = options?.emitter ?? null;
|
|
1634
|
-
this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1635
|
-
}
|
|
1636
|
-
/**
|
|
1637
|
-
* Set the local user's awareness state and broadcast to peers.
|
|
1638
|
-
*
|
|
1639
|
-
* @param state - The awareness state to share. Pass null to clear presence.
|
|
1640
|
-
*/
|
|
1641
|
-
setLocalState(state) {
|
|
1642
|
-
if (this.destroyed) return;
|
|
1643
|
-
this.localState = state;
|
|
1644
|
-
const message = {
|
|
1645
|
-
type: "awareness",
|
|
1646
|
-
clientId: this.clientId,
|
|
1647
|
-
states: { [this.clientId]: state }
|
|
1648
|
-
};
|
|
1649
|
-
this.sendHandler?.(message);
|
|
1650
|
-
this.emitAwarenessEvent();
|
|
1651
|
-
}
|
|
1652
|
-
/**
|
|
1653
|
-
* Get the local awareness state.
|
|
1654
|
-
*/
|
|
1655
|
-
getLocalState() {
|
|
1656
|
-
return this.localState;
|
|
1657
|
-
}
|
|
1658
|
-
/**
|
|
1659
|
-
* Get all known awareness states (local + remote).
|
|
1660
|
-
* Returns a new Map on each call.
|
|
1661
|
-
*/
|
|
1662
|
-
getStates() {
|
|
1663
|
-
const result = /* @__PURE__ */ new Map();
|
|
1664
|
-
if (this.localState) {
|
|
1665
|
-
result.set(this.clientId, this.localState);
|
|
1666
|
-
}
|
|
1667
|
-
for (const [id, state] of this.remoteStates) {
|
|
1668
|
-
result.set(id, state);
|
|
1669
|
-
}
|
|
1670
|
-
return result;
|
|
1671
|
-
}
|
|
1672
|
-
/**
|
|
1673
|
-
* Handle an incoming awareness message from the transport.
|
|
1674
|
-
* This processes remote state updates and notifies listeners.
|
|
1675
|
-
*/
|
|
1676
|
-
handleRemoteMessage(message) {
|
|
1677
|
-
if (this.destroyed) return;
|
|
1678
|
-
const added = [];
|
|
1679
|
-
const updated = [];
|
|
1680
|
-
const removed = [];
|
|
1681
|
-
const now = Date.now();
|
|
1682
|
-
for (const [clientIdStr, state] of Object.entries(message.states)) {
|
|
1683
|
-
const clientId = Number(clientIdStr);
|
|
1684
|
-
if (clientId === this.clientId) continue;
|
|
1685
|
-
if (state === null) {
|
|
1686
|
-
if (this.remoteStates.has(clientId)) {
|
|
1687
|
-
this.remoteStates.delete(clientId);
|
|
1688
|
-
this.lastUpdated.delete(clientId);
|
|
1689
|
-
removed.push(clientId);
|
|
1690
|
-
}
|
|
1691
|
-
} else {
|
|
1692
|
-
if (this.remoteStates.has(clientId)) {
|
|
1693
|
-
this.remoteStates.set(clientId, state);
|
|
1694
|
-
this.lastUpdated.set(clientId, now);
|
|
1695
|
-
updated.push(clientId);
|
|
1696
|
-
} else {
|
|
1697
|
-
this.remoteStates.set(clientId, state);
|
|
1698
|
-
this.lastUpdated.set(clientId, now);
|
|
1699
|
-
added.push(clientId);
|
|
1700
|
-
}
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
if (added.length > 0 || updated.length > 0 || removed.length > 0) {
|
|
1704
|
-
const change = { added, updated, removed };
|
|
1705
|
-
this.notifyListeners(change);
|
|
1706
|
-
this.emitAwarenessEvent();
|
|
2037
|
+
direction
|
|
2038
|
+
});
|
|
1707
2039
|
}
|
|
1708
2040
|
}
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
added: [],
|
|
1720
|
-
updated: [],
|
|
1721
|
-
removed: [clientId]
|
|
1722
|
-
};
|
|
1723
|
-
this.notifyListeners(change);
|
|
1724
|
-
this.emitAwarenessEvent();
|
|
2041
|
+
};
|
|
2042
|
+
|
|
2043
|
+
// src/richtext/richtext-doc-channel.ts
|
|
2044
|
+
import { generateUUIDv7 } from "@korajs/core";
|
|
2045
|
+
|
|
2046
|
+
// src/richtext/doc-channel-wire.ts
|
|
2047
|
+
function encodeYjsUpdate(update) {
|
|
2048
|
+
let binary = "";
|
|
2049
|
+
for (let i = 0; i < update.length; i++) {
|
|
2050
|
+
binary += String.fromCharCode(update[i]);
|
|
1725
2051
|
}
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
2052
|
+
return btoa(binary);
|
|
2053
|
+
}
|
|
2054
|
+
function decodeYjsUpdate(encoded) {
|
|
2055
|
+
const binary = atob(encoded);
|
|
2056
|
+
const bytes = new Uint8Array(binary.length);
|
|
2057
|
+
for (let i = 0; i < binary.length; i++) {
|
|
2058
|
+
bytes[i] = binary.charCodeAt(i);
|
|
1732
2059
|
}
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
2060
|
+
return bytes;
|
|
2061
|
+
}
|
|
2062
|
+
function richtextDocKey(collection, recordId, field) {
|
|
2063
|
+
return `${collection}:${recordId}:${field}`;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// src/richtext/richtext-doc-channel.ts
|
|
2067
|
+
var DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD = 4096;
|
|
2068
|
+
var RichtextDocChannel = class {
|
|
2069
|
+
threshold;
|
|
2070
|
+
onSend;
|
|
2071
|
+
listeners = /* @__PURE__ */ new Map();
|
|
2072
|
+
constructor(options) {
|
|
2073
|
+
this.threshold = options?.largeDocThreshold ?? DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD;
|
|
2074
|
+
this.onSend = options?.onSend ?? null;
|
|
1742
2075
|
}
|
|
1743
2076
|
/**
|
|
1744
|
-
*
|
|
2077
|
+
* Whether the doc channel should be used for a field.
|
|
2078
|
+
* @param preference - `true` forces on, `false` forces off, `undefined` uses size threshold
|
|
1745
2079
|
*/
|
|
1746
|
-
|
|
1747
|
-
|
|
2080
|
+
shouldUseChannel(snapshotByteLength, preference) {
|
|
2081
|
+
if (preference === false) {
|
|
2082
|
+
return false;
|
|
2083
|
+
}
|
|
2084
|
+
if (preference === true) {
|
|
2085
|
+
return true;
|
|
2086
|
+
}
|
|
2087
|
+
return snapshotByteLength >= this.threshold;
|
|
1748
2088
|
}
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
* Called when the sync engine transitions to streaming state.
|
|
1752
|
-
*/
|
|
1753
|
-
startCleanupTimer() {
|
|
1754
|
-
if (this.cleanupTimer) return;
|
|
1755
|
-
this.cleanupTimer = setInterval(() => {
|
|
1756
|
-
this.cleanupStaleStates();
|
|
1757
|
-
}, this.timeoutMs);
|
|
2089
|
+
getThreshold() {
|
|
2090
|
+
return this.threshold;
|
|
1758
2091
|
}
|
|
1759
2092
|
/**
|
|
1760
|
-
*
|
|
2093
|
+
* Subscribe to incremental updates for a document key.
|
|
1761
2094
|
*/
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
2095
|
+
subscribe(collection, recordId, field, listener) {
|
|
2096
|
+
const key = richtextDocKey(collection, recordId, field);
|
|
2097
|
+
let set = this.listeners.get(key);
|
|
2098
|
+
if (!set) {
|
|
2099
|
+
set = /* @__PURE__ */ new Set();
|
|
2100
|
+
this.listeners.set(key, set);
|
|
1766
2101
|
}
|
|
2102
|
+
set.add(listener);
|
|
2103
|
+
return () => {
|
|
2104
|
+
const current = this.listeners.get(key);
|
|
2105
|
+
if (!current) {
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
current.delete(listener);
|
|
2109
|
+
if (current.size === 0) {
|
|
2110
|
+
this.listeners.delete(key);
|
|
2111
|
+
}
|
|
2112
|
+
};
|
|
1767
2113
|
}
|
|
1768
2114
|
/**
|
|
1769
|
-
*
|
|
1770
|
-
* will no longer send or receive awareness updates.
|
|
1771
|
-
* Broadcasts removal of local state before shutting down.
|
|
2115
|
+
* Publish a local incremental update to peers.
|
|
1772
2116
|
*/
|
|
1773
|
-
|
|
1774
|
-
if (this.
|
|
1775
|
-
|
|
1776
|
-
if (this.localState) {
|
|
1777
|
-
const message = {
|
|
1778
|
-
type: "awareness",
|
|
1779
|
-
clientId: this.clientId,
|
|
1780
|
-
states: { [this.clientId]: null }
|
|
1781
|
-
};
|
|
1782
|
-
this.sendHandler?.(message);
|
|
2117
|
+
send(collection, recordId, field, update) {
|
|
2118
|
+
if (!this.onSend || update.length === 0) {
|
|
2119
|
+
return;
|
|
1783
2120
|
}
|
|
1784
|
-
this.
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
2121
|
+
this.onSend({
|
|
2122
|
+
type: "yjs-doc-update",
|
|
2123
|
+
messageId: generateUUIDv7(),
|
|
2124
|
+
collection,
|
|
2125
|
+
recordId,
|
|
2126
|
+
field,
|
|
2127
|
+
update: encodeYjsUpdate(update)
|
|
2128
|
+
});
|
|
1790
2129
|
}
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
removed.push(clientId);
|
|
1800
|
-
}
|
|
1801
|
-
}
|
|
1802
|
-
if (removed.length > 0) {
|
|
1803
|
-
const change = { added: [], updated: [], removed };
|
|
1804
|
-
this.notifyListeners(change);
|
|
1805
|
-
this.emitAwarenessEvent();
|
|
2130
|
+
/**
|
|
2131
|
+
* Deliver a remote incremental update to local subscribers.
|
|
2132
|
+
*/
|
|
2133
|
+
deliver(message) {
|
|
2134
|
+
const key = richtextDocKey(message.collection, message.recordId, message.field);
|
|
2135
|
+
const set = this.listeners.get(key);
|
|
2136
|
+
if (!set || set.size === 0) {
|
|
2137
|
+
return;
|
|
1806
2138
|
}
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
listener(change);
|
|
2139
|
+
const update = decodeYjsUpdate(message.update);
|
|
2140
|
+
for (const listener of set) {
|
|
2141
|
+
listener(update);
|
|
1811
2142
|
}
|
|
1812
2143
|
}
|
|
1813
|
-
emitAwarenessEvent() {
|
|
1814
|
-
const states = this.getStates();
|
|
1815
|
-
this.emitter?.emit({ type: "awareness:updated", states });
|
|
1816
|
-
}
|
|
1817
2144
|
};
|
|
1818
2145
|
|
|
1819
2146
|
// src/engine/outbound-queue.ts
|
|
@@ -1928,6 +2255,19 @@ var OutboundQueue = class {
|
|
|
1928
2255
|
get isInitialized() {
|
|
1929
2256
|
return this.initialized;
|
|
1930
2257
|
}
|
|
2258
|
+
/**
|
|
2259
|
+
* Remove operations by id from queue and persistent storage.
|
|
2260
|
+
* Used when ops were already sent during handshake delta exchange.
|
|
2261
|
+
*/
|
|
2262
|
+
async removeByIds(ids) {
|
|
2263
|
+
if (ids.length === 0) return;
|
|
2264
|
+
const idSet = new Set(ids);
|
|
2265
|
+
this.queue = this.queue.filter((op) => !idSet.has(op.id));
|
|
2266
|
+
for (const id of ids) {
|
|
2267
|
+
this.seen.delete(id);
|
|
2268
|
+
}
|
|
2269
|
+
await this.storage.dequeue(ids);
|
|
2270
|
+
}
|
|
1931
2271
|
};
|
|
1932
2272
|
|
|
1933
2273
|
// src/engine/sync-engine.ts
|
|
@@ -1942,6 +2282,7 @@ var VALID_TRANSITIONS = {
|
|
|
1942
2282
|
error: ["disconnected"]
|
|
1943
2283
|
};
|
|
1944
2284
|
var nextMessageId = 0;
|
|
2285
|
+
var nextQuerySubsetId = 0;
|
|
1945
2286
|
function generateMessageId() {
|
|
1946
2287
|
return `msg-${Date.now()}-${nextMessageId++}`;
|
|
1947
2288
|
}
|
|
@@ -1956,24 +2297,39 @@ var SyncEngine = class {
|
|
|
1956
2297
|
batchSize;
|
|
1957
2298
|
encryptor;
|
|
1958
2299
|
awarenessManager;
|
|
2300
|
+
richtextDocChannel;
|
|
1959
2301
|
metricsCollector;
|
|
2302
|
+
syncState;
|
|
1960
2303
|
remoteVector = /* @__PURE__ */ new Map();
|
|
2304
|
+
lastAckedServerVector = /* @__PURE__ */ new Map();
|
|
2305
|
+
cachedUnsyncedCount = 0;
|
|
1961
2306
|
lastSyncedAt = null;
|
|
1962
2307
|
lastSuccessfulPush = null;
|
|
1963
2308
|
lastSuccessfulPull = null;
|
|
1964
2309
|
conflictCount = 0;
|
|
1965
2310
|
currentBatch = null;
|
|
1966
2311
|
reconnecting = false;
|
|
2312
|
+
schemaBlocked = false;
|
|
1967
2313
|
// Track delta exchange state
|
|
1968
2314
|
deltaBatchesReceived = 0;
|
|
1969
2315
|
deltaReceiveComplete = false;
|
|
1970
2316
|
deltaSendComplete = false;
|
|
2317
|
+
/** Op ids sent during handshake delta — removed from outbound queue to avoid duplicate send */
|
|
2318
|
+
deltaSentOpIds = [];
|
|
2319
|
+
/** Outbound delta batch message IDs awaiting ACK when strictHandshake is enabled */
|
|
2320
|
+
pendingDeltaBatchAcks = /* @__PURE__ */ new Set();
|
|
1971
2321
|
/**
|
|
1972
2322
|
* The effective scope for this sync session.
|
|
1973
2323
|
* Starts as the configured scopeMap. After handshake, may be replaced
|
|
1974
2324
|
* with the server-accepted scope (server is authoritative).
|
|
1975
2325
|
*/
|
|
1976
2326
|
activeScope;
|
|
2327
|
+
/** Live query subsets registered from reactive subscriptions */
|
|
2328
|
+
querySubsets = /* @__PURE__ */ new Map();
|
|
2329
|
+
querySubsetReconnectTimer = null;
|
|
2330
|
+
/** Resume cursor for paginated initial sync (persisted across reconnects) */
|
|
2331
|
+
resumeDeltaCursor = null;
|
|
2332
|
+
initialSyncTotalBatches = 0;
|
|
1977
2333
|
constructor(options) {
|
|
1978
2334
|
this.transport = options.transport;
|
|
1979
2335
|
this.store = options.store;
|
|
@@ -1982,6 +2338,7 @@ var SyncEngine = class {
|
|
|
1982
2338
|
this.emitter = options.emitter ?? null;
|
|
1983
2339
|
this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
1984
2340
|
this.encryptor = options.encryptor ?? null;
|
|
2341
|
+
this.syncState = options.syncState ?? null;
|
|
1985
2342
|
this.activeScope = options.config.scopeMap;
|
|
1986
2343
|
const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
|
|
1987
2344
|
this.outboundQueue = new OutboundQueue(queueStorage);
|
|
@@ -1992,6 +2349,15 @@ var SyncEngine = class {
|
|
|
1992
2349
|
this.awarenessManager = new AwarenessManager({
|
|
1993
2350
|
emitter: this.emitter ?? void 0
|
|
1994
2351
|
});
|
|
2352
|
+
this.richtextDocChannel = new RichtextDocChannel({
|
|
2353
|
+
largeDocThreshold: options.config.richtextDocChannelThreshold,
|
|
2354
|
+
onSend: (message) => {
|
|
2355
|
+
if (this.state !== "streaming") {
|
|
2356
|
+
return;
|
|
2357
|
+
}
|
|
2358
|
+
this.transport.send(message);
|
|
2359
|
+
}
|
|
2360
|
+
});
|
|
1995
2361
|
this.awarenessManager.onSend((message) => {
|
|
1996
2362
|
if (this.state !== "streaming") return;
|
|
1997
2363
|
const wireMessage = {
|
|
@@ -2013,15 +2379,30 @@ var SyncEngine = class {
|
|
|
2013
2379
|
});
|
|
2014
2380
|
}
|
|
2015
2381
|
await this.outboundQueue.initialize();
|
|
2016
|
-
this.
|
|
2382
|
+
if (this.syncState) {
|
|
2383
|
+
this.lastAckedServerVector = await this.syncState.loadLastAckedServerVector();
|
|
2384
|
+
if (this.syncState.loadDeltaCursor) {
|
|
2385
|
+
this.resumeDeltaCursor = await this.syncState.loadDeltaCursor();
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
await this.reconcileOutboundFromOpLog();
|
|
2389
|
+
await this.refreshPendingCount();
|
|
2390
|
+
this.transport.onMessage((msg) => this.enqueueMessage(msg));
|
|
2017
2391
|
this.transport.onClose((reason) => this.handleTransportClose(reason));
|
|
2018
2392
|
this.transport.onError((err) => this.handleTransportError(err));
|
|
2393
|
+
if (this.schemaBlocked) {
|
|
2394
|
+
throw new SyncError4(
|
|
2395
|
+
"Sync is blocked due to schema version mismatch. Upgrade the app schema or align sync.schemaVersion with the server.",
|
|
2396
|
+
{ code: "SCHEMA_MISMATCH_BLOCKED" }
|
|
2397
|
+
);
|
|
2398
|
+
}
|
|
2019
2399
|
this.transitionTo("connecting");
|
|
2020
2400
|
try {
|
|
2021
2401
|
const authToken = this.config.auth ? (await this.config.auth()).token : void 0;
|
|
2022
2402
|
await this.transport.connect(this.config.url, { authToken });
|
|
2023
2403
|
this.transitionTo("handshaking");
|
|
2024
2404
|
const localVector = this.store.getVersionVector();
|
|
2405
|
+
const activeQuerySubsets = this.getActiveQuerySubsets();
|
|
2025
2406
|
const handshake = {
|
|
2026
2407
|
type: "handshake",
|
|
2027
2408
|
messageId: generateMessageId(),
|
|
@@ -2030,7 +2411,9 @@ var SyncEngine = class {
|
|
|
2030
2411
|
schemaVersion: this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION,
|
|
2031
2412
|
authToken,
|
|
2032
2413
|
supportedWireFormats: ["json", "protobuf"],
|
|
2033
|
-
...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {}
|
|
2414
|
+
...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
|
|
2415
|
+
...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
|
|
2416
|
+
...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {}
|
|
2034
2417
|
};
|
|
2035
2418
|
this.transport.send(handshake);
|
|
2036
2419
|
} catch (err) {
|
|
@@ -2067,10 +2450,16 @@ var SyncEngine = class {
|
|
|
2067
2450
|
* because they should remain local-only and not be sent to the server.
|
|
2068
2451
|
*/
|
|
2069
2452
|
async pushOperation(op) {
|
|
2070
|
-
if (!
|
|
2453
|
+
if (!this.operationAllowedForSync(op)) {
|
|
2071
2454
|
return;
|
|
2072
2455
|
}
|
|
2456
|
+
if (this.syncState) {
|
|
2457
|
+
this.cachedUnsyncedCount++;
|
|
2458
|
+
}
|
|
2073
2459
|
await this.outboundQueue.enqueue(op);
|
|
2460
|
+
if (this.syncState) {
|
|
2461
|
+
await this.refreshPendingCount();
|
|
2462
|
+
}
|
|
2074
2463
|
if (this.state === "streaming") {
|
|
2075
2464
|
this.flushQueue();
|
|
2076
2465
|
}
|
|
@@ -2088,7 +2477,7 @@ var SyncEngine = class {
|
|
|
2088
2477
|
* Get the current developer-facing sync status.
|
|
2089
2478
|
*/
|
|
2090
2479
|
getStatus() {
|
|
2091
|
-
const pendingOperations = this.outboundQueue.totalPending;
|
|
2480
|
+
const pendingOperations = this.syncState ? this.cachedUnsyncedCount : this.outboundQueue.totalPending;
|
|
2092
2481
|
const base = {
|
|
2093
2482
|
pendingOperations,
|
|
2094
2483
|
lastSyncedAt: this.lastSyncedAt,
|
|
@@ -2106,7 +2495,24 @@ var SyncEngine = class {
|
|
|
2106
2495
|
case "streaming":
|
|
2107
2496
|
return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
|
|
2108
2497
|
case "error":
|
|
2109
|
-
return { ...base, status: "error" };
|
|
2498
|
+
return { ...base, status: this.schemaBlocked ? "schema-mismatch" : "error" };
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
/**
|
|
2502
|
+
* True when the server rejected the client's schema version at handshake.
|
|
2503
|
+
* Sync stays blocked until the app schema is upgraded or sync config changes.
|
|
2504
|
+
*/
|
|
2505
|
+
isSchemaBlocked() {
|
|
2506
|
+
return this.schemaBlocked;
|
|
2507
|
+
}
|
|
2508
|
+
/**
|
|
2509
|
+
* Clears schema-mismatch block after upgrading the local schema / sync config.
|
|
2510
|
+
* Moves the engine back to `disconnected` so `start()` can run again.
|
|
2511
|
+
*/
|
|
2512
|
+
clearSchemaBlock() {
|
|
2513
|
+
this.schemaBlocked = false;
|
|
2514
|
+
if (this.state === "error") {
|
|
2515
|
+
this.transitionTo("disconnected");
|
|
2110
2516
|
}
|
|
2111
2517
|
}
|
|
2112
2518
|
/**
|
|
@@ -2116,11 +2522,31 @@ var SyncEngine = class {
|
|
|
2116
2522
|
recordConflict() {
|
|
2117
2523
|
this.conflictCount++;
|
|
2118
2524
|
}
|
|
2525
|
+
/**
|
|
2526
|
+
* Count of local operations not yet covered by the last acked server vector (op-log source of truth).
|
|
2527
|
+
*/
|
|
2528
|
+
async getUnsyncedOperationCount() {
|
|
2529
|
+
await this.refreshPendingCount();
|
|
2530
|
+
return this.getStatus().pendingOperations;
|
|
2531
|
+
}
|
|
2532
|
+
emitApplyFailure(op, result, overrides) {
|
|
2533
|
+
const reason = defaultApplyFailureReason(result, overrides);
|
|
2534
|
+
this.emitter?.emit({
|
|
2535
|
+
type: "sync:apply-failed",
|
|
2536
|
+
operationId: op.id,
|
|
2537
|
+
collection: op.collection,
|
|
2538
|
+
recordId: op.recordId,
|
|
2539
|
+
code: reason.code,
|
|
2540
|
+
message: reason.message,
|
|
2541
|
+
retriable: reason.retriable
|
|
2542
|
+
});
|
|
2543
|
+
}
|
|
2119
2544
|
/**
|
|
2120
2545
|
* Force an immediate reconnection attempt. If the engine is disconnected
|
|
2121
2546
|
* or in error state, restarts the sync. If already connected, no-op.
|
|
2122
2547
|
*/
|
|
2123
2548
|
async retryNow() {
|
|
2549
|
+
if (this.schemaBlocked) return;
|
|
2124
2550
|
if (this.state === "disconnected" || this.state === "error") {
|
|
2125
2551
|
this.reconnecting = false;
|
|
2126
2552
|
await this.start();
|
|
@@ -2181,6 +2607,25 @@ var SyncEngine = class {
|
|
|
2181
2607
|
getActiveScope() {
|
|
2182
2608
|
return this.activeScope;
|
|
2183
2609
|
}
|
|
2610
|
+
/**
|
|
2611
|
+
* Register a live query subset that narrows synced data for a collection.
|
|
2612
|
+
* Takes effect on the next connection; reconnects when already connected.
|
|
2613
|
+
*/
|
|
2614
|
+
registerQuerySubset(subset) {
|
|
2615
|
+
const id = `query-${nextQuerySubsetId++}`;
|
|
2616
|
+
this.querySubsets.set(id, subset);
|
|
2617
|
+
this.scheduleQuerySubsetReconnect();
|
|
2618
|
+
return () => {
|
|
2619
|
+
this.querySubsets.delete(id);
|
|
2620
|
+
this.scheduleQuerySubsetReconnect();
|
|
2621
|
+
};
|
|
2622
|
+
}
|
|
2623
|
+
/**
|
|
2624
|
+
* Returns deduplicated active query subsets from registered subscriptions.
|
|
2625
|
+
*/
|
|
2626
|
+
getActiveQuerySubsets() {
|
|
2627
|
+
return dedupeQuerySubsets([...this.querySubsets.values()]);
|
|
2628
|
+
}
|
|
2184
2629
|
/**
|
|
2185
2630
|
* Get the awareness manager for collaborative presence.
|
|
2186
2631
|
* Use this to set local presence, observe remote collaborators,
|
|
@@ -2189,14 +2634,24 @@ var SyncEngine = class {
|
|
|
2189
2634
|
getAwarenessManager() {
|
|
2190
2635
|
return this.awarenessManager;
|
|
2191
2636
|
}
|
|
2637
|
+
/**
|
|
2638
|
+
* Optional side channel for incremental Yjs updates on large richtext fields.
|
|
2639
|
+
*/
|
|
2640
|
+
getRichtextDocChannel() {
|
|
2641
|
+
return this.richtextDocChannel;
|
|
2642
|
+
}
|
|
2192
2643
|
// --- Private methods ---
|
|
2193
|
-
|
|
2644
|
+
messageChain = Promise.resolve();
|
|
2645
|
+
enqueueMessage(message) {
|
|
2646
|
+
this.messageChain = this.messageChain.then(() => this.handleMessageAsync(message)).catch((error) => this.handleMessageFailure(error));
|
|
2647
|
+
}
|
|
2648
|
+
async handleMessageAsync(message) {
|
|
2194
2649
|
switch (message.type) {
|
|
2195
2650
|
case "handshake-response":
|
|
2196
2651
|
this.handleHandshakeResponse(message);
|
|
2197
2652
|
break;
|
|
2198
2653
|
case "operation-batch":
|
|
2199
|
-
this.handleOperationBatch(message);
|
|
2654
|
+
await this.handleOperationBatch(message);
|
|
2200
2655
|
break;
|
|
2201
2656
|
case "acknowledgment":
|
|
2202
2657
|
this.handleAcknowledgment(message);
|
|
@@ -2207,20 +2662,46 @@ var SyncEngine = class {
|
|
|
2207
2662
|
case "awareness-update":
|
|
2208
2663
|
this.handleAwarenessUpdate(message);
|
|
2209
2664
|
break;
|
|
2665
|
+
case "yjs-doc-update":
|
|
2666
|
+
this.richtextDocChannel.deliver(message);
|
|
2667
|
+
break;
|
|
2210
2668
|
}
|
|
2211
2669
|
}
|
|
2670
|
+
handleMessageFailure(error) {
|
|
2671
|
+
const reason = error instanceof Error ? error.message : "Message handling failed";
|
|
2672
|
+
this.handleTransportClose(reason);
|
|
2673
|
+
}
|
|
2212
2674
|
handleHandshakeResponse(msg) {
|
|
2213
2675
|
if (this.state !== "handshaking") return;
|
|
2214
2676
|
if (!msg.accepted) {
|
|
2677
|
+
const reason = msg.rejectReason ?? "Handshake rejected";
|
|
2678
|
+
if (isSchemaMismatchReject(msg.rejectReason)) {
|
|
2679
|
+
this.schemaBlocked = true;
|
|
2680
|
+
const supportedMin = msg.supportedSchemaMin ?? msg.schemaVersion;
|
|
2681
|
+
const supportedMax = msg.supportedSchemaMax ?? msg.schemaVersion;
|
|
2682
|
+
this.emitter?.emit({
|
|
2683
|
+
type: "sync:schema-mismatch",
|
|
2684
|
+
clientSchemaVersion: this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION,
|
|
2685
|
+
serverSchemaVersion: msg.schemaVersion,
|
|
2686
|
+
supportedMin,
|
|
2687
|
+
supportedMax,
|
|
2688
|
+
reason
|
|
2689
|
+
});
|
|
2690
|
+
this.metricsCollector.updateStatus("error");
|
|
2691
|
+
this.transitionTo("error");
|
|
2692
|
+
void this.transport.disconnect();
|
|
2693
|
+
return;
|
|
2694
|
+
}
|
|
2215
2695
|
this.transitionTo("error");
|
|
2216
2696
|
this.emitter?.emit({
|
|
2217
2697
|
type: "sync:disconnected",
|
|
2218
|
-
reason
|
|
2698
|
+
reason
|
|
2219
2699
|
});
|
|
2220
2700
|
this.transitionTo("disconnected");
|
|
2221
2701
|
return;
|
|
2222
2702
|
}
|
|
2223
2703
|
this.remoteVector = wireToVersionVector(msg.versionVector);
|
|
2704
|
+
void this.persistLastAckedServerVector(this.remoteVector);
|
|
2224
2705
|
if (msg.selectedWireFormat) {
|
|
2225
2706
|
this.setSerializerWireFormat(msg.selectedWireFormat);
|
|
2226
2707
|
}
|
|
@@ -2235,38 +2716,57 @@ var SyncEngine = class {
|
|
|
2235
2716
|
this.deltaBatchesReceived = 0;
|
|
2236
2717
|
this.deltaReceiveComplete = false;
|
|
2237
2718
|
this.deltaSendComplete = false;
|
|
2719
|
+
this.deltaSentOpIds = [];
|
|
2720
|
+
this.pendingDeltaBatchAcks.clear();
|
|
2721
|
+
this.initialSyncTotalBatches = 0;
|
|
2238
2722
|
this.sendDelta();
|
|
2239
2723
|
}
|
|
2240
2724
|
async sendDelta() {
|
|
2241
2725
|
const localVector = this.store.getVersionVector();
|
|
2242
2726
|
const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
|
|
2243
|
-
const missingOps = allMissingOps.filter((op) =>
|
|
2727
|
+
const missingOps = allMissingOps.filter((op) => this.operationAllowedForSync(op));
|
|
2728
|
+
this.deltaSentOpIds = missingOps.map((op) => op.id);
|
|
2244
2729
|
if (missingOps.length === 0) {
|
|
2730
|
+
const messageId = generateMessageId();
|
|
2731
|
+
if (this.config.strictHandshake) {
|
|
2732
|
+
this.pendingDeltaBatchAcks.add(messageId);
|
|
2733
|
+
}
|
|
2245
2734
|
const emptyBatch = {
|
|
2246
2735
|
type: "operation-batch",
|
|
2247
|
-
messageId
|
|
2736
|
+
messageId,
|
|
2248
2737
|
operations: [],
|
|
2249
2738
|
isFinal: true,
|
|
2250
|
-
batchIndex: 0
|
|
2739
|
+
batchIndex: 0,
|
|
2740
|
+
totalBatches: 1
|
|
2251
2741
|
};
|
|
2252
2742
|
this.transport.send(emptyBatch);
|
|
2253
|
-
this.
|
|
2254
|
-
|
|
2743
|
+
if (!this.config.strictHandshake) {
|
|
2744
|
+
this.deltaSendComplete = true;
|
|
2745
|
+
void this.checkDeltaComplete();
|
|
2746
|
+
}
|
|
2255
2747
|
return;
|
|
2256
2748
|
}
|
|
2257
2749
|
const sorted = topologicalSort2(missingOps);
|
|
2258
2750
|
const totalBatches = Math.ceil(sorted.length / this.batchSize);
|
|
2751
|
+
this.initialSyncTotalBatches = Math.max(this.initialSyncTotalBatches, totalBatches);
|
|
2259
2752
|
for (let i = 0; i < totalBatches; i++) {
|
|
2260
2753
|
const start = i * this.batchSize;
|
|
2261
2754
|
const batchOps = sorted.slice(start, start + this.batchSize);
|
|
2755
|
+
const batchCursor = createDeltaCursorFromBatch(batchOps, i);
|
|
2262
2756
|
const opsToSerialize = this.encryptor ? await this.encryptor.encryptBatch(batchOps) : batchOps;
|
|
2263
2757
|
const serializedOps = opsToSerialize.map((op) => this.serializer.encodeOperation(op));
|
|
2758
|
+
const messageId = generateMessageId();
|
|
2759
|
+
if (this.config.strictHandshake) {
|
|
2760
|
+
this.pendingDeltaBatchAcks.add(messageId);
|
|
2761
|
+
}
|
|
2264
2762
|
const batchMsg = {
|
|
2265
2763
|
type: "operation-batch",
|
|
2266
|
-
messageId
|
|
2764
|
+
messageId,
|
|
2267
2765
|
operations: serializedOps,
|
|
2268
2766
|
isFinal: i === totalBatches - 1,
|
|
2269
|
-
batchIndex: i
|
|
2767
|
+
batchIndex: i,
|
|
2768
|
+
totalBatches,
|
|
2769
|
+
...batchCursor ? { cursor: encodeDeltaCursor(batchCursor) } : {}
|
|
2270
2770
|
};
|
|
2271
2771
|
this.transport.send(batchMsg);
|
|
2272
2772
|
this.emitter?.emit({
|
|
@@ -2275,8 +2775,17 @@ var SyncEngine = class {
|
|
|
2275
2775
|
batchSize: batchOps.length
|
|
2276
2776
|
});
|
|
2277
2777
|
}
|
|
2778
|
+
if (!this.config.strictHandshake) {
|
|
2779
|
+
this.deltaSendComplete = true;
|
|
2780
|
+
void this.checkDeltaComplete();
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
markDeltaSendCompleteIfReady() {
|
|
2784
|
+
if (this.config.strictHandshake && this.pendingDeltaBatchAcks.size > 0) {
|
|
2785
|
+
return;
|
|
2786
|
+
}
|
|
2278
2787
|
this.deltaSendComplete = true;
|
|
2279
|
-
this.checkDeltaComplete();
|
|
2788
|
+
void this.checkDeltaComplete();
|
|
2280
2789
|
}
|
|
2281
2790
|
async collectDelta(localVector, remoteVector) {
|
|
2282
2791
|
const missing = [];
|
|
@@ -2292,9 +2801,36 @@ var SyncEngine = class {
|
|
|
2292
2801
|
async handleOperationBatch(msg) {
|
|
2293
2802
|
const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
2294
2803
|
const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
|
|
2295
|
-
const inScopeOps = operations.filter((op) =>
|
|
2804
|
+
const inScopeOps = operations.filter((op) => this.operationAllowedForSync(op));
|
|
2805
|
+
const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
2806
|
+
const transforms = this.config.operationTransforms ?? [];
|
|
2296
2807
|
for (const op of inScopeOps) {
|
|
2297
|
-
|
|
2808
|
+
const transformed = transforms.length > 0 ? applyOperationTransforms(op, targetSchemaVersion, transforms) : op;
|
|
2809
|
+
if (transformed === null) {
|
|
2810
|
+
continue;
|
|
2811
|
+
}
|
|
2812
|
+
try {
|
|
2813
|
+
const result = await this.store.applyRemoteOperation(transformed);
|
|
2814
|
+
if (result === "skipped" || result === "rejected" || result === "deferred") {
|
|
2815
|
+
this.emitApplyFailure(transformed, result);
|
|
2816
|
+
}
|
|
2817
|
+
} catch (error) {
|
|
2818
|
+
if (error instanceof ClockDriftError) {
|
|
2819
|
+
this.emitApplyFailure(transformed, "rejected", {
|
|
2820
|
+
code: APPLY_FAILURE_CODES.CLOCK_DRIFT,
|
|
2821
|
+
message: error.message,
|
|
2822
|
+
retriable: false
|
|
2823
|
+
});
|
|
2824
|
+
continue;
|
|
2825
|
+
}
|
|
2826
|
+
const message = error instanceof Error ? error.message : "Apply failed";
|
|
2827
|
+
const code = error instanceof SyncError4 ? error.code : error instanceof KoraError2 ? error.code : error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : APPLY_FAILURE_CODES.APPLY_FAILED;
|
|
2828
|
+
this.emitApplyFailure(transformed, "rejected", {
|
|
2829
|
+
code,
|
|
2830
|
+
message,
|
|
2831
|
+
retriable: code !== APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY
|
|
2832
|
+
});
|
|
2833
|
+
}
|
|
2298
2834
|
}
|
|
2299
2835
|
if (inScopeOps.length > 0) {
|
|
2300
2836
|
this.lastSuccessfulPull = Date.now();
|
|
@@ -2318,13 +2854,33 @@ var SyncEngine = class {
|
|
|
2318
2854
|
});
|
|
2319
2855
|
if (this.state === "syncing") {
|
|
2320
2856
|
this.deltaBatchesReceived++;
|
|
2857
|
+
const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
|
|
2858
|
+
if (msg.totalBatches !== void 0) {
|
|
2859
|
+
this.initialSyncTotalBatches = msg.totalBatches;
|
|
2860
|
+
}
|
|
2861
|
+
this.metricsCollector.updateInitialSyncProgress(this.deltaBatchesReceived, totalBatches);
|
|
2862
|
+
const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
|
|
2863
|
+
inScopeOps.length > 0 ? inScopeOps : operations,
|
|
2864
|
+
msg.batchIndex
|
|
2865
|
+
);
|
|
2866
|
+
if (cursorFromBatch) {
|
|
2867
|
+
this.resumeDeltaCursor = cursorFromBatch;
|
|
2868
|
+
await this.persistDeltaCursor(cursorFromBatch);
|
|
2869
|
+
}
|
|
2321
2870
|
if (msg.isFinal) {
|
|
2322
2871
|
this.deltaReceiveComplete = true;
|
|
2323
|
-
this.
|
|
2872
|
+
this.resumeDeltaCursor = null;
|
|
2873
|
+
await this.persistDeltaCursor(null);
|
|
2874
|
+
this.metricsCollector.recordSyncCompleted();
|
|
2875
|
+
void this.checkDeltaComplete();
|
|
2324
2876
|
}
|
|
2325
2877
|
}
|
|
2326
2878
|
}
|
|
2327
2879
|
handleAcknowledgment(msg) {
|
|
2880
|
+
if (this.state === "syncing" && this.config.strictHandshake) {
|
|
2881
|
+
this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
|
|
2882
|
+
this.markDeltaSendCompleteIfReady();
|
|
2883
|
+
}
|
|
2328
2884
|
if (this.currentBatch) {
|
|
2329
2885
|
this.outboundQueue.acknowledge(this.currentBatch.batchId);
|
|
2330
2886
|
this.currentBatch = null;
|
|
@@ -2332,6 +2888,9 @@ var SyncEngine = class {
|
|
|
2332
2888
|
this.lastSyncedAt = now;
|
|
2333
2889
|
this.lastSuccessfulPush = now;
|
|
2334
2890
|
}
|
|
2891
|
+
void this.advanceLastAckedForLocalNode(msg.lastSequenceNumber).then(
|
|
2892
|
+
() => this.refreshPendingCount()
|
|
2893
|
+
);
|
|
2335
2894
|
if (this.state === "streaming" && this.outboundQueue.hasOperations) {
|
|
2336
2895
|
this.flushQueue();
|
|
2337
2896
|
}
|
|
@@ -2344,18 +2903,107 @@ var SyncEngine = class {
|
|
|
2344
2903
|
this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
|
|
2345
2904
|
this.transitionTo("disconnected");
|
|
2346
2905
|
}
|
|
2347
|
-
checkDeltaComplete() {
|
|
2348
|
-
if (this.deltaSendComplete
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2906
|
+
async checkDeltaComplete() {
|
|
2907
|
+
if (!this.deltaSendComplete || !this.deltaReceiveComplete) {
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
if (this.state !== "syncing") {
|
|
2911
|
+
return;
|
|
2912
|
+
}
|
|
2913
|
+
this.lastSyncedAt = Date.now();
|
|
2914
|
+
this.transitionTo("streaming");
|
|
2915
|
+
this.awarenessManager.startCleanupTimer();
|
|
2916
|
+
const localState = this.awarenessManager.getLocalState();
|
|
2917
|
+
if (localState) {
|
|
2918
|
+
this.awarenessManager.setLocalState(localState);
|
|
2919
|
+
}
|
|
2920
|
+
if (this.deltaSentOpIds.length > 0) {
|
|
2921
|
+
await this.outboundQueue.removeByIds(this.deltaSentOpIds);
|
|
2922
|
+
this.deltaSentOpIds = [];
|
|
2923
|
+
}
|
|
2924
|
+
if (this.syncState) {
|
|
2925
|
+
const localNodeId = this.store.getNodeId();
|
|
2926
|
+
const localSeq = this.store.getVersionVector().get(localNodeId) ?? 0;
|
|
2927
|
+
if (localSeq > 0) {
|
|
2928
|
+
await this.advanceLastAckedForLocalNode(localSeq);
|
|
2355
2929
|
}
|
|
2356
|
-
|
|
2357
|
-
|
|
2930
|
+
}
|
|
2931
|
+
if (this.outboundQueue.hasOperations) {
|
|
2932
|
+
this.flushQueue();
|
|
2933
|
+
}
|
|
2934
|
+
await this.refreshPendingCount();
|
|
2935
|
+
}
|
|
2936
|
+
/**
|
|
2937
|
+
* Effective server vector: persisted last-ack merged with live handshake remote vector.
|
|
2938
|
+
*/
|
|
2939
|
+
getEffectiveServerVector() {
|
|
2940
|
+
if (!this.syncState) {
|
|
2941
|
+
return this.remoteVector;
|
|
2942
|
+
}
|
|
2943
|
+
return this.syncState.mergeServerVectors(this.lastAckedServerVector, this.remoteVector);
|
|
2944
|
+
}
|
|
2945
|
+
async persistLastAckedServerVector(vector) {
|
|
2946
|
+
if (!this.syncState) {
|
|
2947
|
+
return;
|
|
2948
|
+
}
|
|
2949
|
+
this.lastAckedServerVector = this.syncState.mergeServerVectors(
|
|
2950
|
+
this.lastAckedServerVector,
|
|
2951
|
+
vector
|
|
2952
|
+
);
|
|
2953
|
+
await this.syncState.saveLastAckedServerVector(this.lastAckedServerVector);
|
|
2954
|
+
}
|
|
2955
|
+
async advanceLastAckedForLocalNode(lastSequenceNumber) {
|
|
2956
|
+
if (!this.syncState || lastSequenceNumber <= 0) {
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
const nodeId = this.store.getNodeId();
|
|
2960
|
+
const merged = new Map(this.lastAckedServerVector);
|
|
2961
|
+
merged.set(nodeId, Math.max(merged.get(nodeId) ?? 0, lastSequenceNumber));
|
|
2962
|
+
this.lastAckedServerVector = merged;
|
|
2963
|
+
await this.syncState.saveLastAckedServerVector(merged);
|
|
2964
|
+
}
|
|
2965
|
+
/**
|
|
2966
|
+
* Refresh cached unsynced count from op log vs effective server vector.
|
|
2967
|
+
*/
|
|
2968
|
+
async refreshPendingCount() {
|
|
2969
|
+
if (!this.syncState) {
|
|
2970
|
+
this.cachedUnsyncedCount = this.outboundQueue.totalPending;
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
this.cachedUnsyncedCount = await this.syncState.countUnsyncedOperations(
|
|
2974
|
+
this.getEffectiveServerVector()
|
|
2975
|
+
);
|
|
2976
|
+
}
|
|
2977
|
+
/**
|
|
2978
|
+
* Returns operations the server has not yet acknowledged (op-log source of truth).
|
|
2979
|
+
*/
|
|
2980
|
+
async getPendingSyncOperations() {
|
|
2981
|
+
if (!this.syncState) {
|
|
2982
|
+
return this.outboundQueue.peek(Number.MAX_SAFE_INTEGER);
|
|
2983
|
+
}
|
|
2984
|
+
return this.syncState.getUnsyncedOperations(this.getEffectiveServerVector());
|
|
2985
|
+
}
|
|
2986
|
+
/**
|
|
2987
|
+
* Hydrate the outbound queue from unsynced ops in the op log (deduped by operation id).
|
|
2988
|
+
*/
|
|
2989
|
+
async reconcileOutboundFromOpLog() {
|
|
2990
|
+
if (this.syncState) {
|
|
2991
|
+
const unsynced = await this.syncState.getUnsyncedOperations(this.getEffectiveServerVector());
|
|
2992
|
+
for (const op of unsynced) {
|
|
2993
|
+
await this.outboundQueue.enqueue(op);
|
|
2358
2994
|
}
|
|
2995
|
+
return;
|
|
2996
|
+
}
|
|
2997
|
+
const localNodeId = this.store.getNodeId();
|
|
2998
|
+
const localVector = this.store.getVersionVector();
|
|
2999
|
+
const localSeq = localVector.get(localNodeId) ?? 0;
|
|
3000
|
+
if (localSeq === 0) {
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
3003
|
+
const ops = await this.store.getOperationRange(localNodeId, 1, localSeq);
|
|
3004
|
+
const inScope = ops.filter((op) => this.operationAllowedForSync(op));
|
|
3005
|
+
for (const op of inScope) {
|
|
3006
|
+
await this.outboundQueue.enqueue(op);
|
|
2359
3007
|
}
|
|
2360
3008
|
}
|
|
2361
3009
|
flushQueue() {
|
|
@@ -2421,6 +3069,9 @@ var SyncEngine = class {
|
|
|
2421
3069
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
2422
3070
|
this.currentBatch = null;
|
|
2423
3071
|
}
|
|
3072
|
+
if (this.schemaBlocked) {
|
|
3073
|
+
return;
|
|
3074
|
+
}
|
|
2424
3075
|
if (this.state !== "disconnected") {
|
|
2425
3076
|
this.emitter?.emit({ type: "sync:disconnected", reason });
|
|
2426
3077
|
this.transitionTo("disconnected");
|
|
@@ -2448,6 +3099,33 @@ var SyncEngine = class {
|
|
|
2448
3099
|
this.serializer.setWireFormat(format);
|
|
2449
3100
|
}
|
|
2450
3101
|
}
|
|
3102
|
+
operationAllowedForSync(op) {
|
|
3103
|
+
if (!operationMatchesScope(op, this.activeScope)) {
|
|
3104
|
+
return false;
|
|
3105
|
+
}
|
|
3106
|
+
return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets());
|
|
3107
|
+
}
|
|
3108
|
+
async persistDeltaCursor(cursor) {
|
|
3109
|
+
if (!this.syncState?.saveDeltaCursor) {
|
|
3110
|
+
return;
|
|
3111
|
+
}
|
|
3112
|
+
await this.syncState.saveDeltaCursor(cursor);
|
|
3113
|
+
}
|
|
3114
|
+
scheduleQuerySubsetReconnect() {
|
|
3115
|
+
if (this.querySubsetReconnectTimer) {
|
|
3116
|
+
clearTimeout(this.querySubsetReconnectTimer);
|
|
3117
|
+
}
|
|
3118
|
+
this.querySubsetReconnectTimer = setTimeout(() => {
|
|
3119
|
+
this.querySubsetReconnectTimer = null;
|
|
3120
|
+
if (this.state === "streaming" || this.state === "syncing" || this.state === "handshaking") {
|
|
3121
|
+
void this.reconnectForQuerySubsets();
|
|
3122
|
+
}
|
|
3123
|
+
}, 500);
|
|
3124
|
+
}
|
|
3125
|
+
async reconnectForQuerySubsets() {
|
|
3126
|
+
await this.stop();
|
|
3127
|
+
await this.start();
|
|
3128
|
+
}
|
|
2451
3129
|
};
|
|
2452
3130
|
function awarenessStatesToWire(states) {
|
|
2453
3131
|
const wire = {};
|
|
@@ -2621,6 +3299,20 @@ var ReconnectionManager = class {
|
|
|
2621
3299
|
this.running = false;
|
|
2622
3300
|
}
|
|
2623
3301
|
}
|
|
3302
|
+
/**
|
|
3303
|
+
* Cancel the current backoff wait and proceed to the next reconnect attempt immediately.
|
|
3304
|
+
* No-op if not waiting.
|
|
3305
|
+
*/
|
|
3306
|
+
wake() {
|
|
3307
|
+
if (this.timer !== null) {
|
|
3308
|
+
clearTimeout(this.timer);
|
|
3309
|
+
this.timer = null;
|
|
3310
|
+
}
|
|
3311
|
+
if (this.waitResolve) {
|
|
3312
|
+
this.waitResolve();
|
|
3313
|
+
this.waitResolve = null;
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
2624
3316
|
/**
|
|
2625
3317
|
* Stop any pending reconnection attempt.
|
|
2626
3318
|
*/
|
|
@@ -3003,8 +3695,8 @@ var SyncEncryptor = class _SyncEncryptor {
|
|
|
3003
3695
|
);
|
|
3004
3696
|
const payload = {
|
|
3005
3697
|
v: this.currentVersion,
|
|
3006
|
-
iv:
|
|
3007
|
-
ct:
|
|
3698
|
+
iv: toBase642(iv),
|
|
3699
|
+
ct: toBase642(new Uint8Array(ciphertextBuffer)),
|
|
3008
3700
|
alg: "aes-256-gcm"
|
|
3009
3701
|
};
|
|
3010
3702
|
return {
|
|
@@ -3048,8 +3740,8 @@ var SyncEncryptor = class _SyncEncryptor {
|
|
|
3048
3740
|
);
|
|
3049
3741
|
}
|
|
3050
3742
|
try {
|
|
3051
|
-
const iv =
|
|
3052
|
-
const ciphertext =
|
|
3743
|
+
const iv = fromBase642(payload.iv);
|
|
3744
|
+
const ciphertext = fromBase642(payload.ct);
|
|
3053
3745
|
const plaintextBuffer = await globalThis.crypto.subtle.decrypt(
|
|
3054
3746
|
{ name: "AES-GCM", iv },
|
|
3055
3747
|
key.key,
|
|
@@ -3086,14 +3778,14 @@ function isEncryptedPayload(field) {
|
|
|
3086
3778
|
}
|
|
3087
3779
|
return field[ENCRYPTED_MARKER] === true && typeof field.v === "number" && typeof field.iv === "string" && typeof field.ct === "string" && typeof field.alg === "string";
|
|
3088
3780
|
}
|
|
3089
|
-
function
|
|
3781
|
+
function toBase642(bytes) {
|
|
3090
3782
|
let binary = "";
|
|
3091
3783
|
for (let i = 0; i < bytes.length; i++) {
|
|
3092
3784
|
binary += String.fromCharCode(bytes[i]);
|
|
3093
3785
|
}
|
|
3094
3786
|
return btoa(binary);
|
|
3095
3787
|
}
|
|
3096
|
-
function
|
|
3788
|
+
function fromBase642(str) {
|
|
3097
3789
|
const binary = atob(str);
|
|
3098
3790
|
const bytes = new Uint8Array(binary.length);
|
|
3099
3791
|
for (let i = 0; i < binary.length; i++) {
|
|
@@ -3105,6 +3797,7 @@ export {
|
|
|
3105
3797
|
AwarenessManager,
|
|
3106
3798
|
ChaosTransport,
|
|
3107
3799
|
ConnectionMonitor,
|
|
3800
|
+
DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD,
|
|
3108
3801
|
DecryptionError,
|
|
3109
3802
|
EncryptionError,
|
|
3110
3803
|
HttpLongPollingTransport,
|
|
@@ -3115,25 +3808,39 @@ export {
|
|
|
3115
3808
|
OutboundQueue,
|
|
3116
3809
|
ProtobufMessageSerializer,
|
|
3117
3810
|
ReconnectionManager,
|
|
3811
|
+
RichtextDocChannel,
|
|
3812
|
+
SCHEMA_MISMATCH_PREFIX,
|
|
3118
3813
|
SYNC_STATES,
|
|
3119
3814
|
SYNC_STATUSES,
|
|
3120
3815
|
ScopeViolationError,
|
|
3121
3816
|
SyncEncryptor,
|
|
3122
3817
|
SyncEngine,
|
|
3123
3818
|
WebSocketTransport,
|
|
3819
|
+
createDeltaCursorFromBatch,
|
|
3820
|
+
decodeDeltaCursor,
|
|
3821
|
+
decodeYjsUpdate,
|
|
3822
|
+
dedupeQuerySubsets,
|
|
3124
3823
|
deriveKey,
|
|
3125
3824
|
deriveVersionedKey,
|
|
3825
|
+
encodeDeltaCursor,
|
|
3826
|
+
encodeYjsUpdate,
|
|
3126
3827
|
filterOperationsByScope,
|
|
3127
3828
|
generateSalt,
|
|
3128
3829
|
isAcknowledgmentMessage,
|
|
3129
3830
|
isAwarenessUpdateMessage,
|
|
3831
|
+
isClientSchemaVersionSupported,
|
|
3130
3832
|
isEncryptedPayload,
|
|
3131
3833
|
isErrorMessage,
|
|
3132
3834
|
isHandshakeMessage,
|
|
3133
3835
|
isHandshakeResponseMessage,
|
|
3134
3836
|
isOperationBatchMessage,
|
|
3837
|
+
isSchemaMismatchReject,
|
|
3135
3838
|
isSyncMessage,
|
|
3839
|
+
isYjsDocUpdateMessage,
|
|
3840
|
+
operationMatchesQuerySubsets,
|
|
3136
3841
|
operationMatchesScope,
|
|
3842
|
+
richtextDocKey,
|
|
3843
|
+
sliceOperationsAfterCursor,
|
|
3137
3844
|
versionVectorToWire,
|
|
3138
3845
|
wireToVersionVector
|
|
3139
3846
|
};
|