@tldraw/sync-core 4.5.3 → 4.6.0-canary.00a8c03b5687

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 (46) hide show
  1. package/dist-cjs/index.d.ts +86 -0
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/ClientWebSocketAdapter.js +1 -1
  5. package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
  6. package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js.map +2 -2
  7. package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
  8. package/dist-cjs/lib/ServerSocketAdapter.js +1 -1
  9. package/dist-cjs/lib/ServerSocketAdapter.js.map +1 -1
  10. package/dist-cjs/lib/TLSocketRoom.js +137 -1
  11. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  12. package/dist-cjs/lib/TLSyncClient.js +1 -1
  13. package/dist-cjs/lib/TLSyncClient.js.map +1 -1
  14. package/dist-cjs/lib/TLSyncRoom.js +71 -5
  15. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  16. package/dist-cjs/lib/protocol.js.map +1 -1
  17. package/dist-esm/index.d.mts +86 -0
  18. package/dist-esm/index.mjs +1 -1
  19. package/dist-esm/index.mjs.map +2 -2
  20. package/dist-esm/lib/ClientWebSocketAdapter.mjs +1 -1
  21. package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
  22. package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs.map +2 -2
  23. package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
  24. package/dist-esm/lib/ServerSocketAdapter.mjs +1 -1
  25. package/dist-esm/lib/ServerSocketAdapter.mjs.map +1 -1
  26. package/dist-esm/lib/TLSocketRoom.mjs +137 -1
  27. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  28. package/dist-esm/lib/TLSyncClient.mjs +1 -1
  29. package/dist-esm/lib/TLSyncClient.mjs.map +1 -1
  30. package/dist-esm/lib/TLSyncRoom.mjs +73 -6
  31. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  32. package/dist-esm/lib/protocol.mjs.map +1 -1
  33. package/package.json +7 -7
  34. package/src/index.ts +3 -0
  35. package/src/lib/ClientWebSocketAdapter.ts +5 -5
  36. package/src/lib/DurableObjectSqliteSyncWrapper.ts +1 -2
  37. package/src/lib/InMemorySyncStorage.ts +3 -3
  38. package/src/lib/ServerSocketAdapter.ts +6 -6
  39. package/src/lib/TLSocketRoom.ts +193 -6
  40. package/src/lib/TLSyncClient.test.ts +4 -7
  41. package/src/lib/TLSyncClient.ts +1 -1
  42. package/src/lib/TLSyncRoom.ts +96 -4
  43. package/src/lib/protocol.ts +2 -0
  44. package/src/test/RandomSource.ts +1 -1
  45. package/src/test/TLSocketRoom.test.ts +527 -13
  46. package/src/test/TestSocketPair.ts +1 -1
@@ -21,7 +21,7 @@ import { getTlsyncProtocolVersion } from '../lib/protocol'
21
21
  import { WebSocketMinimal } from '../lib/ServerSocketAdapter'
22
22
  import { TLSocketRoom, TLSyncLog } from '../lib/TLSocketRoom'
23
23
  import { TLSyncErrorCloseEventReason } from '../lib/TLSyncClient'
24
- import { RoomSnapshot } from '../lib/TLSyncRoom'
24
+ import { TLSyncRoom, type RoomSnapshot } from '../lib/TLSyncRoom'
25
25
 
26
26
  function getStore() {
27
27
  const schema = createTLSchema()
@@ -805,6 +805,524 @@ describe(TLSocketRoom, () => {
805
805
  })
806
806
  })
807
807
 
808
+ describe('Hibernation support', () => {
809
+ function connectSession(room: TLSocketRoom, sessionId: string, socket: WebSocketMinimal) {
810
+ room.handleSocketConnect({ sessionId, socket })
811
+ const connectRequest = {
812
+ type: 'connect' as const,
813
+ connectRequestId: `connect-${sessionId}`,
814
+ lastServerClock: 0,
815
+ protocolVersion: getTlsyncProtocolVersion(),
816
+ schema: createTLSchema().serialize(),
817
+ }
818
+ room.handleSocketMessage(sessionId, JSON.stringify(connectRequest))
819
+ }
820
+
821
+ describe('getSessionSnapshot', () => {
822
+ it('returns null for unknown session', () => {
823
+ const room = new TLSocketRoom({})
824
+ expect(room.getSessionSnapshot('nonexistent')).toBeNull()
825
+ })
826
+
827
+ it('returns null for session not yet connected', () => {
828
+ const room = new TLSocketRoom({})
829
+ const socket = createMockSocket()
830
+ room.handleSocketConnect({ sessionId: 'test', socket })
831
+ expect(room.getSessionSnapshot('test')).toBeNull()
832
+ })
833
+
834
+ it('returns snapshot for connected session', () => {
835
+ const room = new TLSocketRoom({})
836
+ const socket = createMockSocket()
837
+ connectSession(room, 'test', socket)
838
+
839
+ const snapshot = room.getSessionSnapshot('test')
840
+ expect(snapshot).not.toBeNull()
841
+ expect(snapshot!.serializedSchema).toBeDefined()
842
+ expect(snapshot!.isReadonly).toBe(false)
843
+ expect(snapshot!.presenceId).toBeDefined()
844
+ expect(snapshot!.requiresLegacyRejection).toBe(false)
845
+ expect(snapshot!.supportsStringAppend).toBe(true)
846
+ })
847
+
848
+ it('includes presence record when present', () => {
849
+ const store = getStore()
850
+ store.ensureStoreIsUsable()
851
+ const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
852
+ const socket = createMockSocket()
853
+ connectSession(room, 'test', socket)
854
+
855
+ const presence = InstancePresenceRecordType.create({
856
+ id: InstancePresenceRecordType.createId('p1'),
857
+ userId: 'user1',
858
+ userName: 'User 1',
859
+ currentPageId: PageRecordType.createId('page'),
860
+ })
861
+ const pushRequest = {
862
+ type: 'push' as const,
863
+ clientClock: 1,
864
+ presence: [RecordOpType.Put, presence] as [typeof RecordOpType.Put, typeof presence],
865
+ }
866
+ room.handleSocketMessage('test', JSON.stringify(pushRequest))
867
+
868
+ const snapshot = room.getSessionSnapshot('test')
869
+ expect(snapshot).not.toBeNull()
870
+ expect(snapshot!.presenceRecord).not.toBeNull()
871
+ expect((snapshot!.presenceRecord as any).userId).toBe('user1')
872
+ })
873
+ })
874
+
875
+ describe('handleSocketResume', () => {
876
+ it('creates a connected session that handles pings', () => {
877
+ const room = new TLSocketRoom({})
878
+ const socket = createMockSocket()
879
+
880
+ connectSession(room, 'original', socket)
881
+ const snapshot = room.getSessionSnapshot('original')!
882
+
883
+ // Simulate hibernation: create a new room
884
+ const room2 = new TLSocketRoom({})
885
+ const socket2 = createMockSocket()
886
+ room2.handleSocketResume({
887
+ sessionId: 'original',
888
+ socket: socket2,
889
+ snapshot,
890
+ })
891
+
892
+ expect(room2.getNumActiveSessions()).toBe(1)
893
+ expect(room2.getSessions()[0].isConnected).toBe(true)
894
+
895
+ // Should handle pings (pong is sent)
896
+ room2.handleSocketMessage('original', JSON.stringify({ type: 'ping' }))
897
+ expect(socket2.send).toHaveBeenCalledWith(JSON.stringify({ type: 'pong' }))
898
+ })
899
+
900
+ it('creates a connected session that handles pushes', () => {
901
+ const store = getStore()
902
+ store.ensureStoreIsUsable()
903
+ const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
904
+ const socket = createMockSocket()
905
+ connectSession(room, 'original', socket)
906
+ const snapshot = room.getSessionSnapshot('original')!
907
+
908
+ // Simulate hibernation: new room with same storage
909
+ const room2 = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
910
+ const socket2 = createMockSocket()
911
+ room2.handleSocketResume({
912
+ sessionId: 'original',
913
+ socket: socket2,
914
+ snapshot,
915
+ })
916
+
917
+ // Send a push with a new page
918
+ const pageId = PageRecordType.createId('new-page')
919
+ const pushRequest = {
920
+ type: 'push' as const,
921
+ clientClock: 1,
922
+ diff: {
923
+ [pageId]: [
924
+ RecordOpType.Put,
925
+ PageRecordType.create({ id: pageId, name: 'New Page', index: 'a2' as any }),
926
+ ],
927
+ },
928
+ }
929
+ room2.handleSocketMessage('original', JSON.stringify(pushRequest))
930
+
931
+ // Should have processed the push (record exists)
932
+ const record = room2.getRecord(pageId)
933
+ expect(record).toBeDefined()
934
+ expect((record as TLPage).name).toBe('New Page')
935
+ })
936
+
937
+ it('restores presence into the presence store', () => {
938
+ const store = getStore()
939
+ store.ensureStoreIsUsable()
940
+ const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
941
+ const socket = createMockSocket()
942
+ connectSession(room, 'test', socket)
943
+
944
+ // Push presence
945
+ const presence = InstancePresenceRecordType.create({
946
+ id: InstancePresenceRecordType.createId('p1'),
947
+ userId: 'user1',
948
+ userName: 'User 1',
949
+ currentPageId: PageRecordType.createId('page'),
950
+ })
951
+ room.handleSocketMessage(
952
+ 'test',
953
+ JSON.stringify({
954
+ type: 'push',
955
+ clientClock: 1,
956
+ presence: [RecordOpType.Put, presence],
957
+ })
958
+ )
959
+
960
+ const snapshot = room.getSessionSnapshot('test')!
961
+ expect(snapshot.presenceRecord).not.toBeNull()
962
+
963
+ // Resume in a new room
964
+ const room2 = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
965
+ const socket2 = createMockSocket()
966
+ room2.handleSocketResume({
967
+ sessionId: 'test',
968
+ socket: socket2,
969
+ snapshot,
970
+ })
971
+
972
+ // Presence should be restored
973
+ const presenceRecords = room2.getPresenceRecords()
974
+ expect(Object.keys(presenceRecords)).toHaveLength(1)
975
+ const restored = Object.values(presenceRecords)[0]
976
+ expect((restored as any).userId).toBe('user1')
977
+ })
978
+
979
+ it('does not attach event listeners', () => {
980
+ const room = new TLSocketRoom({})
981
+ const socket = createMockSocket()
982
+ connectSession(room, 'test', socket)
983
+ const snapshot = room.getSessionSnapshot('test')!
984
+
985
+ const room2 = new TLSocketRoom({})
986
+ const socket2 = createMockSocket()
987
+ room2.handleSocketResume({
988
+ sessionId: 'test',
989
+ socket: socket2,
990
+ snapshot,
991
+ })
992
+
993
+ // addEventListener should NOT have been called on the resumed socket
994
+ expect(socket2.addEventListener).not.toHaveBeenCalled()
995
+ })
996
+
997
+ it('supports session metadata', () => {
998
+ const room = new TLSocketRoom<TLRecord, TestSessionMeta>({})
999
+ const socket = createMockSocket()
1000
+ const snapshot = {
1001
+ serializedSchema: createTLSchema().serialize(),
1002
+ isReadonly: false,
1003
+ presenceId: null,
1004
+ presenceRecord: null,
1005
+ requiresLegacyRejection: false,
1006
+ supportsStringAppend: true,
1007
+ }
1008
+
1009
+ room.handleSocketResume({
1010
+ sessionId: 'test',
1011
+ socket,
1012
+ snapshot,
1013
+ meta: { userId: 'user1', userName: 'Alice' },
1014
+ })
1015
+
1016
+ const sessions = room.getSessions()
1017
+ expect(sessions[0].meta).toEqual({ userId: 'user1', userName: 'Alice' })
1018
+ })
1019
+
1020
+ it('handles readonly sessions', () => {
1021
+ const room = new TLSocketRoom({})
1022
+ const socket = createMockSocket()
1023
+ const snapshot = {
1024
+ serializedSchema: createTLSchema().serialize(),
1025
+ isReadonly: true,
1026
+ presenceId: null,
1027
+ presenceRecord: null,
1028
+ requiresLegacyRejection: false,
1029
+ supportsStringAppend: true,
1030
+ }
1031
+
1032
+ room.handleSocketResume({
1033
+ sessionId: 'test',
1034
+ socket,
1035
+ snapshot,
1036
+ })
1037
+
1038
+ expect(room.getSessions()[0].isReadonly).toBe(true)
1039
+ })
1040
+ })
1041
+
1042
+ describe('clientTimeout', () => {
1043
+ it('uses default idle timeout when not specified', () => {
1044
+ const room = new TLSocketRoom({})
1045
+ const socket = createMockSocket()
1046
+ connectSession(room, 'test', socket)
1047
+
1048
+ expect(room.getNumActiveSessions()).toBe(1)
1049
+ })
1050
+
1051
+ it('accepts Infinity as clientTimeout', () => {
1052
+ const room = new TLSocketRoom({ clientTimeout: Infinity })
1053
+ const socket = createMockSocket()
1054
+ connectSession(room, 'test', socket)
1055
+
1056
+ expect(room.getNumActiveSessions()).toBe(1)
1057
+ })
1058
+
1059
+ it('accepts custom clientTimeout value', () => {
1060
+ const room = new TLSocketRoom({ clientTimeout: 60000 })
1061
+ expect(room.opts.clientTimeout).toBe(60000)
1062
+ })
1063
+ })
1064
+
1065
+ describe('onSessionSnapshot', () => {
1066
+ it('calls onSessionSnapshot after debounce on message receipt', () => {
1067
+ vi.useFakeTimers()
1068
+ try {
1069
+ const onSessionSnapshot = vi.fn()
1070
+ const room = new TLSocketRoom({ onSessionSnapshot })
1071
+ const socket = createMockSocket()
1072
+ connectSession(room, 'test', socket)
1073
+
1074
+ // Send a ping to trigger the debounce
1075
+ room.handleSocketMessage('test', JSON.stringify({ type: 'ping' }))
1076
+
1077
+ // Not called immediately
1078
+ expect(onSessionSnapshot).not.toHaveBeenCalled()
1079
+
1080
+ // Advance past the 5s debounce
1081
+ vi.advanceTimersByTime(5100)
1082
+
1083
+ expect(onSessionSnapshot).toHaveBeenCalledTimes(1)
1084
+ expect(onSessionSnapshot).toHaveBeenCalledWith(
1085
+ 'test',
1086
+ expect.objectContaining({
1087
+ serializedSchema: expect.anything(),
1088
+ isReadonly: false,
1089
+ })
1090
+ )
1091
+
1092
+ room.close()
1093
+ } finally {
1094
+ vi.useRealTimers()
1095
+ }
1096
+ })
1097
+
1098
+ it('does not call onSessionSnapshot after socket close (timer cleared)', () => {
1099
+ vi.useFakeTimers()
1100
+ try {
1101
+ const onSessionSnapshot = vi.fn()
1102
+ const room = new TLSocketRoom({ onSessionSnapshot })
1103
+ const socket = createMockSocket()
1104
+ connectSession(room, 'test', socket)
1105
+
1106
+ // Send a message to start the debounce
1107
+ room.handleSocketMessage('test', JSON.stringify({ type: 'ping' }))
1108
+
1109
+ // Close the socket before the debounce fires
1110
+ room.handleSocketClose('test')
1111
+
1112
+ // Advance past the 5s debounce
1113
+ vi.advanceTimersByTime(5100)
1114
+
1115
+ // Should never have been called - timer was cleared on close
1116
+ expect(onSessionSnapshot).not.toHaveBeenCalled()
1117
+
1118
+ room.close()
1119
+ } finally {
1120
+ vi.useRealTimers()
1121
+ }
1122
+ })
1123
+
1124
+ it('resets the debounce on subsequent messages', () => {
1125
+ vi.useFakeTimers()
1126
+ try {
1127
+ const onSessionSnapshot = vi.fn()
1128
+ const room = new TLSocketRoom({ onSessionSnapshot })
1129
+ const socket = createMockSocket()
1130
+ connectSession(room, 'test', socket)
1131
+
1132
+ // Send first message
1133
+ room.handleSocketMessage('test', JSON.stringify({ type: 'ping' }))
1134
+
1135
+ // Advance 3s (within the 5s window)
1136
+ vi.advanceTimersByTime(3000)
1137
+ expect(onSessionSnapshot).not.toHaveBeenCalled()
1138
+
1139
+ // Send another message, resetting the debounce
1140
+ room.handleSocketMessage('test', JSON.stringify({ type: 'ping' }))
1141
+
1142
+ // Advance another 3s (6s total, but only 3s since last message)
1143
+ vi.advanceTimersByTime(3000)
1144
+ expect(onSessionSnapshot).not.toHaveBeenCalled()
1145
+
1146
+ // Advance past the new debounce window
1147
+ vi.advanceTimersByTime(2100)
1148
+ expect(onSessionSnapshot).toHaveBeenCalledTimes(1)
1149
+
1150
+ room.close()
1151
+ } finally {
1152
+ vi.useRealTimers()
1153
+ }
1154
+ })
1155
+ })
1156
+
1157
+ describe('resume-then-close (presence cleanup)', () => {
1158
+ it('removes presence when a resumed session is immediately closed', () => {
1159
+ vi.useFakeTimers()
1160
+ try {
1161
+ const store = getStore()
1162
+ store.ensureStoreIsUsable()
1163
+ const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
1164
+ const socket = createMockSocket()
1165
+ connectSession(room, 'test', socket)
1166
+
1167
+ // Push presence
1168
+ const presence = InstancePresenceRecordType.create({
1169
+ id: InstancePresenceRecordType.createId('p1'),
1170
+ userId: 'user1',
1171
+ userName: 'User 1',
1172
+ currentPageId: PageRecordType.createId('page'),
1173
+ })
1174
+ room.handleSocketMessage(
1175
+ 'test',
1176
+ JSON.stringify({
1177
+ type: 'push',
1178
+ clientClock: 1,
1179
+ presence: [RecordOpType.Put, presence],
1180
+ })
1181
+ )
1182
+
1183
+ const snapshot = room.getSessionSnapshot('test')!
1184
+ expect(snapshot.presenceRecord).not.toBeNull()
1185
+
1186
+ // Simulate hibernation: new room with a second connected client
1187
+ const room2 = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
1188
+ const observerSocket = createMockSocket()
1189
+ connectSession(room2, 'observer', observerSocket)
1190
+ vi.mocked(observerSocket.send).mockClear()
1191
+
1192
+ // Resume the session that's about to disconnect
1193
+ const closingSocket = createMockSocket()
1194
+ room2.handleSocketResume({
1195
+ sessionId: 'test',
1196
+ socket: closingSocket,
1197
+ snapshot,
1198
+ })
1199
+
1200
+ // Presence should be restored (only the resumed session has presence)
1201
+ expect(Object.keys(room2.getPresenceRecords())).toHaveLength(1)
1202
+
1203
+ // Clear any messages from resume so we only see what happens after close
1204
+ vi.mocked(observerSocket.send).mockClear()
1205
+
1206
+ // Now immediately close it (simulating webSocketClose after hibernation)
1207
+ room2.handleSocketClose('test')
1208
+
1209
+ // Advance past SESSION_REMOVAL_WAIT_TIME (5s) + buffer (100ms) + throttle (1s)
1210
+ vi.advanceTimersByTime(6200)
1211
+
1212
+ // Presence should be gone
1213
+ expect(Object.keys(room2.getPresenceRecords())).toHaveLength(0)
1214
+
1215
+ // The observer should have received a presence removal broadcast.
1216
+ // Messages are wrapped in a { type: 'data', data: [...] } envelope.
1217
+ const sentMessages = vi.mocked(observerSocket.send).mock.calls.map((c) => JSON.parse(c[0]))
1218
+ const hasPresenceRemoval = sentMessages.some(
1219
+ (msg: {
1220
+ type: string
1221
+ data?: Array<{ type: string; diff?: Record<string, [string]> }>
1222
+ }) =>
1223
+ msg.type === 'data' &&
1224
+ msg.data?.some(
1225
+ (inner) =>
1226
+ inner.type === 'patch' &&
1227
+ inner.diff &&
1228
+ Object.values(inner.diff).some((op) => op[0] === 'remove')
1229
+ )
1230
+ )
1231
+ expect(hasPresenceRemoval).toBe(true)
1232
+
1233
+ room2.close()
1234
+ } finally {
1235
+ vi.useRealTimers()
1236
+ }
1237
+ })
1238
+ })
1239
+
1240
+ describe('on-demand session pruning', () => {
1241
+ it('prunes timed-out sessions during handleSocketMessage', async () => {
1242
+ const room = new TLSocketRoom({
1243
+ clientTimeout: 1,
1244
+ })
1245
+ const socket = createMockSocket()
1246
+ connectSession(room, 'test', socket)
1247
+ expect(room.getNumActiveSessions()).toBe(1)
1248
+
1249
+ // Wait for both the client timeout and the prune throttle window (1s) to expire
1250
+ await new Promise((r) => setTimeout(r, 1100))
1251
+
1252
+ // Connect a second session and send a message to trigger pruning
1253
+ const socket2 = createMockSocket()
1254
+ connectSession(room, 'test2', socket2)
1255
+
1256
+ // The timed-out socket should have been closed
1257
+ expect(socket.close).toHaveBeenCalled()
1258
+
1259
+ room.close()
1260
+ })
1261
+
1262
+ it('runs prune after handleMessage so the sender is not evicted by their own message', () => {
1263
+ // If prune ran before handleMessage, an idle client's message would trigger
1264
+ // prune first and cancel them before lastInteractionTime is updated. Verify
1265
+ // order: handleMessage must run before pruneSessions.
1266
+ const room = new TLSocketRoom({})
1267
+ const syncRoom = (room as unknown as { room: TLSyncRoom<TLRecord, void> }).room
1268
+ const socket = createMockSocket()
1269
+ connectSession(room, 'test', socket)
1270
+
1271
+ const callOrder: string[] = []
1272
+ const realHandleMessage = syncRoom.handleMessage.bind(syncRoom)
1273
+ vi.spyOn(syncRoom, 'handleMessage').mockImplementation((sessionId, message) => {
1274
+ callOrder.push('handleMessage')
1275
+ return realHandleMessage(sessionId, message)
1276
+ })
1277
+ const originalPrune = syncRoom.pruneSessions
1278
+ const wrappedPrune = function (this: typeof syncRoom) {
1279
+ callOrder.push('prune')
1280
+ return originalPrune.call(this)
1281
+ }
1282
+ wrappedPrune.cancel = originalPrune.cancel?.bind(originalPrune)
1283
+ syncRoom.pruneSessions = wrappedPrune as typeof originalPrune
1284
+
1285
+ room.handleSocketMessage('test', JSON.stringify({ type: 'ping' }))
1286
+
1287
+ expect(callOrder).toEqual(['handleMessage', 'prune'])
1288
+ vi.restoreAllMocks()
1289
+ room.close()
1290
+ })
1291
+
1292
+ it('fully removes sessions after disconnect even with no further messages', () => {
1293
+ vi.useFakeTimers()
1294
+ try {
1295
+ const onSessionRemoved = vi.fn()
1296
+ const room = new TLSocketRoom({ onSessionRemoved })
1297
+ const socket = createMockSocket()
1298
+ connectSession(room, 'test', socket)
1299
+ expect(room.getNumActiveSessions()).toBe(1)
1300
+
1301
+ // Disconnect the only client
1302
+ room.handleSocketClose('test')
1303
+
1304
+ // Session should be in AwaitingRemoval, not yet fully removed
1305
+ expect(room.getNumActiveSessions()).toBe(1)
1306
+ expect(onSessionRemoved).not.toHaveBeenCalled()
1307
+
1308
+ // Advance past SESSION_REMOVAL_WAIT_TIME (5s) + buffer (100ms) + throttle (1s)
1309
+ vi.advanceTimersByTime(6200)
1310
+
1311
+ // Session should now be fully removed via the scheduled follow-up prune
1312
+ expect(room.getNumActiveSessions()).toBe(0)
1313
+ expect(onSessionRemoved).toHaveBeenCalledWith(
1314
+ room,
1315
+ expect.objectContaining({ sessionId: 'test', numSessionsRemaining: 0 })
1316
+ )
1317
+
1318
+ room.close()
1319
+ } finally {
1320
+ vi.useRealTimers()
1321
+ }
1322
+ })
1323
+ })
1324
+ })
1325
+
808
1326
  describe('TLSocketRoom.updateStore', () => {
809
1327
  let storage = new InMemorySyncStorage<TLRecord>({ snapshot: DEFAULT_INITIAL_SNAPSHOT })
810
1328
 
@@ -826,12 +1344,10 @@ describe('TLSocketRoom.updateStore', () => {
826
1344
  document.name = 'My lovely document'
827
1345
  store.put(document)
828
1346
  })
829
- expect(
830
- (
831
- storage.getSnapshot().documents.find((r) => r.state.id === 'document:document')
832
- ?.state as any
833
- ).name
834
- ).toBe('My lovely document')
1347
+ const docRecord = storage
1348
+ .getSnapshot()
1349
+ .documents.find((r) => r.state.id === 'document:document') as any
1350
+ expect(docRecord.state.name).toBe('My lovely document')
835
1351
  expect(clock).toBeLessThan(storage.getClock())
836
1352
  })
837
1353
 
@@ -841,12 +1357,10 @@ describe('TLSocketRoom.updateStore', () => {
841
1357
  const document = store.get('document:document') as TLDocument
842
1358
  document.name = 'My lovely document'
843
1359
  })
844
- expect(
845
- (
846
- storage.getSnapshot().documents.find((r) => r.state.id === 'document:document')
847
- ?.state as any
848
- ).name
849
- ).toBe('')
1360
+ const docRecord = storage
1361
+ .getSnapshot()
1362
+ .documents.find((r) => r.state.id === 'document:document') as any
1363
+ expect(docRecord.state.name).toBe('')
850
1364
  expect(clock).toBe(storage.getClock())
851
1365
  })
852
1366
 
@@ -97,7 +97,7 @@ export class TestSocketPair<R extends UnknownRecord> {
97
97
  onStatusChange: null as null | TLSocketStatusListener,
98
98
  }
99
99
 
100
- // eslint-disable-next-line no-restricted-syntax
100
+ // eslint-disable-next-line tldraw/no-setter-getter
101
101
  get isConnected() {
102
102
  return this.clientSocket.connectionStatus === 'online'
103
103
  }