@tldraw/sync-core 5.5.0-next.f9308d324e1c → 5.6.0-canary.5bfc37740468
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-cjs/index.d.ts +8 -4
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/ClientWebSocketAdapter.js +46 -18
- package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +18 -18
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/SQLiteSyncStorage.js +15 -7
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +9 -8
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +8 -1
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +82 -50
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncStorage.js +2 -2
- package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
- package/dist-cjs/lib/chunk.js +35 -3
- package/dist-cjs/lib/chunk.js.map +2 -2
- package/dist-cjs/lib/diff.js +15 -7
- package/dist-cjs/lib/diff.js.map +2 -2
- package/dist-esm/index.d.mts +8 -4
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/ClientWebSocketAdapter.mjs +47 -19
- package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +18 -18
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/SQLiteSyncStorage.mjs +15 -7
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +10 -9
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +8 -1
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +82 -50
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncStorage.mjs +2 -2
- package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/chunk.mjs +35 -3
- package/dist-esm/lib/chunk.mjs.map +2 -2
- package/dist-esm/lib/diff.mjs +16 -8
- package/dist-esm/lib/diff.mjs.map +2 -2
- package/package.json +22 -18
- package/src/lib/ClientWebSocketAdapter.test.ts +221 -16
- package/src/lib/ClientWebSocketAdapter.ts +62 -23
- package/src/lib/InMemorySyncStorage.test.ts +21 -0
- package/src/lib/InMemorySyncStorage.ts +22 -24
- package/src/lib/SQLiteSyncStorage.test.ts +60 -1
- package/src/lib/SQLiteSyncStorage.ts +24 -16
- package/src/lib/TLSocketRoom.ts +16 -9
- package/src/lib/TLSyncClient.test.ts +61 -0
- package/src/lib/TLSyncClient.ts +11 -0
- package/src/lib/TLSyncRoom.ts +122 -54
- package/src/lib/TLSyncStorage.ts +7 -2
- package/src/lib/chunk.test.ts +88 -1
- package/src/lib/chunk.ts +66 -3
- package/src/lib/diff.test.ts +91 -1
- package/src/lib/diff.ts +30 -9
- package/src/test/TLSocketRoom.test.ts +63 -0
- package/src/test/TLSyncRoom.test.ts +300 -0
- package/src/test/objectStore.test.ts +35 -0
- package/src/test/upgradeDowngrade.test.ts +21 -0
|
@@ -37,6 +37,23 @@ import {
|
|
|
37
37
|
} from "./TLSyncStorage.mjs";
|
|
38
38
|
const DATA_MESSAGE_DEBOUNCE_INTERVAL = 1e3 / 60;
|
|
39
39
|
const timeSince = (time) => Date.now() - time;
|
|
40
|
+
const MAX_CLOSE_REASON_BYTES = 123;
|
|
41
|
+
const closeReasonEncoder = new TextEncoder();
|
|
42
|
+
const truncatedSuffix = (droppedBytes) => `... (+${droppedBytes} bytes)`;
|
|
43
|
+
function truncateCloseReason(reason) {
|
|
44
|
+
const totalBytes = closeReasonEncoder.encode(reason).length;
|
|
45
|
+
if (totalBytes <= MAX_CLOSE_REASON_BYTES) return reason;
|
|
46
|
+
const budget = MAX_CLOSE_REASON_BYTES - truncatedSuffix(totalBytes).length;
|
|
47
|
+
let out = "";
|
|
48
|
+
let outBytes = 0;
|
|
49
|
+
for (const char of reason) {
|
|
50
|
+
const charBytes = closeReasonEncoder.encode(char).length;
|
|
51
|
+
if (outBytes + charBytes > budget) break;
|
|
52
|
+
out += char;
|
|
53
|
+
outBytes += charBytes;
|
|
54
|
+
}
|
|
55
|
+
return out + truncatedSuffix(totalBytes - outBytes);
|
|
56
|
+
}
|
|
40
57
|
class TLSyncRoom {
|
|
41
58
|
// A table of connected clients
|
|
42
59
|
sessions = /* @__PURE__ */ new Map();
|
|
@@ -81,7 +98,7 @@ class TLSyncRoom {
|
|
|
81
98
|
}
|
|
82
99
|
}, 1e3);
|
|
83
100
|
scheduleFollowUpPrune() {
|
|
84
|
-
if (this.pruneTimer) return;
|
|
101
|
+
if (this._isClosed || this.pruneTimer) return;
|
|
85
102
|
this.pruneTimer = setTimeout(this.pruneSessions, SESSION_REMOVAL_WAIT_TIME + 100);
|
|
86
103
|
}
|
|
87
104
|
presenceStore = new PresenceStore();
|
|
@@ -92,11 +109,23 @@ class TLSyncRoom {
|
|
|
92
109
|
* and stops background processes.
|
|
93
110
|
*/
|
|
94
111
|
close() {
|
|
112
|
+
this._isClosed = true;
|
|
95
113
|
this.disposables.forEach((d) => d());
|
|
96
114
|
this.sessions.forEach((session) => {
|
|
97
|
-
|
|
115
|
+
this.clearDebounceTimer(session);
|
|
116
|
+
try {
|
|
117
|
+
session.socket.close();
|
|
118
|
+
} catch {
|
|
119
|
+
}
|
|
98
120
|
});
|
|
99
|
-
this.
|
|
121
|
+
this.sessions.clear();
|
|
122
|
+
}
|
|
123
|
+
clearDebounceTimer(session) {
|
|
124
|
+
if (session.state === RoomSessionState.Connected && session.debounceTimer !== null) {
|
|
125
|
+
clearTimeout(session.debounceTimer);
|
|
126
|
+
session.debounceTimer = null;
|
|
127
|
+
session.outstandingDataMessages = [];
|
|
128
|
+
}
|
|
100
129
|
}
|
|
101
130
|
/**
|
|
102
131
|
* Check if the room has been closed and is no longer accepting connections.
|
|
@@ -257,6 +286,10 @@ class TLSyncRoom {
|
|
|
257
286
|
if (session.outstandingDataMessages.length > 0) {
|
|
258
287
|
const data = session.outstandingDataMessages;
|
|
259
288
|
session.outstandingDataMessages = [];
|
|
289
|
+
if (!session.socket.isOpen) {
|
|
290
|
+
this.cancelSession(sessionId);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
260
293
|
session.socket.sendMessage({ type: "data", data });
|
|
261
294
|
}
|
|
262
295
|
}
|
|
@@ -268,9 +301,10 @@ class TLSyncRoom {
|
|
|
268
301
|
return;
|
|
269
302
|
}
|
|
270
303
|
this.sessions.delete(sessionId);
|
|
304
|
+
this.clearDebounceTimer(session);
|
|
271
305
|
try {
|
|
272
306
|
if (fatalReason) {
|
|
273
|
-
session.socket.close(TLSyncErrorCloseEventCode, fatalReason);
|
|
307
|
+
session.socket.close(TLSyncErrorCloseEventCode, truncateCloseReason(fatalReason));
|
|
274
308
|
} else {
|
|
275
309
|
session.socket.close();
|
|
276
310
|
}
|
|
@@ -298,6 +332,7 @@ class TLSyncRoom {
|
|
|
298
332
|
this.log?.warn?.("Tried to cancel session that is already awaiting removal");
|
|
299
333
|
return;
|
|
300
334
|
}
|
|
335
|
+
this.clearDebounceTimer(session);
|
|
301
336
|
this.sessions.set(sessionId, {
|
|
302
337
|
state: RoomSessionState.AwaitingRemoval,
|
|
303
338
|
sessionId,
|
|
@@ -326,8 +361,8 @@ class TLSyncRoom {
|
|
|
326
361
|
* @param sourceSessionId - Optional session ID to exclude from the broadcast
|
|
327
362
|
*/
|
|
328
363
|
broadcastPatch(diff, networkDiff, sourceSessionId) {
|
|
329
|
-
const
|
|
330
|
-
|
|
364
|
+
const legacyAppendMode = !this.getCanEmitStringAppend();
|
|
365
|
+
const unmigrated = networkDiff ?? toNetworkDiff(diff, legacyAppendMode);
|
|
331
366
|
this.sessions.forEach((session) => {
|
|
332
367
|
if (session.state !== RoomSessionState.Connected) return;
|
|
333
368
|
if (sourceSessionId === session.sessionId) return;
|
|
@@ -339,7 +374,9 @@ class TLSyncRoom {
|
|
|
339
374
|
session.sessionId,
|
|
340
375
|
session.serializedSchema,
|
|
341
376
|
session.requiresDownMigrations,
|
|
342
|
-
diff
|
|
377
|
+
diff,
|
|
378
|
+
unmigrated,
|
|
379
|
+
legacyAppendMode
|
|
343
380
|
);
|
|
344
381
|
if (!diffResult.ok) return;
|
|
345
382
|
this._unsafe_sendMessage(session.sessionId, {
|
|
@@ -399,7 +436,12 @@ class TLSyncRoom {
|
|
|
399
436
|
*/
|
|
400
437
|
handleNewSession(opts) {
|
|
401
438
|
const { sessionId, socket, meta, isReadonly, objectAccess } = opts;
|
|
439
|
+
if (this._isClosed) {
|
|
440
|
+
socket.close();
|
|
441
|
+
return this;
|
|
442
|
+
}
|
|
402
443
|
const existing = this.sessions.get(sessionId);
|
|
444
|
+
if (existing) this.clearDebounceTimer(existing);
|
|
403
445
|
this.sessions.set(sessionId, {
|
|
404
446
|
state: RoomSessionState.AwaitingConnectMessage,
|
|
405
447
|
sessionId,
|
|
@@ -435,6 +477,10 @@ class TLSyncRoom {
|
|
|
435
477
|
requiresLegacyRejection,
|
|
436
478
|
supportsStringAppend
|
|
437
479
|
} = opts;
|
|
480
|
+
if (this._isClosed) {
|
|
481
|
+
socket.close();
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
438
484
|
const migrations = this.schema.getMigrationsSince(serializedSchema);
|
|
439
485
|
const requiresDownMigrations = migrations.ok ? migrations.value.length > 0 : false;
|
|
440
486
|
this.sessions.set(sessionId, {
|
|
@@ -456,6 +502,14 @@ class TLSyncRoom {
|
|
|
456
502
|
if (presenceRecord && presenceId) {
|
|
457
503
|
this.presenceStore.set(presenceId, presenceRecord);
|
|
458
504
|
}
|
|
505
|
+
if (!migrations.ok) {
|
|
506
|
+
this.rejectSession(sessionId, this.getVersionMismatchReason(serializedSchema));
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (migrations.value.some((m) => m.scope !== "record" || !m.down)) {
|
|
510
|
+
this.rejectSession(sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
459
513
|
}
|
|
460
514
|
/**
|
|
461
515
|
* Checks if all connected sessions support string append operations (protocol version 8+).
|
|
@@ -488,11 +542,12 @@ class TLSyncRoom {
|
|
|
488
542
|
* @param requiresDownMigrations - Whether the client needs down migrations
|
|
489
543
|
* @param diff - The TLSyncForwardDiff containing full records to migrate
|
|
490
544
|
* @param unmigrated - Optional pre-computed NetworkDiff for when no migration is needed
|
|
545
|
+
* @param legacyAppendMode - Emit string appends as puts (SES5); defaults to the room-wide state
|
|
491
546
|
* @returns A NetworkDiff with migrated records, or a migration failure
|
|
492
547
|
*/
|
|
493
|
-
migrateDiffOrRejectSession(sessionId, serializedSchema, requiresDownMigrations, diff, unmigrated) {
|
|
548
|
+
migrateDiffOrRejectSession(sessionId, serializedSchema, requiresDownMigrations, diff, unmigrated, legacyAppendMode = !this.getCanEmitStringAppend()) {
|
|
494
549
|
if (!requiresDownMigrations) {
|
|
495
|
-
return Result.ok(unmigrated ?? toNetworkDiff(diff
|
|
550
|
+
return Result.ok(unmigrated ?? toNetworkDiff(diff, legacyAppendMode));
|
|
496
551
|
}
|
|
497
552
|
const result = {};
|
|
498
553
|
for (const [id, put] of objectMapEntriesIterable(diff.puts)) {
|
|
@@ -508,7 +563,7 @@ class TLSyncRoom {
|
|
|
508
563
|
this.rejectSession(sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
|
|
509
564
|
return Result.err(toResult.reason);
|
|
510
565
|
}
|
|
511
|
-
const patch = diffRecord(fromResult.value, toResult.value);
|
|
566
|
+
const patch = diffRecord(fromResult.value, toResult.value, legacyAppendMode);
|
|
512
567
|
if (patch) {
|
|
513
568
|
result[id] = [RecordOpType.Patch, patch];
|
|
514
569
|
}
|
|
@@ -578,7 +633,8 @@ class TLSyncRoom {
|
|
|
578
633
|
* Sends appropriate error messages before closing the connection.
|
|
579
634
|
*
|
|
580
635
|
* @param sessionId - The session to reject
|
|
581
|
-
* @param fatalReason - The reason for rejection (optional)
|
|
636
|
+
* @param fatalReason - The reason for rejection (optional). WebSocket close reasons are capped
|
|
637
|
+
* at 123 UTF-8 bytes, so a longer reason is truncated and ends with `... (+N bytes)`.
|
|
582
638
|
* @example
|
|
583
639
|
* ```ts
|
|
584
640
|
* // Reject due to version mismatch
|
|
@@ -628,6 +684,7 @@ class TLSyncRoom {
|
|
|
628
684
|
}
|
|
629
685
|
forceAllReconnect() {
|
|
630
686
|
for (const session of this.sessions.values()) {
|
|
687
|
+
if (session.state !== RoomSessionState.Connected) continue;
|
|
631
688
|
this.removeSession(session.sessionId);
|
|
632
689
|
}
|
|
633
690
|
}
|
|
@@ -651,7 +708,8 @@ class TLSyncRoom {
|
|
|
651
708
|
if (theirSchema.schemaVersion > ourSchema.schemaVersion) {
|
|
652
709
|
return TLSyncErrorCloseEventReason.SERVER_TOO_OLD;
|
|
653
710
|
}
|
|
654
|
-
if (theirSchema.schemaVersion === 2 && ourSchema.schemaVersion === 2
|
|
711
|
+
if (theirSchema.schemaVersion === 2 && ourSchema.schemaVersion === 2 && // a malformed client schema must reject the session, not throw
|
|
712
|
+
typeof theirSchema.sequences === "object" && theirSchema.sequences !== null) {
|
|
655
713
|
for (const [sequenceId, theirVersion] of Object.entries(theirSchema.sequences)) {
|
|
656
714
|
const ourVersion = ourSchema.sequences[sequenceId];
|
|
657
715
|
if (ourVersion === void 0 || theirVersion > ourVersion) {
|
|
@@ -696,7 +754,7 @@ class TLSyncRoom {
|
|
|
696
754
|
}
|
|
697
755
|
const sessionSchema = isEqual(message.schema, this.serializedSchema) ? this.serializedSchema : message.schema;
|
|
698
756
|
const requiresDownMigrations = migrations.value.length > 0;
|
|
699
|
-
const connect =
|
|
757
|
+
const connect = (msg) => {
|
|
700
758
|
this.sessions.set(session.sessionId, {
|
|
701
759
|
state: RoomSessionState.Connected,
|
|
702
760
|
sessionId: session.sessionId,
|
|
@@ -804,7 +862,7 @@ class TLSyncRoom {
|
|
|
804
862
|
}
|
|
805
863
|
if (doc) {
|
|
806
864
|
const recordType = assertExists(getOwnProperty(this.schema.types, doc.typeName));
|
|
807
|
-
const diff = diffAndValidateRecord(doc, state, recordType);
|
|
865
|
+
const diff = diffAndValidateRecord(doc, state, recordType, legacyAppendMode);
|
|
808
866
|
if (diff) {
|
|
809
867
|
storage.set(id, state);
|
|
810
868
|
propagateOp(changes2, id, [RecordOpType.Patch, diff], doc, state);
|
|
@@ -876,6 +934,7 @@ class TLSyncRoom {
|
|
|
876
934
|
}
|
|
877
935
|
}
|
|
878
936
|
const canWrite = (typeName) => !session || (this.objectTypes.has(typeName) ? session.objectAccess !== "read" : !session.isReadonly);
|
|
937
|
+
const authSession = session ? { sessionId: session.sessionId, isReadonly: session.isReadonly, meta: session.meta } : null;
|
|
879
938
|
if (message.diff) {
|
|
880
939
|
for (const [id, op] of objectMapEntriesIterable(message.diff)) {
|
|
881
940
|
switch (op[0]) {
|
|
@@ -905,25 +964,7 @@ class TLSyncRoom {
|
|
|
905
964
|
if (authorizePut) {
|
|
906
965
|
authorize = (prevRec, next) => {
|
|
907
966
|
const result2 = authorizePut(
|
|
908
|
-
prevRec ? {
|
|
909
|
-
session: {
|
|
910
|
-
sessionId: session.sessionId,
|
|
911
|
-
isReadonly: session.isReadonly,
|
|
912
|
-
meta: session.meta
|
|
913
|
-
},
|
|
914
|
-
type: "update",
|
|
915
|
-
prev: prevRec,
|
|
916
|
-
next
|
|
917
|
-
} : {
|
|
918
|
-
session: {
|
|
919
|
-
sessionId: session.sessionId,
|
|
920
|
-
isReadonly: session.isReadonly,
|
|
921
|
-
meta: session.meta
|
|
922
|
-
},
|
|
923
|
-
type: "create",
|
|
924
|
-
prev: null,
|
|
925
|
-
next
|
|
926
|
-
}
|
|
967
|
+
prevRec ? { session: authSession, type: "update", prev: prevRec, next } : { session: authSession, type: "create", prev: null, next }
|
|
927
968
|
);
|
|
928
969
|
if (!result2) {
|
|
929
970
|
this.log?.warn?.(
|
|
@@ -949,11 +990,7 @@ class TLSyncRoom {
|
|
|
949
990
|
const authorizePatch = session && this.authorizerFor(doc.typeName);
|
|
950
991
|
const authorize = authorizePatch ? (prev, next) => {
|
|
951
992
|
const result2 = authorizePatch({
|
|
952
|
-
session:
|
|
953
|
-
sessionId: session.sessionId,
|
|
954
|
-
isReadonly: session.isReadonly,
|
|
955
|
-
meta: session.meta
|
|
956
|
-
},
|
|
993
|
+
session: authSession,
|
|
957
994
|
type: "update",
|
|
958
995
|
prev,
|
|
959
996
|
next
|
|
@@ -979,16 +1016,7 @@ class TLSyncRoom {
|
|
|
979
1016
|
}
|
|
980
1017
|
if (!canWrite(doc.typeName)) continue;
|
|
981
1018
|
const authorizeRemove = session && this.authorizerFor(doc.typeName);
|
|
982
|
-
if (authorizeRemove && !authorizeRemove({
|
|
983
|
-
session: {
|
|
984
|
-
sessionId: session.sessionId,
|
|
985
|
-
isReadonly: session.isReadonly,
|
|
986
|
-
meta: session.meta
|
|
987
|
-
},
|
|
988
|
-
type: "delete",
|
|
989
|
-
prev: doc,
|
|
990
|
-
next: null
|
|
991
|
-
})) {
|
|
1019
|
+
if (authorizeRemove && !authorizeRemove({ session: authSession, type: "delete", prev: doc, next: null })) {
|
|
992
1020
|
this.log?.warn?.(
|
|
993
1021
|
"authorizer vetoed delete",
|
|
994
1022
|
doc.typeName,
|
|
@@ -1012,7 +1040,10 @@ class TLSyncRoom {
|
|
|
1012
1040
|
this.lastDocumentClock = documentClock;
|
|
1013
1041
|
let pushResult;
|
|
1014
1042
|
if (changes && session) {
|
|
1015
|
-
result.docChanges.diffs = {
|
|
1043
|
+
result.docChanges.diffs = {
|
|
1044
|
+
networkDiff: toNetworkDiff(changes, legacyAppendMode),
|
|
1045
|
+
diff: changes
|
|
1046
|
+
};
|
|
1016
1047
|
}
|
|
1017
1048
|
if (isEqual(result.docChanges.diffs?.networkDiff, message.diff)) {
|
|
1018
1049
|
pushResult = {
|
|
@@ -1034,7 +1065,8 @@ class TLSyncRoom {
|
|
|
1034
1065
|
session.serializedSchema,
|
|
1035
1066
|
session.requiresDownMigrations,
|
|
1036
1067
|
result.docChanges.diffs.diff,
|
|
1037
|
-
result.docChanges.diffs.networkDiff
|
|
1068
|
+
result.docChanges.diffs.networkDiff,
|
|
1069
|
+
legacyAppendMode
|
|
1038
1070
|
);
|
|
1039
1071
|
if (diff.ok) {
|
|
1040
1072
|
pushResult = {
|