@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.
- package/dist-cjs/index.d.ts +86 -0
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/ClientWebSocketAdapter.js +1 -1
- package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
- package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/ServerSocketAdapter.js +1 -1
- package/dist-cjs/lib/ServerSocketAdapter.js.map +1 -1
- package/dist-cjs/lib/TLSocketRoom.js +137 -1
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +1 -1
- package/dist-cjs/lib/TLSyncClient.js.map +1 -1
- package/dist-cjs/lib/TLSyncRoom.js +71 -5
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/protocol.js.map +1 -1
- package/dist-esm/index.d.mts +86 -0
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/ClientWebSocketAdapter.mjs +1 -1
- package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
- package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/ServerSocketAdapter.mjs +1 -1
- package/dist-esm/lib/ServerSocketAdapter.mjs.map +1 -1
- package/dist-esm/lib/TLSocketRoom.mjs +137 -1
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +1 -1
- package/dist-esm/lib/TLSyncClient.mjs.map +1 -1
- package/dist-esm/lib/TLSyncRoom.mjs +73 -6
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/protocol.mjs.map +1 -1
- package/package.json +7 -7
- package/src/index.ts +3 -0
- package/src/lib/ClientWebSocketAdapter.ts +5 -5
- package/src/lib/DurableObjectSqliteSyncWrapper.ts +1 -2
- package/src/lib/InMemorySyncStorage.ts +3 -3
- package/src/lib/ServerSocketAdapter.ts +6 -6
- package/src/lib/TLSocketRoom.ts +193 -6
- package/src/lib/TLSyncClient.test.ts +4 -7
- package/src/lib/TLSyncClient.ts +1 -1
- package/src/lib/TLSyncRoom.ts +96 -4
- package/src/lib/protocol.ts +2 -0
- package/src/test/RandomSource.ts +1 -1
- package/src/test/TLSocketRoom.test.ts +527 -13
- 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
|
-
|
|
830
|
-
(
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
845
|
-
(
|
|
846
|
-
|
|
847
|
-
|
|
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-
|
|
100
|
+
// eslint-disable-next-line tldraw/no-setter-getter
|
|
101
101
|
get isConnected() {
|
|
102
102
|
return this.clientSocket.connectionStatus === 'online'
|
|
103
103
|
}
|