@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.
Files changed (60) hide show
  1. package/dist-cjs/index.d.ts +8 -4
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/ClientWebSocketAdapter.js +46 -18
  4. package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
  5. package/dist-cjs/lib/InMemorySyncStorage.js +18 -18
  6. package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
  7. package/dist-cjs/lib/SQLiteSyncStorage.js +15 -7
  8. package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
  9. package/dist-cjs/lib/TLSocketRoom.js +9 -8
  10. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncClient.js +8 -1
  12. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  13. package/dist-cjs/lib/TLSyncRoom.js +82 -50
  14. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  15. package/dist-cjs/lib/TLSyncStorage.js +2 -2
  16. package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
  17. package/dist-cjs/lib/chunk.js +35 -3
  18. package/dist-cjs/lib/chunk.js.map +2 -2
  19. package/dist-cjs/lib/diff.js +15 -7
  20. package/dist-cjs/lib/diff.js.map +2 -2
  21. package/dist-esm/index.d.mts +8 -4
  22. package/dist-esm/index.mjs +1 -1
  23. package/dist-esm/lib/ClientWebSocketAdapter.mjs +47 -19
  24. package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
  25. package/dist-esm/lib/InMemorySyncStorage.mjs +18 -18
  26. package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
  27. package/dist-esm/lib/SQLiteSyncStorage.mjs +15 -7
  28. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
  29. package/dist-esm/lib/TLSocketRoom.mjs +10 -9
  30. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  31. package/dist-esm/lib/TLSyncClient.mjs +8 -1
  32. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  33. package/dist-esm/lib/TLSyncRoom.mjs +82 -50
  34. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  35. package/dist-esm/lib/TLSyncStorage.mjs +2 -2
  36. package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
  37. package/dist-esm/lib/chunk.mjs +35 -3
  38. package/dist-esm/lib/chunk.mjs.map +2 -2
  39. package/dist-esm/lib/diff.mjs +16 -8
  40. package/dist-esm/lib/diff.mjs.map +2 -2
  41. package/package.json +22 -18
  42. package/src/lib/ClientWebSocketAdapter.test.ts +221 -16
  43. package/src/lib/ClientWebSocketAdapter.ts +62 -23
  44. package/src/lib/InMemorySyncStorage.test.ts +21 -0
  45. package/src/lib/InMemorySyncStorage.ts +22 -24
  46. package/src/lib/SQLiteSyncStorage.test.ts +60 -1
  47. package/src/lib/SQLiteSyncStorage.ts +24 -16
  48. package/src/lib/TLSocketRoom.ts +16 -9
  49. package/src/lib/TLSyncClient.test.ts +61 -0
  50. package/src/lib/TLSyncClient.ts +11 -0
  51. package/src/lib/TLSyncRoom.ts +122 -54
  52. package/src/lib/TLSyncStorage.ts +7 -2
  53. package/src/lib/chunk.test.ts +88 -1
  54. package/src/lib/chunk.ts +66 -3
  55. package/src/lib/diff.test.ts +91 -1
  56. package/src/lib/diff.ts +30 -9
  57. package/src/test/TLSocketRoom.test.ts +63 -0
  58. package/src/test/TLSyncRoom.test.ts +300 -0
  59. package/src/test/objectStore.test.ts +35 -0
  60. 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
- session.socket.close();
115
+ this.clearDebounceTimer(session);
116
+ try {
117
+ session.socket.close();
118
+ } catch {
119
+ }
98
120
  });
99
- this._isClosed = true;
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 unmigrated = networkDiff ?? toNetworkDiff(diff);
330
- if (!unmigrated) return this;
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 = async (msg) => {
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 = { networkDiff: toNetworkDiff(changes) ?? {}, diff: changes };
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 = {