@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.cjs
CHANGED
|
@@ -33,6 +33,7 @@ __export(index_exports, {
|
|
|
33
33
|
AwarenessManager: () => AwarenessManager,
|
|
34
34
|
ChaosTransport: () => ChaosTransport,
|
|
35
35
|
ConnectionMonitor: () => ConnectionMonitor,
|
|
36
|
+
DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD: () => DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD,
|
|
36
37
|
DecryptionError: () => DecryptionError,
|
|
37
38
|
EncryptionError: () => EncryptionError,
|
|
38
39
|
HttpLongPollingTransport: () => HttpLongPollingTransport,
|
|
@@ -43,25 +44,39 @@ __export(index_exports, {
|
|
|
43
44
|
OutboundQueue: () => OutboundQueue,
|
|
44
45
|
ProtobufMessageSerializer: () => ProtobufMessageSerializer,
|
|
45
46
|
ReconnectionManager: () => ReconnectionManager,
|
|
47
|
+
RichtextDocChannel: () => RichtextDocChannel,
|
|
48
|
+
SCHEMA_MISMATCH_PREFIX: () => SCHEMA_MISMATCH_PREFIX,
|
|
46
49
|
SYNC_STATES: () => SYNC_STATES,
|
|
47
50
|
SYNC_STATUSES: () => SYNC_STATUSES,
|
|
48
51
|
ScopeViolationError: () => ScopeViolationError,
|
|
49
52
|
SyncEncryptor: () => SyncEncryptor,
|
|
50
53
|
SyncEngine: () => SyncEngine,
|
|
51
54
|
WebSocketTransport: () => WebSocketTransport,
|
|
55
|
+
createDeltaCursorFromBatch: () => createDeltaCursorFromBatch,
|
|
56
|
+
decodeDeltaCursor: () => decodeDeltaCursor,
|
|
57
|
+
decodeYjsUpdate: () => decodeYjsUpdate,
|
|
58
|
+
dedupeQuerySubsets: () => dedupeQuerySubsets,
|
|
52
59
|
deriveKey: () => deriveKey,
|
|
53
60
|
deriveVersionedKey: () => deriveVersionedKey,
|
|
61
|
+
encodeDeltaCursor: () => encodeDeltaCursor,
|
|
62
|
+
encodeYjsUpdate: () => encodeYjsUpdate,
|
|
54
63
|
filterOperationsByScope: () => filterOperationsByScope,
|
|
55
64
|
generateSalt: () => generateSalt,
|
|
56
65
|
isAcknowledgmentMessage: () => isAcknowledgmentMessage,
|
|
57
66
|
isAwarenessUpdateMessage: () => isAwarenessUpdateMessage,
|
|
67
|
+
isClientSchemaVersionSupported: () => isClientSchemaVersionSupported,
|
|
58
68
|
isEncryptedPayload: () => isEncryptedPayload,
|
|
59
69
|
isErrorMessage: () => isErrorMessage,
|
|
60
70
|
isHandshakeMessage: () => isHandshakeMessage,
|
|
61
71
|
isHandshakeResponseMessage: () => isHandshakeResponseMessage,
|
|
62
72
|
isOperationBatchMessage: () => isOperationBatchMessage,
|
|
73
|
+
isSchemaMismatchReject: () => isSchemaMismatchReject,
|
|
63
74
|
isSyncMessage: () => isSyncMessage,
|
|
75
|
+
isYjsDocUpdateMessage: () => isYjsDocUpdateMessage,
|
|
76
|
+
operationMatchesQuerySubsets: () => operationMatchesQuerySubsets,
|
|
64
77
|
operationMatchesScope: () => operationMatchesScope,
|
|
78
|
+
richtextDocKey: () => richtextDocKey,
|
|
79
|
+
sliceOperationsAfterCursor: () => sliceOperationsAfterCursor,
|
|
65
80
|
versionVectorToWire: () => versionVectorToWire,
|
|
66
81
|
wireToVersionVector: () => wireToVersionVector
|
|
67
82
|
});
|
|
@@ -77,7 +92,14 @@ var SYNC_STATES = [
|
|
|
77
92
|
"streaming",
|
|
78
93
|
"error"
|
|
79
94
|
];
|
|
80
|
-
var SYNC_STATUSES = [
|
|
95
|
+
var SYNC_STATUSES = [
|
|
96
|
+
"connected",
|
|
97
|
+
"syncing",
|
|
98
|
+
"synced",
|
|
99
|
+
"offline",
|
|
100
|
+
"error",
|
|
101
|
+
"schema-mismatch"
|
|
102
|
+
];
|
|
81
103
|
var ScopeViolationError = class extends import_core.KoraError {
|
|
82
104
|
constructor(operationId, collection, scope, message) {
|
|
83
105
|
super(
|
|
@@ -102,12 +124,12 @@ var InvalidScopeError = class extends import_core.KoraError {
|
|
|
102
124
|
};
|
|
103
125
|
|
|
104
126
|
// src/scopes/scope-filter.ts
|
|
105
|
-
function operationMatchesScope(op, scopeMap) {
|
|
127
|
+
function operationMatchesScope(op, scopeMap, fullRecord) {
|
|
106
128
|
if (!scopeMap) return true;
|
|
107
129
|
const collectionScope = scopeMap[op.collection];
|
|
108
130
|
if (!collectionScope) return false;
|
|
109
131
|
if (Object.keys(collectionScope).length === 0) return true;
|
|
110
|
-
const snapshot = buildSnapshot(op);
|
|
132
|
+
const snapshot = buildSnapshot(op, fullRecord ?? void 0);
|
|
111
133
|
if (!snapshot) return false;
|
|
112
134
|
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
113
135
|
if (snapshot[field] !== expected) {
|
|
@@ -120,11 +142,12 @@ function filterOperationsByScope(operations, scopeMap) {
|
|
|
120
142
|
if (!scopeMap) return operations;
|
|
121
143
|
return operations.filter((op) => operationMatchesScope(op, scopeMap));
|
|
122
144
|
}
|
|
123
|
-
function buildSnapshot(op) {
|
|
145
|
+
function buildSnapshot(op, fullRecord) {
|
|
124
146
|
const previous = asRecord(op.previousData);
|
|
125
147
|
const next = asRecord(op.data);
|
|
126
|
-
if (!previous && !next) return null;
|
|
148
|
+
if (!previous && !next && !fullRecord) return null;
|
|
127
149
|
return {
|
|
150
|
+
...fullRecord ?? {},
|
|
128
151
|
...previous ?? {},
|
|
129
152
|
...next ?? {}
|
|
130
153
|
};
|
|
@@ -136,6 +159,128 @@ function asRecord(value) {
|
|
|
136
159
|
return value;
|
|
137
160
|
}
|
|
138
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
|
+
}
|
|
283
|
+
|
|
139
284
|
// src/protocol/messages.ts
|
|
140
285
|
function isSyncMessage(value) {
|
|
141
286
|
if (typeof value !== "object" || value === null) return false;
|
|
@@ -154,6 +299,8 @@ function isSyncMessage(value) {
|
|
|
154
299
|
return isErrorMessage(value);
|
|
155
300
|
case "awareness-update":
|
|
156
301
|
return isAwarenessUpdateMessage(value);
|
|
302
|
+
case "yjs-doc-update":
|
|
303
|
+
return isYjsDocUpdateMessage(value);
|
|
157
304
|
default:
|
|
158
305
|
return false;
|
|
159
306
|
}
|
|
@@ -188,6 +335,11 @@ function isAwarenessUpdateMessage(value) {
|
|
|
188
335
|
const msg = value;
|
|
189
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);
|
|
190
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
|
+
}
|
|
191
343
|
|
|
192
344
|
// src/protocol/serializer.ts
|
|
193
345
|
var import_core2 = require("@korajs/core");
|
|
@@ -203,6 +355,84 @@ function versionVectorToWire(vector) {
|
|
|
203
355
|
function wireToVersionVector(wire) {
|
|
204
356
|
return new Map(Object.entries(wire));
|
|
205
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
|
+
}
|
|
206
436
|
var JsonMessageSerializer = class {
|
|
207
437
|
encode(message) {
|
|
208
438
|
return JSON.stringify(message);
|
|
@@ -231,8 +461,8 @@ var JsonMessageSerializer = class {
|
|
|
231
461
|
type: op.type,
|
|
232
462
|
collection: op.collection,
|
|
233
463
|
recordId: op.recordId,
|
|
234
|
-
data: op.data,
|
|
235
|
-
previousData: op.previousData,
|
|
464
|
+
data: serializeWireRecord(op.data),
|
|
465
|
+
previousData: serializeWireRecord(op.previousData),
|
|
236
466
|
timestamp: {
|
|
237
467
|
wallTime: op.timestamp.wallTime,
|
|
238
468
|
logical: op.timestamp.logical,
|
|
@@ -253,8 +483,8 @@ var JsonMessageSerializer = class {
|
|
|
253
483
|
type: serialized.type,
|
|
254
484
|
collection: serialized.collection,
|
|
255
485
|
recordId: serialized.recordId,
|
|
256
|
-
data: serialized.data,
|
|
257
|
-
previousData: serialized.previousData,
|
|
486
|
+
data: deserializeWireRecord(serialized.data),
|
|
487
|
+
previousData: deserializeWireRecord(serialized.previousData),
|
|
258
488
|
timestamp: {
|
|
259
489
|
wallTime: serialized.timestamp.wallTime,
|
|
260
490
|
logical: serialized.timestamp.logical,
|
|
@@ -375,6 +605,7 @@ function toProtoEnvelope(message) {
|
|
|
375
605
|
retriable: message.retriable
|
|
376
606
|
};
|
|
377
607
|
case "awareness-update":
|
|
608
|
+
case "yjs-doc-update":
|
|
378
609
|
return {
|
|
379
610
|
type: message.type,
|
|
380
611
|
messageId: message.messageId
|
|
@@ -1231,9 +1462,215 @@ var ChaosTransport = class {
|
|
|
1231
1462
|
};
|
|
1232
1463
|
|
|
1233
1464
|
// src/engine/sync-engine.ts
|
|
1234
|
-
var
|
|
1465
|
+
var import_core6 = require("@korajs/core");
|
|
1235
1466
|
var import_internal2 = require("@korajs/core/internal");
|
|
1236
1467
|
|
|
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;
|
|
1490
|
+
}
|
|
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();
|
|
1506
|
+
}
|
|
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);
|
|
1524
|
+
}
|
|
1525
|
+
return result;
|
|
1526
|
+
}
|
|
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
|
+
}
|
|
1563
|
+
}
|
|
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();
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
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.
|
|
1584
|
+
*/
|
|
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);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
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);
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
emitAwarenessEvent() {
|
|
1669
|
+
const states = this.getStates();
|
|
1670
|
+
this.emitter?.emit({ type: "awareness:updated", states });
|
|
1671
|
+
}
|
|
1672
|
+
};
|
|
1673
|
+
|
|
1237
1674
|
// src/diagnostics/bandwidth-estimator.ts
|
|
1238
1675
|
var BandwidthEstimator = class {
|
|
1239
1676
|
maxSamples;
|
|
@@ -1275,6 +1712,7 @@ var BandwidthEstimator = class {
|
|
|
1275
1712
|
const decayFactor = 0.9;
|
|
1276
1713
|
for (let i = this.samples.length - 1; i >= 0; i--) {
|
|
1277
1714
|
const sample = this.samples[i];
|
|
1715
|
+
if (!sample) continue;
|
|
1278
1716
|
const bytesPerSec = sample.bytes / sample.durationMs * 1e3;
|
|
1279
1717
|
const age = this.samples.length - 1 - i;
|
|
1280
1718
|
const weight = decayFactor ** age;
|
|
@@ -1340,7 +1778,7 @@ var SlidingWindowPercentile = class {
|
|
|
1340
1778
|
const sorted = this.samples.slice(0, this.count).sort((a, b) => a - b);
|
|
1341
1779
|
const rank = Math.ceil(p / 100 * sorted.length) - 1;
|
|
1342
1780
|
const index = Math.max(0, Math.min(rank, sorted.length - 1));
|
|
1343
|
-
return sorted[index];
|
|
1781
|
+
return sorted[index] ?? 0;
|
|
1344
1782
|
}
|
|
1345
1783
|
/**
|
|
1346
1784
|
* Get the most recently added sample, or 0 if no samples exist.
|
|
@@ -1348,7 +1786,7 @@ var SlidingWindowPercentile = class {
|
|
|
1348
1786
|
latest() {
|
|
1349
1787
|
if (this.count === 0) return 0;
|
|
1350
1788
|
const lastIndex = (this.writeIndex - 1 + this.maxSize) % this.maxSize;
|
|
1351
|
-
return this.samples[lastIndex];
|
|
1789
|
+
return this.samples[lastIndex] ?? 0;
|
|
1352
1790
|
}
|
|
1353
1791
|
/**
|
|
1354
1792
|
* Get the number of samples currently in the window.
|
|
@@ -1675,210 +2113,107 @@ var SyncMetricsCollector = class {
|
|
|
1675
2113
|
}
|
|
1676
2114
|
};
|
|
1677
2115
|
|
|
1678
|
-
// src/
|
|
1679
|
-
var
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
listeners = /* @__PURE__ */ new Set();
|
|
1687
|
-
emitter;
|
|
1688
|
-
timeoutMs;
|
|
1689
|
-
// Track when we last received an update from each remote client.
|
|
1690
|
-
// Used for timeout-based cleanup as a safety net in case the server
|
|
1691
|
-
// does not send an explicit removal on disconnect.
|
|
1692
|
-
lastUpdated = /* @__PURE__ */ new Map();
|
|
1693
|
-
cleanupTimer = null;
|
|
1694
|
-
sendHandler = null;
|
|
1695
|
-
destroyed = false;
|
|
1696
|
-
constructor(options) {
|
|
1697
|
-
this.clientId = options?.clientId ?? nextLocalClientId++;
|
|
1698
|
-
this.emitter = options?.emitter ?? null;
|
|
1699
|
-
this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1700
|
-
}
|
|
1701
|
-
/**
|
|
1702
|
-
* Set the local user's awareness state and broadcast to peers.
|
|
1703
|
-
*
|
|
1704
|
-
* @param state - The awareness state to share. Pass null to clear presence.
|
|
1705
|
-
*/
|
|
1706
|
-
setLocalState(state) {
|
|
1707
|
-
if (this.destroyed) return;
|
|
1708
|
-
this.localState = state;
|
|
1709
|
-
const message = {
|
|
1710
|
-
type: "awareness",
|
|
1711
|
-
clientId: this.clientId,
|
|
1712
|
-
states: { [this.clientId]: state }
|
|
1713
|
-
};
|
|
1714
|
-
this.sendHandler?.(message);
|
|
1715
|
-
this.emitAwarenessEvent();
|
|
1716
|
-
}
|
|
1717
|
-
/**
|
|
1718
|
-
* Get the local awareness state.
|
|
1719
|
-
*/
|
|
1720
|
-
getLocalState() {
|
|
1721
|
-
return this.localState;
|
|
1722
|
-
}
|
|
1723
|
-
/**
|
|
1724
|
-
* Get all known awareness states (local + remote).
|
|
1725
|
-
* Returns a new Map on each call.
|
|
1726
|
-
*/
|
|
1727
|
-
getStates() {
|
|
1728
|
-
const result = /* @__PURE__ */ new Map();
|
|
1729
|
-
if (this.localState) {
|
|
1730
|
-
result.set(this.clientId, this.localState);
|
|
1731
|
-
}
|
|
1732
|
-
for (const [id, state] of this.remoteStates) {
|
|
1733
|
-
result.set(id, state);
|
|
1734
|
-
}
|
|
1735
|
-
return result;
|
|
1736
|
-
}
|
|
1737
|
-
/**
|
|
1738
|
-
* Handle an incoming awareness message from the transport.
|
|
1739
|
-
* This processes remote state updates and notifies listeners.
|
|
1740
|
-
*/
|
|
1741
|
-
handleRemoteMessage(message) {
|
|
1742
|
-
if (this.destroyed) return;
|
|
1743
|
-
const added = [];
|
|
1744
|
-
const updated = [];
|
|
1745
|
-
const removed = [];
|
|
1746
|
-
const now = Date.now();
|
|
1747
|
-
for (const [clientIdStr, state] of Object.entries(message.states)) {
|
|
1748
|
-
const clientId = Number(clientIdStr);
|
|
1749
|
-
if (clientId === this.clientId) continue;
|
|
1750
|
-
if (state === null) {
|
|
1751
|
-
if (this.remoteStates.has(clientId)) {
|
|
1752
|
-
this.remoteStates.delete(clientId);
|
|
1753
|
-
this.lastUpdated.delete(clientId);
|
|
1754
|
-
removed.push(clientId);
|
|
1755
|
-
}
|
|
1756
|
-
} else {
|
|
1757
|
-
if (this.remoteStates.has(clientId)) {
|
|
1758
|
-
this.remoteStates.set(clientId, state);
|
|
1759
|
-
this.lastUpdated.set(clientId, now);
|
|
1760
|
-
updated.push(clientId);
|
|
1761
|
-
} else {
|
|
1762
|
-
this.remoteStates.set(clientId, state);
|
|
1763
|
-
this.lastUpdated.set(clientId, now);
|
|
1764
|
-
added.push(clientId);
|
|
1765
|
-
}
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
if (added.length > 0 || updated.length > 0 || removed.length > 0) {
|
|
1769
|
-
const change = { added, updated, removed };
|
|
1770
|
-
this.notifyListeners(change);
|
|
1771
|
-
this.emitAwarenessEvent();
|
|
1772
|
-
}
|
|
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]);
|
|
1773
2124
|
}
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
this.remoteStates.delete(clientId);
|
|
1782
|
-
this.lastUpdated.delete(clientId);
|
|
1783
|
-
const change = {
|
|
1784
|
-
added: [],
|
|
1785
|
-
updated: [],
|
|
1786
|
-
removed: [clientId]
|
|
1787
|
-
};
|
|
1788
|
-
this.notifyListeners(change);
|
|
1789
|
-
this.emitAwarenessEvent();
|
|
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);
|
|
1790
2132
|
}
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
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;
|
|
1797
2148
|
}
|
|
1798
2149
|
/**
|
|
1799
|
-
*
|
|
1800
|
-
*
|
|
2150
|
+
* Whether the doc channel should be used for a field.
|
|
2151
|
+
* @param preference - `true` forces on, `false` forces off, `undefined` uses size threshold
|
|
1801
2152
|
*/
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
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;
|
|
1807
2161
|
}
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
*/
|
|
1811
|
-
off(_event, listener) {
|
|
1812
|
-
this.listeners.delete(listener);
|
|
2162
|
+
getThreshold() {
|
|
2163
|
+
return this.threshold;
|
|
1813
2164
|
}
|
|
1814
2165
|
/**
|
|
1815
|
-
*
|
|
1816
|
-
* Called when the sync engine transitions to streaming state.
|
|
2166
|
+
* Subscribe to incremental updates for a document key.
|
|
1817
2167
|
*/
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
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
|
+
};
|
|
1823
2186
|
}
|
|
1824
2187
|
/**
|
|
1825
|
-
*
|
|
2188
|
+
* Publish a local incremental update to peers.
|
|
1826
2189
|
*/
|
|
1827
|
-
|
|
1828
|
-
if (this.
|
|
1829
|
-
|
|
1830
|
-
this.cleanupTimer = null;
|
|
2190
|
+
send(collection, recordId, field, update) {
|
|
2191
|
+
if (!this.onSend || update.length === 0) {
|
|
2192
|
+
return;
|
|
1831
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
|
+
});
|
|
1832
2202
|
}
|
|
1833
2203
|
/**
|
|
1834
|
-
*
|
|
1835
|
-
* will no longer send or receive awareness updates.
|
|
1836
|
-
* Broadcasts removal of local state before shutting down.
|
|
2204
|
+
* Deliver a remote incremental update to local subscribers.
|
|
1837
2205
|
*/
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
if (
|
|
1842
|
-
|
|
1843
|
-
type: "awareness",
|
|
1844
|
-
clientId: this.clientId,
|
|
1845
|
-
states: { [this.clientId]: null }
|
|
1846
|
-
};
|
|
1847
|
-
this.sendHandler?.(message);
|
|
1848
|
-
}
|
|
1849
|
-
this.localState = null;
|
|
1850
|
-
this.remoteStates.clear();
|
|
1851
|
-
this.lastUpdated.clear();
|
|
1852
|
-
this.listeners.clear();
|
|
1853
|
-
this.sendHandler = null;
|
|
1854
|
-
this.stopCleanupTimer();
|
|
1855
|
-
}
|
|
1856
|
-
// --- Private ---
|
|
1857
|
-
cleanupStaleStates() {
|
|
1858
|
-
const now = Date.now();
|
|
1859
|
-
const removed = [];
|
|
1860
|
-
for (const [clientId, lastTime] of this.lastUpdated) {
|
|
1861
|
-
if (now - lastTime > this.timeoutMs) {
|
|
1862
|
-
this.remoteStates.delete(clientId);
|
|
1863
|
-
this.lastUpdated.delete(clientId);
|
|
1864
|
-
removed.push(clientId);
|
|
1865
|
-
}
|
|
1866
|
-
}
|
|
1867
|
-
if (removed.length > 0) {
|
|
1868
|
-
const change = { added: [], updated: [], removed };
|
|
1869
|
-
this.notifyListeners(change);
|
|
1870
|
-
this.emitAwarenessEvent();
|
|
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;
|
|
1871
2211
|
}
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
listener(change);
|
|
2212
|
+
const update = decodeYjsUpdate(message.update);
|
|
2213
|
+
for (const listener of set) {
|
|
2214
|
+
listener(update);
|
|
1876
2215
|
}
|
|
1877
2216
|
}
|
|
1878
|
-
emitAwarenessEvent() {
|
|
1879
|
-
const states = this.getStates();
|
|
1880
|
-
this.emitter?.emit({ type: "awareness:updated", states });
|
|
1881
|
-
}
|
|
1882
2217
|
};
|
|
1883
2218
|
|
|
1884
2219
|
// src/engine/memory-queue-storage.ts
|
|
@@ -2012,6 +2347,19 @@ var OutboundQueue = class {
|
|
|
2012
2347
|
get isInitialized() {
|
|
2013
2348
|
return this.initialized;
|
|
2014
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
|
+
}
|
|
2015
2363
|
};
|
|
2016
2364
|
|
|
2017
2365
|
// src/engine/sync-engine.ts
|
|
@@ -2026,6 +2374,7 @@ var VALID_TRANSITIONS = {
|
|
|
2026
2374
|
error: ["disconnected"]
|
|
2027
2375
|
};
|
|
2028
2376
|
var nextMessageId = 0;
|
|
2377
|
+
var nextQuerySubsetId = 0;
|
|
2029
2378
|
function generateMessageId() {
|
|
2030
2379
|
return `msg-${Date.now()}-${nextMessageId++}`;
|
|
2031
2380
|
}
|
|
@@ -2040,24 +2389,39 @@ var SyncEngine = class {
|
|
|
2040
2389
|
batchSize;
|
|
2041
2390
|
encryptor;
|
|
2042
2391
|
awarenessManager;
|
|
2392
|
+
richtextDocChannel;
|
|
2043
2393
|
metricsCollector;
|
|
2394
|
+
syncState;
|
|
2044
2395
|
remoteVector = /* @__PURE__ */ new Map();
|
|
2396
|
+
lastAckedServerVector = /* @__PURE__ */ new Map();
|
|
2397
|
+
cachedUnsyncedCount = 0;
|
|
2045
2398
|
lastSyncedAt = null;
|
|
2046
2399
|
lastSuccessfulPush = null;
|
|
2047
2400
|
lastSuccessfulPull = null;
|
|
2048
2401
|
conflictCount = 0;
|
|
2049
2402
|
currentBatch = null;
|
|
2050
2403
|
reconnecting = false;
|
|
2404
|
+
schemaBlocked = false;
|
|
2051
2405
|
// Track delta exchange state
|
|
2052
2406
|
deltaBatchesReceived = 0;
|
|
2053
2407
|
deltaReceiveComplete = false;
|
|
2054
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();
|
|
2055
2413
|
/**
|
|
2056
2414
|
* The effective scope for this sync session.
|
|
2057
2415
|
* Starts as the configured scopeMap. After handshake, may be replaced
|
|
2058
2416
|
* with the server-accepted scope (server is authoritative).
|
|
2059
2417
|
*/
|
|
2060
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;
|
|
2061
2425
|
constructor(options) {
|
|
2062
2426
|
this.transport = options.transport;
|
|
2063
2427
|
this.store = options.store;
|
|
@@ -2066,6 +2430,7 @@ var SyncEngine = class {
|
|
|
2066
2430
|
this.emitter = options.emitter ?? null;
|
|
2067
2431
|
this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2068
2432
|
this.encryptor = options.encryptor ?? null;
|
|
2433
|
+
this.syncState = options.syncState ?? null;
|
|
2069
2434
|
this.activeScope = options.config.scopeMap;
|
|
2070
2435
|
const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
|
|
2071
2436
|
this.outboundQueue = new OutboundQueue(queueStorage);
|
|
@@ -2076,6 +2441,15 @@ var SyncEngine = class {
|
|
|
2076
2441
|
this.awarenessManager = new AwarenessManager({
|
|
2077
2442
|
emitter: this.emitter ?? void 0
|
|
2078
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
|
+
});
|
|
2079
2453
|
this.awarenessManager.onSend((message) => {
|
|
2080
2454
|
if (this.state !== "streaming") return;
|
|
2081
2455
|
const wireMessage = {
|
|
@@ -2092,20 +2466,35 @@ var SyncEngine = class {
|
|
|
2092
2466
|
*/
|
|
2093
2467
|
async start() {
|
|
2094
2468
|
if (this.state !== "disconnected") {
|
|
2095
|
-
throw new
|
|
2469
|
+
throw new import_core6.SyncError("Cannot start sync engine: not in disconnected state", {
|
|
2096
2470
|
currentState: this.state
|
|
2097
2471
|
});
|
|
2098
2472
|
}
|
|
2099
2473
|
await this.outboundQueue.initialize();
|
|
2100
|
-
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));
|
|
2101
2483
|
this.transport.onClose((reason) => this.handleTransportClose(reason));
|
|
2102
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
|
+
}
|
|
2103
2491
|
this.transitionTo("connecting");
|
|
2104
2492
|
try {
|
|
2105
2493
|
const authToken = this.config.auth ? (await this.config.auth()).token : void 0;
|
|
2106
2494
|
await this.transport.connect(this.config.url, { authToken });
|
|
2107
2495
|
this.transitionTo("handshaking");
|
|
2108
2496
|
const localVector = this.store.getVersionVector();
|
|
2497
|
+
const activeQuerySubsets = this.getActiveQuerySubsets();
|
|
2109
2498
|
const handshake = {
|
|
2110
2499
|
type: "handshake",
|
|
2111
2500
|
messageId: generateMessageId(),
|
|
@@ -2114,7 +2503,9 @@ var SyncEngine = class {
|
|
|
2114
2503
|
schemaVersion: this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION,
|
|
2115
2504
|
authToken,
|
|
2116
2505
|
supportedWireFormats: ["json", "protobuf"],
|
|
2117
|
-
...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {}
|
|
2506
|
+
...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
|
|
2507
|
+
...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
|
|
2508
|
+
...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {}
|
|
2118
2509
|
};
|
|
2119
2510
|
this.transport.send(handshake);
|
|
2120
2511
|
} catch (err) {
|
|
@@ -2151,10 +2542,16 @@ var SyncEngine = class {
|
|
|
2151
2542
|
* because they should remain local-only and not be sent to the server.
|
|
2152
2543
|
*/
|
|
2153
2544
|
async pushOperation(op) {
|
|
2154
|
-
if (!
|
|
2545
|
+
if (!this.operationAllowedForSync(op)) {
|
|
2155
2546
|
return;
|
|
2156
2547
|
}
|
|
2548
|
+
if (this.syncState) {
|
|
2549
|
+
this.cachedUnsyncedCount++;
|
|
2550
|
+
}
|
|
2157
2551
|
await this.outboundQueue.enqueue(op);
|
|
2552
|
+
if (this.syncState) {
|
|
2553
|
+
await this.refreshPendingCount();
|
|
2554
|
+
}
|
|
2158
2555
|
if (this.state === "streaming") {
|
|
2159
2556
|
this.flushQueue();
|
|
2160
2557
|
}
|
|
@@ -2172,7 +2569,7 @@ var SyncEngine = class {
|
|
|
2172
2569
|
* Get the current developer-facing sync status.
|
|
2173
2570
|
*/
|
|
2174
2571
|
getStatus() {
|
|
2175
|
-
const pendingOperations = this.outboundQueue.totalPending;
|
|
2572
|
+
const pendingOperations = this.syncState ? this.cachedUnsyncedCount : this.outboundQueue.totalPending;
|
|
2176
2573
|
const base = {
|
|
2177
2574
|
pendingOperations,
|
|
2178
2575
|
lastSyncedAt: this.lastSyncedAt,
|
|
@@ -2190,7 +2587,24 @@ var SyncEngine = class {
|
|
|
2190
2587
|
case "streaming":
|
|
2191
2588
|
return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
|
|
2192
2589
|
case "error":
|
|
2193
|
-
return { ...base, status: "error" };
|
|
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");
|
|
2194
2608
|
}
|
|
2195
2609
|
}
|
|
2196
2610
|
/**
|
|
@@ -2200,11 +2614,31 @@ var SyncEngine = class {
|
|
|
2200
2614
|
recordConflict() {
|
|
2201
2615
|
this.conflictCount++;
|
|
2202
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
|
+
}
|
|
2203
2636
|
/**
|
|
2204
2637
|
* Force an immediate reconnection attempt. If the engine is disconnected
|
|
2205
2638
|
* or in error state, restarts the sync. If already connected, no-op.
|
|
2206
2639
|
*/
|
|
2207
2640
|
async retryNow() {
|
|
2641
|
+
if (this.schemaBlocked) return;
|
|
2208
2642
|
if (this.state === "disconnected" || this.state === "error") {
|
|
2209
2643
|
this.reconnecting = false;
|
|
2210
2644
|
await this.start();
|
|
@@ -2265,6 +2699,25 @@ var SyncEngine = class {
|
|
|
2265
2699
|
getActiveScope() {
|
|
2266
2700
|
return this.activeScope;
|
|
2267
2701
|
}
|
|
2702
|
+
/**
|
|
2703
|
+
* Register a live query subset that narrows synced data for a collection.
|
|
2704
|
+
* Takes effect on the next connection; reconnects when already connected.
|
|
2705
|
+
*/
|
|
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
|
+
}
|
|
2268
2721
|
/**
|
|
2269
2722
|
* Get the awareness manager for collaborative presence.
|
|
2270
2723
|
* Use this to set local presence, observe remote collaborators,
|
|
@@ -2273,14 +2726,24 @@ var SyncEngine = class {
|
|
|
2273
2726
|
getAwarenessManager() {
|
|
2274
2727
|
return this.awarenessManager;
|
|
2275
2728
|
}
|
|
2729
|
+
/**
|
|
2730
|
+
* Optional side channel for incremental Yjs updates on large richtext fields.
|
|
2731
|
+
*/
|
|
2732
|
+
getRichtextDocChannel() {
|
|
2733
|
+
return this.richtextDocChannel;
|
|
2734
|
+
}
|
|
2276
2735
|
// --- Private methods ---
|
|
2277
|
-
|
|
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) {
|
|
2278
2741
|
switch (message.type) {
|
|
2279
2742
|
case "handshake-response":
|
|
2280
2743
|
this.handleHandshakeResponse(message);
|
|
2281
2744
|
break;
|
|
2282
2745
|
case "operation-batch":
|
|
2283
|
-
this.handleOperationBatch(message);
|
|
2746
|
+
await this.handleOperationBatch(message);
|
|
2284
2747
|
break;
|
|
2285
2748
|
case "acknowledgment":
|
|
2286
2749
|
this.handleAcknowledgment(message);
|
|
@@ -2291,20 +2754,46 @@ var SyncEngine = class {
|
|
|
2291
2754
|
case "awareness-update":
|
|
2292
2755
|
this.handleAwarenessUpdate(message);
|
|
2293
2756
|
break;
|
|
2757
|
+
case "yjs-doc-update":
|
|
2758
|
+
this.richtextDocChannel.deliver(message);
|
|
2759
|
+
break;
|
|
2294
2760
|
}
|
|
2295
2761
|
}
|
|
2762
|
+
handleMessageFailure(error) {
|
|
2763
|
+
const reason = error instanceof Error ? error.message : "Message handling failed";
|
|
2764
|
+
this.handleTransportClose(reason);
|
|
2765
|
+
}
|
|
2296
2766
|
handleHandshakeResponse(msg) {
|
|
2297
2767
|
if (this.state !== "handshaking") return;
|
|
2298
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
|
+
}
|
|
2299
2787
|
this.transitionTo("error");
|
|
2300
2788
|
this.emitter?.emit({
|
|
2301
2789
|
type: "sync:disconnected",
|
|
2302
|
-
reason
|
|
2790
|
+
reason
|
|
2303
2791
|
});
|
|
2304
2792
|
this.transitionTo("disconnected");
|
|
2305
2793
|
return;
|
|
2306
2794
|
}
|
|
2307
2795
|
this.remoteVector = wireToVersionVector(msg.versionVector);
|
|
2796
|
+
void this.persistLastAckedServerVector(this.remoteVector);
|
|
2308
2797
|
if (msg.selectedWireFormat) {
|
|
2309
2798
|
this.setSerializerWireFormat(msg.selectedWireFormat);
|
|
2310
2799
|
}
|
|
@@ -2319,38 +2808,57 @@ var SyncEngine = class {
|
|
|
2319
2808
|
this.deltaBatchesReceived = 0;
|
|
2320
2809
|
this.deltaReceiveComplete = false;
|
|
2321
2810
|
this.deltaSendComplete = false;
|
|
2811
|
+
this.deltaSentOpIds = [];
|
|
2812
|
+
this.pendingDeltaBatchAcks.clear();
|
|
2813
|
+
this.initialSyncTotalBatches = 0;
|
|
2322
2814
|
this.sendDelta();
|
|
2323
2815
|
}
|
|
2324
2816
|
async sendDelta() {
|
|
2325
2817
|
const localVector = this.store.getVersionVector();
|
|
2326
2818
|
const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
|
|
2327
|
-
const missingOps = allMissingOps.filter((op) =>
|
|
2819
|
+
const missingOps = allMissingOps.filter((op) => this.operationAllowedForSync(op));
|
|
2820
|
+
this.deltaSentOpIds = missingOps.map((op) => op.id);
|
|
2328
2821
|
if (missingOps.length === 0) {
|
|
2822
|
+
const messageId = generateMessageId();
|
|
2823
|
+
if (this.config.strictHandshake) {
|
|
2824
|
+
this.pendingDeltaBatchAcks.add(messageId);
|
|
2825
|
+
}
|
|
2329
2826
|
const emptyBatch = {
|
|
2330
2827
|
type: "operation-batch",
|
|
2331
|
-
messageId
|
|
2828
|
+
messageId,
|
|
2332
2829
|
operations: [],
|
|
2333
2830
|
isFinal: true,
|
|
2334
|
-
batchIndex: 0
|
|
2831
|
+
batchIndex: 0,
|
|
2832
|
+
totalBatches: 1
|
|
2335
2833
|
};
|
|
2336
2834
|
this.transport.send(emptyBatch);
|
|
2337
|
-
this.
|
|
2338
|
-
|
|
2835
|
+
if (!this.config.strictHandshake) {
|
|
2836
|
+
this.deltaSendComplete = true;
|
|
2837
|
+
void this.checkDeltaComplete();
|
|
2838
|
+
}
|
|
2339
2839
|
return;
|
|
2340
2840
|
}
|
|
2341
2841
|
const sorted = (0, import_internal2.topologicalSort)(missingOps);
|
|
2342
2842
|
const totalBatches = Math.ceil(sorted.length / this.batchSize);
|
|
2843
|
+
this.initialSyncTotalBatches = Math.max(this.initialSyncTotalBatches, totalBatches);
|
|
2343
2844
|
for (let i = 0; i < totalBatches; i++) {
|
|
2344
2845
|
const start = i * this.batchSize;
|
|
2345
2846
|
const batchOps = sorted.slice(start, start + this.batchSize);
|
|
2847
|
+
const batchCursor = createDeltaCursorFromBatch(batchOps, i);
|
|
2346
2848
|
const opsToSerialize = this.encryptor ? await this.encryptor.encryptBatch(batchOps) : batchOps;
|
|
2347
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
|
+
}
|
|
2348
2854
|
const batchMsg = {
|
|
2349
2855
|
type: "operation-batch",
|
|
2350
|
-
messageId
|
|
2856
|
+
messageId,
|
|
2351
2857
|
operations: serializedOps,
|
|
2352
2858
|
isFinal: i === totalBatches - 1,
|
|
2353
|
-
batchIndex: i
|
|
2859
|
+
batchIndex: i,
|
|
2860
|
+
totalBatches,
|
|
2861
|
+
...batchCursor ? { cursor: encodeDeltaCursor(batchCursor) } : {}
|
|
2354
2862
|
};
|
|
2355
2863
|
this.transport.send(batchMsg);
|
|
2356
2864
|
this.emitter?.emit({
|
|
@@ -2359,8 +2867,17 @@ var SyncEngine = class {
|
|
|
2359
2867
|
batchSize: batchOps.length
|
|
2360
2868
|
});
|
|
2361
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
|
+
}
|
|
2362
2879
|
this.deltaSendComplete = true;
|
|
2363
|
-
this.checkDeltaComplete();
|
|
2880
|
+
void this.checkDeltaComplete();
|
|
2364
2881
|
}
|
|
2365
2882
|
async collectDelta(localVector, remoteVector) {
|
|
2366
2883
|
const missing = [];
|
|
@@ -2376,9 +2893,36 @@ var SyncEngine = class {
|
|
|
2376
2893
|
async handleOperationBatch(msg) {
|
|
2377
2894
|
const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
2378
2895
|
const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
|
|
2379
|
-
const inScopeOps = operations.filter((op) =>
|
|
2896
|
+
const inScopeOps = operations.filter((op) => this.operationAllowedForSync(op));
|
|
2897
|
+
const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
2898
|
+
const transforms = this.config.operationTransforms ?? [];
|
|
2380
2899
|
for (const op of inScopeOps) {
|
|
2381
|
-
|
|
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
|
+
}
|
|
2382
2926
|
}
|
|
2383
2927
|
if (inScopeOps.length > 0) {
|
|
2384
2928
|
this.lastSuccessfulPull = Date.now();
|
|
@@ -2402,13 +2946,33 @@ var SyncEngine = class {
|
|
|
2402
2946
|
});
|
|
2403
2947
|
if (this.state === "syncing") {
|
|
2404
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
|
+
}
|
|
2405
2962
|
if (msg.isFinal) {
|
|
2406
2963
|
this.deltaReceiveComplete = true;
|
|
2407
|
-
this.
|
|
2964
|
+
this.resumeDeltaCursor = null;
|
|
2965
|
+
await this.persistDeltaCursor(null);
|
|
2966
|
+
this.metricsCollector.recordSyncCompleted();
|
|
2967
|
+
void this.checkDeltaComplete();
|
|
2408
2968
|
}
|
|
2409
2969
|
}
|
|
2410
2970
|
}
|
|
2411
2971
|
handleAcknowledgment(msg) {
|
|
2972
|
+
if (this.state === "syncing" && this.config.strictHandshake) {
|
|
2973
|
+
this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
|
|
2974
|
+
this.markDeltaSendCompleteIfReady();
|
|
2975
|
+
}
|
|
2412
2976
|
if (this.currentBatch) {
|
|
2413
2977
|
this.outboundQueue.acknowledge(this.currentBatch.batchId);
|
|
2414
2978
|
this.currentBatch = null;
|
|
@@ -2416,6 +2980,9 @@ var SyncEngine = class {
|
|
|
2416
2980
|
this.lastSyncedAt = now;
|
|
2417
2981
|
this.lastSuccessfulPush = now;
|
|
2418
2982
|
}
|
|
2983
|
+
void this.advanceLastAckedForLocalNode(msg.lastSequenceNumber).then(
|
|
2984
|
+
() => this.refreshPendingCount()
|
|
2985
|
+
);
|
|
2419
2986
|
if (this.state === "streaming" && this.outboundQueue.hasOperations) {
|
|
2420
2987
|
this.flushQueue();
|
|
2421
2988
|
}
|
|
@@ -2428,18 +2995,107 @@ var SyncEngine = class {
|
|
|
2428
2995
|
this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
|
|
2429
2996
|
this.transitionTo("disconnected");
|
|
2430
2997
|
}
|
|
2431
|
-
checkDeltaComplete() {
|
|
2432
|
-
if (this.deltaSendComplete
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
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);
|
|
2439
3021
|
}
|
|
2440
|
-
|
|
2441
|
-
|
|
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);
|
|
2442
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);
|
|
2443
3099
|
}
|
|
2444
3100
|
}
|
|
2445
3101
|
flushQueue() {
|
|
@@ -2505,6 +3161,9 @@ var SyncEngine = class {
|
|
|
2505
3161
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
2506
3162
|
this.currentBatch = null;
|
|
2507
3163
|
}
|
|
3164
|
+
if (this.schemaBlocked) {
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
2508
3167
|
if (this.state !== "disconnected") {
|
|
2509
3168
|
this.emitter?.emit({ type: "sync:disconnected", reason });
|
|
2510
3169
|
this.transitionTo("disconnected");
|
|
@@ -2520,7 +3179,7 @@ var SyncEngine = class {
|
|
|
2520
3179
|
transitionTo(newState) {
|
|
2521
3180
|
const validTargets = VALID_TRANSITIONS[this.state];
|
|
2522
3181
|
if (!validTargets.includes(newState)) {
|
|
2523
|
-
throw new
|
|
3182
|
+
throw new import_core6.SyncError(`Invalid sync state transition: ${this.state} \u2192 ${newState}`, {
|
|
2524
3183
|
from: this.state,
|
|
2525
3184
|
to: newState
|
|
2526
3185
|
});
|
|
@@ -2532,6 +3191,33 @@ var SyncEngine = class {
|
|
|
2532
3191
|
this.serializer.setWireFormat(format);
|
|
2533
3192
|
}
|
|
2534
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
|
+
}
|
|
2535
3221
|
};
|
|
2536
3222
|
function awarenessStatesToWire(states) {
|
|
2537
3223
|
const wire = {};
|
|
@@ -2705,6 +3391,20 @@ var ReconnectionManager = class {
|
|
|
2705
3391
|
this.running = false;
|
|
2706
3392
|
}
|
|
2707
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
|
+
}
|
|
2708
3408
|
/**
|
|
2709
3409
|
* Stop any pending reconnection attempt.
|
|
2710
3410
|
*/
|
|
@@ -2761,11 +3461,11 @@ var ReconnectionManager = class {
|
|
|
2761
3461
|
};
|
|
2762
3462
|
|
|
2763
3463
|
// src/encryption/sync-encryptor.ts
|
|
2764
|
-
var
|
|
3464
|
+
var import_core8 = require("@korajs/core");
|
|
2765
3465
|
|
|
2766
3466
|
// src/encryption/key-derivation.ts
|
|
2767
|
-
var
|
|
2768
|
-
var KeyDerivationError = class extends
|
|
3467
|
+
var import_core7 = require("@korajs/core");
|
|
3468
|
+
var KeyDerivationError = class extends import_core7.SyncError {
|
|
2769
3469
|
constructor(message, context) {
|
|
2770
3470
|
super(message, { ...context, errorType: "KEY_DERIVATION_ERROR" });
|
|
2771
3471
|
this.name = "KeyDerivationError";
|
|
@@ -2841,13 +3541,13 @@ async function deriveVersionedKey(passphrase, version, salt) {
|
|
|
2841
3541
|
}
|
|
2842
3542
|
|
|
2843
3543
|
// src/encryption/sync-encryptor.ts
|
|
2844
|
-
var EncryptionError = class extends
|
|
3544
|
+
var EncryptionError = class extends import_core8.SyncError {
|
|
2845
3545
|
constructor(message, context) {
|
|
2846
3546
|
super(message, { ...context, errorType: "ENCRYPTION_ERROR" });
|
|
2847
3547
|
this.name = "EncryptionError";
|
|
2848
3548
|
}
|
|
2849
3549
|
};
|
|
2850
|
-
var DecryptionError = class extends
|
|
3550
|
+
var DecryptionError = class extends import_core8.SyncError {
|
|
2851
3551
|
constructor(message, context) {
|
|
2852
3552
|
super(message, { ...context, errorType: "DECRYPTION_ERROR" });
|
|
2853
3553
|
this.name = "DecryptionError";
|
|
@@ -3087,8 +3787,8 @@ var SyncEncryptor = class _SyncEncryptor {
|
|
|
3087
3787
|
);
|
|
3088
3788
|
const payload = {
|
|
3089
3789
|
v: this.currentVersion,
|
|
3090
|
-
iv:
|
|
3091
|
-
ct:
|
|
3790
|
+
iv: toBase642(iv),
|
|
3791
|
+
ct: toBase642(new Uint8Array(ciphertextBuffer)),
|
|
3092
3792
|
alg: "aes-256-gcm"
|
|
3093
3793
|
};
|
|
3094
3794
|
return {
|
|
@@ -3132,8 +3832,8 @@ var SyncEncryptor = class _SyncEncryptor {
|
|
|
3132
3832
|
);
|
|
3133
3833
|
}
|
|
3134
3834
|
try {
|
|
3135
|
-
const iv =
|
|
3136
|
-
const ciphertext =
|
|
3835
|
+
const iv = fromBase642(payload.iv);
|
|
3836
|
+
const ciphertext = fromBase642(payload.ct);
|
|
3137
3837
|
const plaintextBuffer = await globalThis.crypto.subtle.decrypt(
|
|
3138
3838
|
{ name: "AES-GCM", iv },
|
|
3139
3839
|
key.key,
|
|
@@ -3170,14 +3870,14 @@ function isEncryptedPayload(field) {
|
|
|
3170
3870
|
}
|
|
3171
3871
|
return field[ENCRYPTED_MARKER] === true && typeof field.v === "number" && typeof field.iv === "string" && typeof field.ct === "string" && typeof field.alg === "string";
|
|
3172
3872
|
}
|
|
3173
|
-
function
|
|
3873
|
+
function toBase642(bytes) {
|
|
3174
3874
|
let binary = "";
|
|
3175
3875
|
for (let i = 0; i < bytes.length; i++) {
|
|
3176
3876
|
binary += String.fromCharCode(bytes[i]);
|
|
3177
3877
|
}
|
|
3178
3878
|
return btoa(binary);
|
|
3179
3879
|
}
|
|
3180
|
-
function
|
|
3880
|
+
function fromBase642(str) {
|
|
3181
3881
|
const binary = atob(str);
|
|
3182
3882
|
const bytes = new Uint8Array(binary.length);
|
|
3183
3883
|
for (let i = 0; i < binary.length; i++) {
|
|
@@ -3190,6 +3890,7 @@ function fromBase64(str) {
|
|
|
3190
3890
|
AwarenessManager,
|
|
3191
3891
|
ChaosTransport,
|
|
3192
3892
|
ConnectionMonitor,
|
|
3893
|
+
DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD,
|
|
3193
3894
|
DecryptionError,
|
|
3194
3895
|
EncryptionError,
|
|
3195
3896
|
HttpLongPollingTransport,
|
|
@@ -3200,25 +3901,39 @@ function fromBase64(str) {
|
|
|
3200
3901
|
OutboundQueue,
|
|
3201
3902
|
ProtobufMessageSerializer,
|
|
3202
3903
|
ReconnectionManager,
|
|
3904
|
+
RichtextDocChannel,
|
|
3905
|
+
SCHEMA_MISMATCH_PREFIX,
|
|
3203
3906
|
SYNC_STATES,
|
|
3204
3907
|
SYNC_STATUSES,
|
|
3205
3908
|
ScopeViolationError,
|
|
3206
3909
|
SyncEncryptor,
|
|
3207
3910
|
SyncEngine,
|
|
3208
3911
|
WebSocketTransport,
|
|
3912
|
+
createDeltaCursorFromBatch,
|
|
3913
|
+
decodeDeltaCursor,
|
|
3914
|
+
decodeYjsUpdate,
|
|
3915
|
+
dedupeQuerySubsets,
|
|
3209
3916
|
deriveKey,
|
|
3210
3917
|
deriveVersionedKey,
|
|
3918
|
+
encodeDeltaCursor,
|
|
3919
|
+
encodeYjsUpdate,
|
|
3211
3920
|
filterOperationsByScope,
|
|
3212
3921
|
generateSalt,
|
|
3213
3922
|
isAcknowledgmentMessage,
|
|
3214
3923
|
isAwarenessUpdateMessage,
|
|
3924
|
+
isClientSchemaVersionSupported,
|
|
3215
3925
|
isEncryptedPayload,
|
|
3216
3926
|
isErrorMessage,
|
|
3217
3927
|
isHandshakeMessage,
|
|
3218
3928
|
isHandshakeResponseMessage,
|
|
3219
3929
|
isOperationBatchMessage,
|
|
3930
|
+
isSchemaMismatchReject,
|
|
3220
3931
|
isSyncMessage,
|
|
3932
|
+
isYjsDocUpdateMessage,
|
|
3933
|
+
operationMatchesQuerySubsets,
|
|
3221
3934
|
operationMatchesScope,
|
|
3935
|
+
richtextDocKey,
|
|
3936
|
+
sliceOperationsAfterCursor,
|
|
3222
3937
|
versionVectorToWire,
|
|
3223
3938
|
wireToVersionVector
|
|
3224
3939
|
});
|