@tldraw/sync-core 5.2.0-canary.fe03bcdddf34 → 5.2.0-canary.fff413eea248
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/DOCS.md +662 -0
- package/README.md +9 -1
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/TLSocketRoom.js +4 -1
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +2 -3
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +3 -2
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/TLSocketRoom.mjs +4 -1
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +2 -4
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +3 -2
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/package.json +9 -8
- package/src/lib/ClientWebSocketAdapter.test.ts +486 -508
- package/src/lib/DurableObjectSqliteSyncWrapper.test.ts +102 -0
- package/src/lib/InMemorySyncStorage.test.ts +294 -0
- package/src/lib/MicrotaskNotifier.test.ts +79 -254
- package/src/lib/{NodeSqliteSyncWrapper.test.ts → NodeSqliteWrapper.test.ts} +29 -25
- package/src/lib/SQLiteSyncStorage.test.ts +239 -0
- package/src/lib/ServerSocketAdapter.test.ts +60 -199
- package/src/lib/TLSocketRoom.ts +6 -1
- package/src/lib/TLSyncClient.test.ts +1127 -846
- package/src/lib/TLSyncClient.ts +4 -4
- package/src/lib/TLSyncRoom.ts +5 -2
- package/src/lib/TLSyncStorage.test.ts +225 -0
- package/src/lib/chunk.test.ts +372 -0
- package/src/lib/diff.test.ts +885 -0
- package/src/lib/interval.test.ts +43 -0
- package/src/lib/protocol.test.ts +43 -0
- package/src/lib/recordDiff.test.ts +159 -0
- package/src/test/TLSocketRoom.test.ts +1109 -894
- package/src/test/TLSyncRoom.test.ts +1735 -893
- package/src/test/storageContractSuite.ts +618 -0
- package/src/test/upgradeDowngrade.test.ts +137 -37
- package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
- package/src/lib/RoomSession.test.ts +0 -101
- package/src/lib/computeTombstonePruning.test.ts +0 -352
- package/src/lib/server-types.test.ts +0 -44
- package/src/test/InMemorySyncStorage.test.ts +0 -1780
- package/src/test/SQLiteSyncStorage.test.ts +0 -1485
- package/src/test/chunk.test.ts +0 -385
- package/src/test/customMessages.test.ts +0 -36
- package/src/test/diff.test.ts +0 -784
- package/src/test/presenceMode.test.ts +0 -149
- package/src/test/validation.test.ts +0 -186
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-deprecated
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-deprecated -- initialSnapshot, onDataChange,
|
|
2
|
+
* updateStore, and getCurrentSnapshot are deprecated but remain part of the public
|
|
3
|
+
* contract under test (see SPEC.md sections 28 and 29). */
|
|
2
4
|
import {
|
|
5
|
+
createShapeId,
|
|
6
|
+
createUserId,
|
|
3
7
|
InstancePresenceRecordType,
|
|
4
8
|
PageRecordType,
|
|
5
9
|
TLDocument,
|
|
10
|
+
TLInstancePresence,
|
|
6
11
|
TLPage,
|
|
7
12
|
TLRecord,
|
|
13
|
+
TLScribble,
|
|
8
14
|
} from '@tldraw/tlschema'
|
|
9
15
|
import {
|
|
10
16
|
createTLSchema,
|
|
@@ -20,7 +26,7 @@ import { DEFAULT_INITIAL_SNAPSHOT, InMemorySyncStorage } from '../lib/InMemorySy
|
|
|
20
26
|
import { getTlsyncProtocolVersion } from '../lib/protocol'
|
|
21
27
|
import { WebSocketMinimal } from '../lib/ServerSocketAdapter'
|
|
22
28
|
import { TLSocketRoom, TLSyncLog } from '../lib/TLSocketRoom'
|
|
23
|
-
import { TLSyncErrorCloseEventReason } from '../lib/TLSyncClient'
|
|
29
|
+
import { TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from '../lib/TLSyncClient'
|
|
24
30
|
import { TLSyncRoom, type RoomSnapshot } from '../lib/TLSyncRoom'
|
|
25
31
|
|
|
26
32
|
function getStore() {
|
|
@@ -41,354 +47,134 @@ function createMockSocket(overrides: Partial<WebSocketMinimal> = {}): WebSocketM
|
|
|
41
47
|
}
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
// Connect a session and complete the connect handshake
|
|
51
|
+
function connectSession(room: TLSocketRoom<any, any>, sessionId: string, socket: WebSocketMinimal) {
|
|
52
|
+
room.handleSocketConnect({ sessionId, socket })
|
|
53
|
+
const connectRequest = {
|
|
54
|
+
type: 'connect' as const,
|
|
55
|
+
connectRequestId: `connect-${sessionId}`,
|
|
56
|
+
lastServerClock: 0,
|
|
57
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
58
|
+
schema: createTLSchema().serialize(),
|
|
59
|
+
}
|
|
60
|
+
room.handleSocketMessage(sessionId, JSON.stringify(connectRequest))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function flushAsync() {
|
|
64
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
65
|
+
}
|
|
66
|
+
|
|
44
67
|
// Helper to create test session metadata
|
|
45
68
|
interface TestSessionMeta {
|
|
46
69
|
userId: string
|
|
47
70
|
userName: string
|
|
48
71
|
}
|
|
49
72
|
|
|
50
|
-
describe(TLSocketRoom, () => {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
// populate with an empty document (document:document and page:page records)
|
|
54
|
-
store.ensureStoreIsUsable()
|
|
55
|
-
const snapshot = store.getStoreSnapshot()
|
|
56
|
-
const room = new TLSocketRoom({
|
|
57
|
-
initialSnapshot: snapshot,
|
|
58
|
-
})
|
|
59
|
-
expect(room.getCurrentSnapshot()).not.toMatchObject({ clock: 0, documents: [] })
|
|
60
|
-
expect(room.getCurrentSnapshot().documentClock).toBe(0)
|
|
61
|
-
expect(room.getCurrentSnapshot().documents.sort((a, b) => a.state.id.localeCompare(b.state.id)))
|
|
62
|
-
.toMatchInlineSnapshot(`
|
|
63
|
-
[
|
|
64
|
-
{
|
|
65
|
-
"lastChangedClock": 0,
|
|
66
|
-
"state": {
|
|
67
|
-
"gridSize": 10,
|
|
68
|
-
"id": "document:document",
|
|
69
|
-
"meta": {},
|
|
70
|
-
"name": "",
|
|
71
|
-
"typeName": "document",
|
|
72
|
-
},
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
"lastChangedClock": 0,
|
|
76
|
-
"state": {
|
|
77
|
-
"id": "page:page",
|
|
78
|
-
"index": "a1",
|
|
79
|
-
"meta": {},
|
|
80
|
-
"name": "Page 1",
|
|
81
|
-
"typeName": "page",
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
]
|
|
85
|
-
`)
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it('allows loading a TLStoreSnapshot at some later time', () => {
|
|
89
|
-
const store = getStore()
|
|
90
|
-
const room = new TLSocketRoom({
|
|
91
|
-
initialSnapshot: store.getStoreSnapshot(),
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
expect(room.getCurrentSnapshot()).toMatchObject({ documentClock: 0, documents: [] })
|
|
95
|
-
|
|
96
|
-
// populate with an empty document (document:document and page:page records)
|
|
97
|
-
store.ensureStoreIsUsable()
|
|
98
|
-
|
|
99
|
-
const snapshot = store.getStoreSnapshot()
|
|
100
|
-
room.loadSnapshot(snapshot)
|
|
101
|
-
|
|
102
|
-
expect(room.getCurrentSnapshot().documentClock).toBe(1)
|
|
103
|
-
expect(room.getCurrentSnapshot().documents.sort((a, b) => a.state.id.localeCompare(b.state.id)))
|
|
104
|
-
.toMatchInlineSnapshot(`
|
|
105
|
-
[
|
|
106
|
-
{
|
|
107
|
-
"lastChangedClock": 1,
|
|
108
|
-
"state": {
|
|
109
|
-
"gridSize": 10,
|
|
110
|
-
"id": "document:document",
|
|
111
|
-
"meta": {},
|
|
112
|
-
"name": "",
|
|
113
|
-
"typeName": "document",
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
"lastChangedClock": 1,
|
|
118
|
-
"state": {
|
|
119
|
-
"id": "page:page",
|
|
120
|
-
"index": "a1",
|
|
121
|
-
"meta": {},
|
|
122
|
-
"name": "Page 1",
|
|
123
|
-
"typeName": "page",
|
|
124
|
-
},
|
|
125
|
-
},
|
|
126
|
-
]
|
|
127
|
-
`)
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
it('getPresenceRecords correctly handles presence records', () => {
|
|
131
|
-
const store = getStore()
|
|
132
|
-
store.ensureStoreIsUsable()
|
|
133
|
-
|
|
134
|
-
const snapshot = store.getStoreSnapshot()
|
|
135
|
-
const room = new TLSocketRoom({
|
|
136
|
-
initialSnapshot: snapshot,
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
// Create two separate sessions, each with their own presence record
|
|
140
|
-
const sessionId1 = 'test-session-1'
|
|
141
|
-
const sessionId2 = 'test-session-2'
|
|
142
|
-
|
|
143
|
-
// Create mock sockets
|
|
144
|
-
const mockSocket1: WebSocketMinimal = {
|
|
145
|
-
send: vi.fn(),
|
|
146
|
-
close: vi.fn(),
|
|
147
|
-
readyState: 1, // WebSocket.OPEN
|
|
148
|
-
addEventListener: vi.fn(),
|
|
149
|
-
removeEventListener: vi.fn(),
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const mockSocket2: WebSocketMinimal = {
|
|
153
|
-
send: vi.fn(),
|
|
154
|
-
close: vi.fn(),
|
|
155
|
-
readyState: 1, // WebSocket.OPEN
|
|
156
|
-
addEventListener: vi.fn(),
|
|
157
|
-
removeEventListener: vi.fn(),
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Add sessions to the room
|
|
161
|
-
room.handleSocketConnect({
|
|
162
|
-
sessionId: sessionId1,
|
|
163
|
-
socket: mockSocket1,
|
|
164
|
-
isReadonly: false,
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
room.handleSocketConnect({
|
|
168
|
-
sessionId: sessionId2,
|
|
169
|
-
socket: mockSocket2,
|
|
170
|
-
isReadonly: false,
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
// Send connect messages to establish the sessions
|
|
174
|
-
const connectRequest1 = {
|
|
175
|
-
type: 'connect' as const,
|
|
176
|
-
connectRequestId: 'connect-1',
|
|
177
|
-
lastServerClock: 0,
|
|
178
|
-
protocolVersion: 8,
|
|
179
|
-
schema: store.schema.serialize(),
|
|
180
|
-
}
|
|
181
|
-
room.handleSocketMessage(sessionId1, JSON.stringify(connectRequest1))
|
|
182
|
-
|
|
183
|
-
const connectRequest2 = {
|
|
184
|
-
type: 'connect' as const,
|
|
185
|
-
connectRequestId: 'connect-2',
|
|
186
|
-
lastServerClock: 0,
|
|
187
|
-
protocolVersion: 8,
|
|
188
|
-
schema: store.schema.serialize(),
|
|
189
|
-
}
|
|
190
|
-
room.handleSocketMessage(sessionId2, JSON.stringify(connectRequest2))
|
|
191
|
-
|
|
192
|
-
// Create presence records for each session
|
|
193
|
-
const presence1 = InstancePresenceRecordType.create({
|
|
194
|
-
id: InstancePresenceRecordType.createId('presence1'),
|
|
195
|
-
userId: 'user1',
|
|
196
|
-
userName: 'User 1',
|
|
197
|
-
currentPageId: PageRecordType.createId('page'),
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
const presence2 = InstancePresenceRecordType.create({
|
|
201
|
-
id: InstancePresenceRecordType.createId('presence2'),
|
|
202
|
-
userId: 'user2',
|
|
203
|
-
userName: 'User 2',
|
|
204
|
-
currentPageId: PageRecordType.createId('page'),
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
// Send push messages with presence data for each session
|
|
208
|
-
const pushRequest1 = {
|
|
209
|
-
type: 'push' as const,
|
|
210
|
-
clientClock: 1,
|
|
211
|
-
presence: [RecordOpType.Put, presence1] as [typeof RecordOpType.Put, typeof presence1],
|
|
212
|
-
}
|
|
213
|
-
room.handleSocketMessage(sessionId1, JSON.stringify(pushRequest1))
|
|
214
|
-
|
|
215
|
-
const pushRequest2 = {
|
|
216
|
-
type: 'push' as const,
|
|
217
|
-
clientClock: 2,
|
|
218
|
-
presence: [RecordOpType.Put, presence2] as [typeof RecordOpType.Put, typeof presence2],
|
|
219
|
-
}
|
|
220
|
-
room.handleSocketMessage(sessionId2, JSON.stringify(pushRequest2))
|
|
221
|
-
|
|
222
|
-
// Get presence records
|
|
223
|
-
const presenceRecords = room.getPresenceRecords()
|
|
224
|
-
|
|
225
|
-
// Should return the presence records that were added through the protocol
|
|
226
|
-
expect(Object.keys(presenceRecords)).toHaveLength(2)
|
|
227
|
-
|
|
228
|
-
// Find the presence records by their user data since the IDs are generated by the room
|
|
229
|
-
const user1Presence = Object.values(presenceRecords).find((p) => (p as any).userId === 'user1')
|
|
230
|
-
const user2Presence = Object.values(presenceRecords).find((p) => (p as any).userId === 'user2')
|
|
231
|
-
|
|
232
|
-
expect(user1Presence).toBeDefined()
|
|
233
|
-
expect(user2Presence).toBeDefined()
|
|
234
|
-
|
|
235
|
-
// Verify the records are properly structured
|
|
236
|
-
expect(user1Presence).toMatchObject({
|
|
237
|
-
typeName: 'instance_presence',
|
|
238
|
-
userId: 'user1',
|
|
239
|
-
userName: 'User 1',
|
|
240
|
-
})
|
|
241
|
-
|
|
242
|
-
expect(user2Presence).toMatchObject({
|
|
243
|
-
typeName: 'instance_presence',
|
|
244
|
-
userId: 'user2',
|
|
245
|
-
userName: 'User 2',
|
|
246
|
-
})
|
|
247
|
-
|
|
248
|
-
// Should not include document records
|
|
249
|
-
const documentRecordIds = Object.keys(presenceRecords).filter(
|
|
250
|
-
(id) => presenceRecords[id].typeName === 'document'
|
|
251
|
-
)
|
|
252
|
-
expect(documentRecordIds).toHaveLength(0)
|
|
253
|
-
})
|
|
254
|
-
|
|
255
|
-
it('passes onDataChange handler through', async () => {
|
|
256
|
-
const addPage = (room: TLSocketRoom) =>
|
|
257
|
-
room.updateStore((store) => {
|
|
258
|
-
store.put(
|
|
259
|
-
PageRecordType.create({ id: PageRecordType.createId(), name: '', index: ZERO_INDEX_KEY })
|
|
260
|
-
)
|
|
261
|
-
})
|
|
262
|
-
const store = getStore()
|
|
263
|
-
store.ensureStoreIsUsable()
|
|
264
|
-
let called = 0
|
|
265
|
-
|
|
266
|
-
const room = new TLSocketRoom({ onDataChange: () => ++called })
|
|
267
|
-
expect(called).toEqual(0)
|
|
268
|
-
|
|
269
|
-
await addPage(room)
|
|
270
|
-
expect(called).toEqual(1)
|
|
271
|
-
|
|
272
|
-
room.loadSnapshot(room.getCurrentSnapshot())
|
|
273
|
-
expect(called).toEqual(1)
|
|
274
|
-
|
|
275
|
-
await addPage(room)
|
|
276
|
-
expect(called).toEqual(2)
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
it('sends custom messages', async () => {
|
|
280
|
-
const json = JSON.stringify
|
|
281
|
-
const store = getStore()
|
|
282
|
-
const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
|
|
283
|
-
|
|
284
|
-
const sessionId = 'test-session-1'
|
|
285
|
-
const send = vi.fn()
|
|
286
|
-
|
|
287
|
-
// Add session to the room
|
|
288
|
-
const mockSocket: WebSocketMinimal = { send, close: vi.fn(), readyState: WebSocket.OPEN }
|
|
289
|
-
room.handleSocketConnect({ sessionId, socket: mockSocket })
|
|
290
|
-
|
|
291
|
-
// Send connect message to establish the session
|
|
292
|
-
const connect = {
|
|
293
|
-
type: 'connect' as const,
|
|
294
|
-
connectRequestId: 'connect-1',
|
|
295
|
-
lastServerClock: 0,
|
|
296
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
297
|
-
schema: store.schema.serialize(),
|
|
298
|
-
}
|
|
299
|
-
room.handleSocketMessage(sessionId, json(connect))
|
|
300
|
-
|
|
301
|
-
room.sendCustomMessage(sessionId, 'hello world')
|
|
302
|
-
expect(send.mock.lastCall).toEqual([json({ type: 'custom', data: 'hello world' })])
|
|
303
|
-
})
|
|
304
|
-
|
|
305
|
-
describe('Room state resetting behavior', () => {
|
|
306
|
-
it('increments documentClock when loading snapshot with different data', () => {
|
|
73
|
+
describe('28. TLSocketRoom (SR)', () => {
|
|
74
|
+
describe('construction', () => {
|
|
75
|
+
it('[SR1] allows being initialized with a non-empty TLStoreSnapshot', () => {
|
|
307
76
|
const store = getStore()
|
|
77
|
+
// populate with an empty document (document:document and page:page records)
|
|
308
78
|
store.ensureStoreIsUsable()
|
|
79
|
+
const snapshot = store.getStoreSnapshot()
|
|
309
80
|
const room = new TLSocketRoom({
|
|
310
|
-
initialSnapshot:
|
|
81
|
+
initialSnapshot: snapshot,
|
|
311
82
|
})
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
expect(
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
83
|
+
expect(room.getCurrentSnapshot()).not.toMatchObject({ clock: 0, documents: [] })
|
|
84
|
+
expect(room.getCurrentSnapshot().documentClock).toBe(0)
|
|
85
|
+
expect(
|
|
86
|
+
room.getCurrentSnapshot().documents.sort((a, b) => a.state.id.localeCompare(b.state.id))
|
|
87
|
+
).toMatchInlineSnapshot(`
|
|
88
|
+
[
|
|
89
|
+
{
|
|
90
|
+
"lastChangedClock": 0,
|
|
91
|
+
"state": {
|
|
92
|
+
"gridSize": 10,
|
|
93
|
+
"id": "document:document",
|
|
94
|
+
"meta": {},
|
|
95
|
+
"name": "",
|
|
96
|
+
"typeName": "document",
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"lastChangedClock": 0,
|
|
101
|
+
"state": {
|
|
102
|
+
"id": "page:page",
|
|
103
|
+
"index": "a1",
|
|
104
|
+
"meta": {},
|
|
105
|
+
"name": "Page 1",
|
|
106
|
+
"typeName": "page",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
]
|
|
110
|
+
`)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('[SR1] throws when both storage and initialSnapshot are provided', () => {
|
|
114
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: DEFAULT_INITIAL_SNAPSHOT })
|
|
325
115
|
const store = getStore()
|
|
326
116
|
store.ensureStoreIsUsable()
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
room.loadSnapshot(store.getStoreSnapshot())
|
|
335
|
-
|
|
336
|
-
// Clock should not change since data is identical
|
|
337
|
-
expect(room.getCurrentDocumentClock()).toBe(oldClock)
|
|
117
|
+
expect(
|
|
118
|
+
() =>
|
|
119
|
+
new TLSocketRoom<TLRecord, undefined>({
|
|
120
|
+
storage,
|
|
121
|
+
initialSnapshot: store.getStoreSnapshot(),
|
|
122
|
+
})
|
|
123
|
+
).toThrow('Cannot provide both storage and initialSnapshot options')
|
|
338
124
|
})
|
|
339
125
|
|
|
340
|
-
it('
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
126
|
+
it('[SR1] seeds an InMemorySyncStorage from the default snapshot when neither option is given', () => {
|
|
127
|
+
const room = new TLSocketRoom({})
|
|
128
|
+
const snapshot = room.getCurrentSnapshot()
|
|
129
|
+
expect(snapshot.documentClock).toBe(0)
|
|
130
|
+
expect(snapshot.documents.map((d) => d.state.id).sort()).toEqual([
|
|
131
|
+
'document:document',
|
|
132
|
+
'page:page',
|
|
347
133
|
])
|
|
348
|
-
|
|
349
|
-
initialSnapshot: store.getStoreSnapshot(),
|
|
350
|
-
})
|
|
351
|
-
|
|
352
|
-
await room.updateStore((store) => {
|
|
353
|
-
store.delete(testPageId)
|
|
354
|
-
})
|
|
355
|
-
|
|
356
|
-
const deletionClock = room.getCurrentDocumentClock()
|
|
357
|
-
expect(room.getCurrentSnapshot().tombstones).toEqual({
|
|
358
|
-
[testPageId]: deletionClock,
|
|
359
|
-
})
|
|
360
|
-
|
|
361
|
-
room.loadSnapshot(room.getCurrentSnapshot())
|
|
362
|
-
|
|
363
|
-
// Tombstones should be preserved
|
|
364
|
-
expect(room.getCurrentSnapshot().tombstones).toEqual({
|
|
365
|
-
[testPageId]: deletionClock,
|
|
366
|
-
})
|
|
134
|
+
})
|
|
367
135
|
|
|
368
|
-
|
|
369
|
-
|
|
136
|
+
it('[SR1] accepts a room snapshot as initialSnapshot', () => {
|
|
137
|
+
const source = new TLSocketRoom({})
|
|
138
|
+
const roomSnapshot = source.getCurrentSnapshot()
|
|
139
|
+
const room = new TLSocketRoom({ initialSnapshot: roomSnapshot })
|
|
140
|
+
expect(
|
|
141
|
+
room.getCurrentSnapshot().documents.sort((a, b) => sortById(a.state, b.state))
|
|
142
|
+
).toEqual(roomSnapshot.documents.sort((a, b) => sortById(a.state, b.state)))
|
|
370
143
|
})
|
|
144
|
+
})
|
|
371
145
|
|
|
372
|
-
|
|
146
|
+
describe('onDataChange', () => {
|
|
147
|
+
it('[SR2] passes onDataChange handler through', async () => {
|
|
148
|
+
const addPage = (room: TLSocketRoom) =>
|
|
149
|
+
room.updateStore((store) => {
|
|
150
|
+
store.put(
|
|
151
|
+
PageRecordType.create({
|
|
152
|
+
id: PageRecordType.createId(),
|
|
153
|
+
name: '',
|
|
154
|
+
index: ZERO_INDEX_KEY,
|
|
155
|
+
})
|
|
156
|
+
)
|
|
157
|
+
})
|
|
373
158
|
const store = getStore()
|
|
374
159
|
store.ensureStoreIsUsable()
|
|
375
|
-
|
|
376
|
-
initialSnapshot: store.getStoreSnapshot(),
|
|
377
|
-
})
|
|
160
|
+
let called = 0
|
|
378
161
|
|
|
379
|
-
const
|
|
162
|
+
const room = new TLSocketRoom({ onDataChange: () => ++called })
|
|
163
|
+
expect(called).toEqual(0)
|
|
380
164
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
room.loadSnapshot(newSnapshot)
|
|
165
|
+
await addPage(room)
|
|
166
|
+
expect(called).toEqual(1)
|
|
384
167
|
|
|
385
|
-
|
|
386
|
-
expect(
|
|
168
|
+
room.loadSnapshot(room.getCurrentSnapshot())
|
|
169
|
+
expect(called).toEqual(1)
|
|
170
|
+
|
|
171
|
+
await addPage(room)
|
|
172
|
+
expect(called).toEqual(2)
|
|
387
173
|
})
|
|
388
174
|
})
|
|
389
175
|
|
|
390
|
-
describe('
|
|
391
|
-
it('sets up logging with default console.error when log option missing', () => {
|
|
176
|
+
describe('logging', () => {
|
|
177
|
+
it('[SR3] sets up logging with default console.error when log option missing', () => {
|
|
392
178
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
393
179
|
const room = new TLSocketRoom({})
|
|
394
180
|
// Create a session first, then send invalid message to trigger JSON parse error
|
|
@@ -403,7 +189,23 @@ describe(TLSocketRoom, () => {
|
|
|
403
189
|
consoleSpy.mockRestore()
|
|
404
190
|
})
|
|
405
191
|
|
|
406
|
-
it('
|
|
192
|
+
it('[SR3] leaves the room without a logger when log is explicitly undefined', () => {
|
|
193
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
194
|
+
const room = new TLSocketRoom({ log: undefined })
|
|
195
|
+
expect(room.log).toBeUndefined()
|
|
196
|
+
|
|
197
|
+
// Trigger the same error path as the default-logging test; nothing should be logged
|
|
198
|
+
const socket = createMockSocket()
|
|
199
|
+
room.handleSocketConnect({
|
|
200
|
+
sessionId: 'test-session',
|
|
201
|
+
socket,
|
|
202
|
+
})
|
|
203
|
+
room.handleSocketMessage('test-session', '{invalid json')
|
|
204
|
+
expect(consoleSpy).not.toHaveBeenCalled()
|
|
205
|
+
consoleSpy.mockRestore()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('[SR3] uses custom logger when provided', () => {
|
|
407
209
|
const mockLog: TLSyncLog = {
|
|
408
210
|
warn: vi.fn(),
|
|
409
211
|
error: vi.fn(),
|
|
@@ -419,51 +221,25 @@ describe(TLSocketRoom, () => {
|
|
|
419
221
|
room.handleSocketMessage('test-session', '{invalid json')
|
|
420
222
|
expect(mockLog.error).toHaveBeenCalled()
|
|
421
223
|
})
|
|
422
|
-
|
|
423
|
-
it('initializes with custom client timeout', () => {
|
|
424
|
-
const customTimeout = 15000
|
|
425
|
-
const room = new TLSocketRoom({ clientTimeout: customTimeout })
|
|
426
|
-
expect(room.opts.clientTimeout).toBe(customTimeout)
|
|
427
|
-
})
|
|
428
224
|
})
|
|
429
225
|
|
|
430
|
-
describe('
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
onSessionRemoved = vi.fn()
|
|
436
|
-
room = new TLSocketRoom({ onSessionRemoved })
|
|
437
|
-
})
|
|
438
|
-
|
|
439
|
-
it('handles multiple concurrent sessions', () => {
|
|
440
|
-
const sessions = ['session1', 'session2', 'session3']
|
|
441
|
-
const sockets = sessions.map(() => createMockSocket())
|
|
442
|
-
|
|
443
|
-
sessions.forEach((sessionId, index) => {
|
|
444
|
-
room.handleSocketConnect({
|
|
445
|
-
sessionId,
|
|
446
|
-
socket: sockets[index],
|
|
447
|
-
})
|
|
226
|
+
describe('handleSocketConnect', () => {
|
|
227
|
+
it('[SR4] registers sessions with readonly defaulting to false', () => {
|
|
228
|
+
const room = new TLSocketRoom({})
|
|
229
|
+
const socket = createMockSocket()
|
|
230
|
+
connectSession(room, 'test-session', socket)
|
|
448
231
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}
|
|
456
|
-
room.handleSocketMessage(sessionId, JSON.stringify(connectRequest))
|
|
232
|
+
const sessions = room.getSessions()
|
|
233
|
+
expect(sessions).toHaveLength(1)
|
|
234
|
+
expect(sessions[0]).toMatchObject({
|
|
235
|
+
sessionId: 'test-session',
|
|
236
|
+
isConnected: true,
|
|
237
|
+
isReadonly: false,
|
|
457
238
|
})
|
|
458
|
-
|
|
459
|
-
expect(room.getNumActiveSessions()).toBe(3)
|
|
460
|
-
|
|
461
|
-
const sessionInfo = room.getSessions()
|
|
462
|
-
expect(sessionInfo).toHaveLength(3)
|
|
463
|
-
expect(sessionInfo.every((s) => s.isConnected)).toBe(true)
|
|
464
239
|
})
|
|
465
240
|
|
|
466
|
-
it('handles readonly sessions correctly', () => {
|
|
241
|
+
it('[SR4][SR7] handles readonly sessions correctly', () => {
|
|
242
|
+
const room = new TLSocketRoom({})
|
|
467
243
|
const socket = createMockSocket()
|
|
468
244
|
room.handleSocketConnect({
|
|
469
245
|
sessionId: 'readonly-session',
|
|
@@ -483,28 +259,36 @@ describe(TLSocketRoom, () => {
|
|
|
483
259
|
const sessions = room.getSessions()
|
|
484
260
|
expect(sessions[0].isReadonly).toBe(true)
|
|
485
261
|
})
|
|
486
|
-
})
|
|
487
262
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
let onAfterReceiveMessage: ReturnType<typeof vi.fn>
|
|
263
|
+
it('[SR4] attaches message, close, and error listeners when the socket supports addEventListener', () => {
|
|
264
|
+
const room = new TLSocketRoom({})
|
|
265
|
+
const socket = createMockSocket()
|
|
266
|
+
room.handleSocketConnect({ sessionId: 'test-session', socket })
|
|
493
267
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
onAfterReceiveMessage = vi.fn()
|
|
497
|
-
room = new TLSocketRoom({
|
|
498
|
-
onBeforeSendMessage,
|
|
499
|
-
onAfterReceiveMessage,
|
|
500
|
-
})
|
|
501
|
-
socket = createMockSocket()
|
|
268
|
+
const events = vi.mocked(socket.addEventListener!).mock.calls.map((c) => c[0])
|
|
269
|
+
expect(events.sort()).toEqual(['close', 'error', 'message'])
|
|
502
270
|
})
|
|
503
271
|
|
|
504
|
-
it('
|
|
505
|
-
room
|
|
506
|
-
|
|
272
|
+
it('[SR4] tolerates sockets without addEventListener', () => {
|
|
273
|
+
const room = new TLSocketRoom({})
|
|
274
|
+
const socket: WebSocketMinimal = {
|
|
275
|
+
send: vi.fn(),
|
|
276
|
+
close: vi.fn(),
|
|
277
|
+
readyState: WebSocket.OPEN,
|
|
278
|
+
}
|
|
279
|
+
expect(() => connectSession(room, 'test-session', socket)).not.toThrow()
|
|
280
|
+
expect(room.getSessions()[0].isConnected).toBe(true)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('[SR4][SR7] handles sessions with metadata correctly', () => {
|
|
284
|
+
const roomWithMeta = new TLSocketRoom<any, TestSessionMeta>({})
|
|
285
|
+
const socket = createMockSocket()
|
|
286
|
+
const meta: TestSessionMeta = { userId: 'user123', userName: 'Alice' }
|
|
287
|
+
|
|
288
|
+
roomWithMeta.handleSocketConnect({
|
|
289
|
+
sessionId: 'meta-session',
|
|
507
290
|
socket,
|
|
291
|
+
meta,
|
|
508
292
|
})
|
|
509
293
|
|
|
510
294
|
const connectRequest = {
|
|
@@ -514,7 +298,17 @@ describe(TLSocketRoom, () => {
|
|
|
514
298
|
protocolVersion: getTlsyncProtocolVersion(),
|
|
515
299
|
schema: createTLSchema().serialize(),
|
|
516
300
|
}
|
|
517
|
-
|
|
301
|
+
roomWithMeta.handleSocketMessage('meta-session', JSON.stringify(connectRequest))
|
|
302
|
+
|
|
303
|
+
const sessions = roomWithMeta.getSessions()
|
|
304
|
+
expect(sessions[0].meta).toEqual(meta)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('[SR4] calls onBeforeSendMessage for outgoing messages', () => {
|
|
308
|
+
const onBeforeSendMessage = vi.fn()
|
|
309
|
+
const room = new TLSocketRoom({ onBeforeSendMessage })
|
|
310
|
+
const socket = createMockSocket()
|
|
311
|
+
connectSession(room, 'test-session', socket)
|
|
518
312
|
|
|
519
313
|
expect(onBeforeSendMessage).toHaveBeenCalled()
|
|
520
314
|
const call = onBeforeSendMessage.mock.calls[0][0]
|
|
@@ -523,20 +317,11 @@ describe(TLSocketRoom, () => {
|
|
|
523
317
|
expect(call.stringified).toBeDefined()
|
|
524
318
|
})
|
|
525
319
|
|
|
526
|
-
it('calls onAfterReceiveMessage for valid incoming messages', () => {
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const connectRequest = {
|
|
533
|
-
type: 'connect' as const,
|
|
534
|
-
connectRequestId: 'connect-1',
|
|
535
|
-
lastServerClock: 0,
|
|
536
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
537
|
-
schema: createTLSchema().serialize(),
|
|
538
|
-
}
|
|
539
|
-
room.handleSocketMessage('test-session', JSON.stringify(connectRequest))
|
|
320
|
+
it('[SR4][SR5] calls onAfterReceiveMessage for valid incoming messages', () => {
|
|
321
|
+
const onAfterReceiveMessage = vi.fn()
|
|
322
|
+
const room = new TLSocketRoom({ onAfterReceiveMessage })
|
|
323
|
+
const socket = createMockSocket()
|
|
324
|
+
connectSession(room, 'test-session', socket)
|
|
540
325
|
|
|
541
326
|
expect(onAfterReceiveMessage).toHaveBeenCalled()
|
|
542
327
|
const call = onAfterReceiveMessage.mock.calls[0][0]
|
|
@@ -546,179 +331,243 @@ describe(TLSocketRoom, () => {
|
|
|
546
331
|
})
|
|
547
332
|
})
|
|
548
333
|
|
|
549
|
-
describe('
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
room
|
|
555
|
-
socket = createMockSocket()
|
|
556
|
-
})
|
|
557
|
-
|
|
558
|
-
it('handles socket errors correctly', () => {
|
|
559
|
-
room.handleSocketConnect({
|
|
560
|
-
sessionId: 'test-session',
|
|
561
|
-
socket,
|
|
562
|
-
})
|
|
563
|
-
|
|
564
|
-
const connectRequest = {
|
|
565
|
-
type: 'connect' as const,
|
|
566
|
-
connectRequestId: 'connect-1',
|
|
567
|
-
lastServerClock: 0,
|
|
568
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
569
|
-
schema: createTLSchema().serialize(),
|
|
570
|
-
}
|
|
571
|
-
room.handleSocketMessage('test-session', JSON.stringify(connectRequest))
|
|
334
|
+
describe('handleSocketMessage', () => {
|
|
335
|
+
it('[SR5] closes the socket via the error path on chunk assembly errors', () => {
|
|
336
|
+
const log: TLSyncLog = { warn: vi.fn(), error: vi.fn() }
|
|
337
|
+
const room = new TLSocketRoom({ log })
|
|
338
|
+
const socket = createMockSocket()
|
|
339
|
+
connectSession(room, 'test-session', socket)
|
|
572
340
|
|
|
573
|
-
|
|
341
|
+
// A non-JSON, non-chunk message produces an assembly error (CH7), which
|
|
342
|
+
// is logged and closes the socket via the error path rather than throwing
|
|
343
|
+
room.handleSocketMessage('test-session', 'not a json message and not a chunk')
|
|
574
344
|
|
|
575
|
-
|
|
576
|
-
expect(
|
|
345
|
+
expect(log.error).toHaveBeenCalledWith('Error assembling message', expect.anything())
|
|
346
|
+
expect(socket.close).toHaveBeenCalled()
|
|
347
|
+
// the session enters the disconnect grace period rather than being rejected
|
|
348
|
+
expect(room.getSessions()[0].isConnected).toBe(false)
|
|
577
349
|
})
|
|
578
350
|
|
|
579
|
-
it('
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
351
|
+
it('[SR5] rejects the session with UNKNOWN_ERROR when message handling throws', () => {
|
|
352
|
+
const log: TLSyncLog = { warn: vi.fn(), error: vi.fn() }
|
|
353
|
+
const room = new TLSocketRoom({ log })
|
|
354
|
+
const socket = createMockSocket()
|
|
355
|
+
connectSession(room, 'test-session', socket)
|
|
584
356
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
lastServerClock: 0,
|
|
589
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
590
|
-
schema: createTLSchema().serialize(),
|
|
591
|
-
}
|
|
592
|
-
room.handleSocketMessage('test-session', JSON.stringify(connectRequest))
|
|
357
|
+
// Invalid JSON while the assembler is idle throws synchronously (CH4),
|
|
358
|
+
// which lands in the catch path and rejects the session
|
|
359
|
+
room.handleSocketMessage('test-session', '{invalid json')
|
|
593
360
|
|
|
594
|
-
expect(
|
|
361
|
+
expect(log.error).toHaveBeenCalled()
|
|
362
|
+
expect(socket.close).toHaveBeenCalledWith(
|
|
363
|
+
TLSyncErrorCloseEventCode,
|
|
364
|
+
TLSyncErrorCloseEventReason.UNKNOWN_ERROR
|
|
365
|
+
)
|
|
366
|
+
expect(room.getSessions()).toHaveLength(0)
|
|
367
|
+
})
|
|
595
368
|
|
|
596
|
-
|
|
597
|
-
|
|
369
|
+
it('[SR5] rejects the session with UNKNOWN_ERROR when async message handling throws', async () => {
|
|
370
|
+
const log: TLSyncLog = { warn: vi.fn(), error: vi.fn() }
|
|
371
|
+
const room = new TLSocketRoom({ log })
|
|
372
|
+
const socket = createMockSocket()
|
|
373
|
+
connectSession(room, 'test-session', socket)
|
|
374
|
+
|
|
375
|
+
// A structurally valid JSON message with an unknown type passes the
|
|
376
|
+
// assembler but makes the room's (async) message handler throw
|
|
377
|
+
room.handleSocketMessage('test-session', JSON.stringify({ type: 'bogus' }))
|
|
378
|
+
await Promise.resolve()
|
|
379
|
+
await Promise.resolve()
|
|
380
|
+
|
|
381
|
+
expect(log.error).toHaveBeenCalled()
|
|
382
|
+
expect(socket.close).toHaveBeenCalledWith(
|
|
383
|
+
TLSyncErrorCloseEventCode,
|
|
384
|
+
TLSyncErrorCloseEventReason.UNKNOWN_ERROR
|
|
385
|
+
)
|
|
386
|
+
expect(room.getSessions()).toHaveLength(0)
|
|
598
387
|
})
|
|
599
|
-
})
|
|
600
388
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
389
|
+
it('[SR5] warns when receiving a message from an unknown session', () => {
|
|
390
|
+
const log: TLSyncLog = { warn: vi.fn(), error: vi.fn() }
|
|
391
|
+
const room = new TLSocketRoom({ log })
|
|
604
392
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
393
|
+
room.handleSocketMessage('nonexistent-session', JSON.stringify({ type: 'ping' }))
|
|
394
|
+
|
|
395
|
+
expect(log.warn).toHaveBeenCalledWith(
|
|
396
|
+
'Received message from unknown session',
|
|
397
|
+
'nonexistent-session'
|
|
398
|
+
)
|
|
399
|
+
expect(log.error).not.toHaveBeenCalled()
|
|
608
400
|
})
|
|
609
401
|
|
|
610
|
-
it('
|
|
611
|
-
room
|
|
612
|
-
|
|
613
|
-
socket,
|
|
402
|
+
it('[SR5] prunes timed-out sessions during handleSocketMessage', async () => {
|
|
403
|
+
const room = new TLSocketRoom({
|
|
404
|
+
clientTimeout: 1,
|
|
614
405
|
})
|
|
406
|
+
const socket = createMockSocket()
|
|
407
|
+
connectSession(room, 'test', socket)
|
|
408
|
+
expect(room.getNumActiveSessions()).toBe(1)
|
|
615
409
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
connectRequestId: 'connect-1',
|
|
619
|
-
lastServerClock: 0,
|
|
620
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
621
|
-
schema: createTLSchema().serialize(),
|
|
622
|
-
}
|
|
623
|
-
room.handleSocketMessage('test-session', JSON.stringify(connectRequest))
|
|
410
|
+
// Wait for both the client timeout and the prune throttle window (1s) to expire
|
|
411
|
+
await new Promise((r) => setTimeout(r, 1100))
|
|
624
412
|
|
|
625
|
-
|
|
626
|
-
|
|
413
|
+
// Connect a second session and send a message to trigger pruning
|
|
414
|
+
const socket2 = createMockSocket()
|
|
415
|
+
connectSession(room, 'test2', socket2)
|
|
627
416
|
|
|
628
|
-
|
|
629
|
-
|
|
417
|
+
// The timed-out socket should have been closed
|
|
418
|
+
expect(socket.close).toHaveBeenCalled()
|
|
630
419
|
|
|
631
|
-
|
|
632
|
-
// Should not throw an error
|
|
633
|
-
expect(() => {
|
|
634
|
-
room.sendCustomMessage('nonexistent-session', { test: 'data' })
|
|
635
|
-
}).not.toThrow()
|
|
420
|
+
room.close()
|
|
636
421
|
})
|
|
637
|
-
})
|
|
638
422
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
423
|
+
it('[SR5] runs prune after handleMessage so the sender is not evicted by their own message', () => {
|
|
424
|
+
// If prune ran before handleMessage, an idle client's message would trigger
|
|
425
|
+
// prune first and cancel them before lastInteractionTime is updated. Verify
|
|
426
|
+
// order: handleMessage must run before pruneSessions.
|
|
427
|
+
const room = new TLSocketRoom({})
|
|
428
|
+
const syncRoom = (room as unknown as { room: TLSyncRoom<TLRecord, void> }).room
|
|
429
|
+
const socket = createMockSocket()
|
|
430
|
+
connectSession(room, 'test', socket)
|
|
431
|
+
|
|
432
|
+
const callOrder: string[] = []
|
|
433
|
+
const realHandleMessage = syncRoom.handleMessage.bind(syncRoom)
|
|
434
|
+
vi.spyOn(syncRoom, 'handleMessage').mockImplementation((sessionId, message) => {
|
|
435
|
+
callOrder.push('handleMessage')
|
|
436
|
+
return realHandleMessage(sessionId, message)
|
|
437
|
+
})
|
|
438
|
+
const originalPrune = syncRoom.pruneSessions
|
|
439
|
+
const wrappedPrune = function (this: typeof syncRoom) {
|
|
440
|
+
callOrder.push('prune')
|
|
441
|
+
return originalPrune.call(this)
|
|
442
|
+
}
|
|
443
|
+
wrappedPrune.cancel = originalPrune.cancel?.bind(originalPrune)
|
|
444
|
+
syncRoom.pruneSessions = wrappedPrune as typeof originalPrune
|
|
445
|
+
|
|
446
|
+
room.handleSocketMessage('test', JSON.stringify({ type: 'ping' }))
|
|
447
|
+
|
|
448
|
+
expect(callOrder).toEqual(['handleMessage', 'prune'])
|
|
449
|
+
vi.restoreAllMocks()
|
|
450
|
+
room.close()
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('[SR5] fully removes sessions after disconnect even with no further messages', () => {
|
|
454
|
+
vi.useFakeTimers()
|
|
455
|
+
try {
|
|
456
|
+
const onSessionRemoved = vi.fn()
|
|
457
|
+
const room = new TLSocketRoom({ onSessionRemoved })
|
|
458
|
+
const socket = createMockSocket()
|
|
459
|
+
connectSession(room, 'test', socket)
|
|
460
|
+
expect(room.getNumActiveSessions()).toBe(1)
|
|
461
|
+
|
|
462
|
+
// Disconnect the only client
|
|
463
|
+
room.handleSocketClose('test')
|
|
464
|
+
|
|
465
|
+
// Session should be in AwaitingRemoval, not yet fully removed
|
|
466
|
+
expect(room.getNumActiveSessions()).toBe(1)
|
|
467
|
+
expect(onSessionRemoved).not.toHaveBeenCalled()
|
|
468
|
+
|
|
469
|
+
// Advance past SESSION_REMOVAL_WAIT_TIME (5s) + buffer (100ms) + throttle (1s)
|
|
470
|
+
vi.advanceTimersByTime(6200)
|
|
471
|
+
|
|
472
|
+
// Session should now be fully removed via the scheduled follow-up prune
|
|
473
|
+
expect(room.getNumActiveSessions()).toBe(0)
|
|
474
|
+
expect(onSessionRemoved).toHaveBeenCalledWith(
|
|
475
|
+
room,
|
|
476
|
+
expect.objectContaining({ sessionId: 'test', numSessionsRemaining: 0 })
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
room.close()
|
|
480
|
+
} finally {
|
|
481
|
+
vi.useRealTimers()
|
|
482
|
+
}
|
|
483
|
+
})
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
describe('socket error and close handling', () => {
|
|
487
|
+
let room: TLSocketRoom
|
|
488
|
+
let socket: WebSocketMinimal
|
|
642
489
|
|
|
643
490
|
beforeEach(() => {
|
|
644
491
|
room = new TLSocketRoom({})
|
|
645
492
|
socket = createMockSocket()
|
|
646
493
|
})
|
|
647
494
|
|
|
648
|
-
it('
|
|
649
|
-
room
|
|
650
|
-
sessionId: 'test-session',
|
|
651
|
-
socket,
|
|
652
|
-
})
|
|
495
|
+
it('[SR6] handles socket errors correctly', () => {
|
|
496
|
+
connectSession(room, 'test-session', socket)
|
|
653
497
|
|
|
654
|
-
|
|
655
|
-
type: 'connect' as const,
|
|
656
|
-
connectRequestId: 'connect-1',
|
|
657
|
-
lastServerClock: 0,
|
|
658
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
659
|
-
schema: createTLSchema().serialize(),
|
|
660
|
-
}
|
|
661
|
-
room.handleSocketMessage('test-session', JSON.stringify(connectRequest))
|
|
498
|
+
expect(room.getSessions()).toHaveLength(1)
|
|
662
499
|
|
|
663
|
-
|
|
500
|
+
// Trigger socket error - should not throw
|
|
501
|
+
expect(() => room.handleSocketError('test-session')).not.toThrow()
|
|
664
502
|
|
|
665
|
-
//
|
|
666
|
-
expect(room.getSessions()).
|
|
503
|
+
// The session is cancelled (grace period) and the socket closed
|
|
504
|
+
expect(room.getSessions()[0].isConnected).toBe(false)
|
|
505
|
+
expect(socket.close).toHaveBeenCalled()
|
|
667
506
|
})
|
|
668
507
|
|
|
669
|
-
it('
|
|
670
|
-
room
|
|
671
|
-
sessionId: 'test-session',
|
|
672
|
-
socket,
|
|
673
|
-
})
|
|
508
|
+
it('[SR6] handles socket close correctly', () => {
|
|
509
|
+
connectSession(room, 'test-session', socket)
|
|
674
510
|
|
|
675
|
-
|
|
676
|
-
type: 'connect' as const,
|
|
677
|
-
connectRequestId: 'connect-1',
|
|
678
|
-
lastServerClock: 0,
|
|
679
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
680
|
-
schema: createTLSchema().serialize(),
|
|
681
|
-
}
|
|
682
|
-
room.handleSocketMessage('test-session', JSON.stringify(connectRequest))
|
|
511
|
+
expect(room.getSessions()).toHaveLength(1)
|
|
683
512
|
|
|
684
|
-
|
|
513
|
+
// Trigger socket close - should not throw
|
|
514
|
+
expect(() => room.handleSocketClose('test-session')).not.toThrow()
|
|
685
515
|
|
|
686
|
-
//
|
|
687
|
-
expect(room.getSessions()).
|
|
516
|
+
// The session is cancelled (grace period) and the socket closed
|
|
517
|
+
expect(room.getSessions()[0].isConnected).toBe(false)
|
|
518
|
+
expect(socket.close).toHaveBeenCalled()
|
|
688
519
|
})
|
|
689
520
|
})
|
|
690
521
|
|
|
691
|
-
describe('
|
|
692
|
-
it('
|
|
522
|
+
describe('session reporting', () => {
|
|
523
|
+
it('[SR7] handles multiple concurrent sessions', () => {
|
|
693
524
|
const room = new TLSocketRoom({})
|
|
694
|
-
const
|
|
525
|
+
const sessions = ['session1', 'session2', 'session3']
|
|
526
|
+
const sockets = sessions.map(() => createMockSocket())
|
|
695
527
|
|
|
696
|
-
|
|
697
|
-
sessionId
|
|
698
|
-
socket,
|
|
528
|
+
sessions.forEach((sessionId, index) => {
|
|
529
|
+
connectSession(room, sessionId, sockets[index])
|
|
699
530
|
})
|
|
700
531
|
|
|
701
|
-
|
|
702
|
-
type: 'connect' as const,
|
|
703
|
-
connectRequestId: 'connect-1',
|
|
704
|
-
lastServerClock: 0,
|
|
705
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
706
|
-
schema: createTLSchema().serialize(),
|
|
707
|
-
}
|
|
708
|
-
room.handleSocketMessage('test-session', JSON.stringify(connectRequest))
|
|
532
|
+
expect(room.getNumActiveSessions()).toBe(3)
|
|
709
533
|
|
|
710
|
-
|
|
534
|
+
const sessionInfo = room.getSessions()
|
|
535
|
+
expect(sessionInfo).toHaveLength(3)
|
|
536
|
+
expect(sessionInfo.every((s) => s.isConnected)).toBe(true)
|
|
537
|
+
})
|
|
711
538
|
|
|
712
|
-
|
|
713
|
-
room
|
|
539
|
+
it('[SR7] counts sessions that have not completed the connect handshake', () => {
|
|
540
|
+
const room = new TLSocketRoom({})
|
|
541
|
+
const socket = createMockSocket()
|
|
542
|
+
room.handleSocketConnect({ sessionId: 'pending', socket })
|
|
714
543
|
|
|
715
|
-
|
|
716
|
-
expect(room.
|
|
544
|
+
expect(room.getNumActiveSessions()).toBe(1)
|
|
545
|
+
expect(room.getSessions()[0]).toMatchObject({
|
|
546
|
+
sessionId: 'pending',
|
|
547
|
+
isConnected: false,
|
|
548
|
+
})
|
|
549
|
+
})
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
describe('getRecord', () => {
|
|
553
|
+
it('[SR8] returns a deep clone that is safe to mutate', () => {
|
|
554
|
+
const room = new TLSocketRoom({})
|
|
555
|
+
const page = room.getRecord('page:page') as TLPage
|
|
556
|
+
expect(page).toMatchObject({ id: 'page:page', name: 'Page 1' })
|
|
557
|
+
|
|
558
|
+
// mutating the returned record must not affect the store
|
|
559
|
+
page.name = 'mutated'
|
|
560
|
+
expect((room.getRecord('page:page') as TLPage).name).toBe('Page 1')
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('[SR8] returns undefined for missing records', () => {
|
|
564
|
+
const room = new TLSocketRoom({})
|
|
565
|
+
expect(room.getRecord('page:does-not-exist')).toBeUndefined()
|
|
717
566
|
})
|
|
718
567
|
})
|
|
719
568
|
|
|
720
|
-
describe('
|
|
721
|
-
it('
|
|
569
|
+
describe('clocks and snapshots', () => {
|
|
570
|
+
it('[SR9] increments clock after store updates', async () => {
|
|
722
571
|
const store = getStore()
|
|
723
572
|
store.ensureStoreIsUsable()
|
|
724
573
|
const room = new TLSocketRoom({
|
|
@@ -727,111 +576,419 @@ describe(TLSocketRoom, () => {
|
|
|
727
576
|
|
|
728
577
|
const initialClock = room.getCurrentDocumentClock()
|
|
729
578
|
|
|
730
|
-
await room.updateStore(
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
579
|
+
await room.updateStore((store) => {
|
|
580
|
+
store.put(
|
|
581
|
+
PageRecordType.create({
|
|
582
|
+
id: PageRecordType.createId('test-page'),
|
|
583
|
+
name: 'Test',
|
|
584
|
+
index: ZERO_INDEX_KEY,
|
|
585
|
+
})
|
|
586
|
+
)
|
|
737
587
|
})
|
|
738
588
|
|
|
739
589
|
expect(room.getCurrentDocumentClock()).toBeGreaterThan(initialClock)
|
|
740
590
|
})
|
|
741
591
|
|
|
742
|
-
it('
|
|
592
|
+
it('[SR9] getCurrentDocumentClock returns the storage clock', async () => {
|
|
593
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: DEFAULT_INITIAL_SNAPSHOT })
|
|
594
|
+
const room = new TLSocketRoom<TLRecord, undefined>({ storage })
|
|
595
|
+
|
|
596
|
+
expect(room.getCurrentDocumentClock()).toBe(storage.getClock())
|
|
597
|
+
|
|
598
|
+
await room.updateStore((store) => {
|
|
599
|
+
store.put(PageRecordType.create({ name: 'page 3', index: 'a3' as IndexKey }) as TLRecord)
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
expect(room.getCurrentDocumentClock()).toBe(storage.getClock())
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it('[SR9] getCurrentSnapshot delegates to storage.getSnapshot', () => {
|
|
606
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: DEFAULT_INITIAL_SNAPSHOT })
|
|
607
|
+
const room = new TLSocketRoom<TLRecord, undefined>({ storage })
|
|
608
|
+
|
|
609
|
+
expect(room.getCurrentSnapshot()).toEqual(storage.getSnapshot())
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it('[SR9] getCurrentSnapshot throws when the storage does not support snapshots', () => {
|
|
613
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: DEFAULT_INITIAL_SNAPSHOT })
|
|
614
|
+
const room = new TLSocketRoom<TLRecord, undefined>({ storage })
|
|
615
|
+
// simulate a storage implementation without optional getSnapshot support
|
|
616
|
+
;(storage as any).getSnapshot = undefined
|
|
617
|
+
|
|
618
|
+
expect(() => room.getCurrentSnapshot()).toThrow(
|
|
619
|
+
'getCurrentSnapshot is not supported for this storage type'
|
|
620
|
+
)
|
|
621
|
+
})
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
describe('loadSnapshot', () => {
|
|
625
|
+
it('[SR10] allows loading a TLStoreSnapshot at some later time', () => {
|
|
626
|
+
const store = getStore()
|
|
627
|
+
const room = new TLSocketRoom({
|
|
628
|
+
initialSnapshot: store.getStoreSnapshot(),
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
expect(room.getCurrentSnapshot()).toMatchObject({ documentClock: 0, documents: [] })
|
|
632
|
+
|
|
633
|
+
// populate with an empty document (document:document and page:page records)
|
|
634
|
+
store.ensureStoreIsUsable()
|
|
635
|
+
|
|
636
|
+
const snapshot = store.getStoreSnapshot()
|
|
637
|
+
room.loadSnapshot(snapshot)
|
|
638
|
+
|
|
639
|
+
expect(room.getCurrentSnapshot().documentClock).toBe(1)
|
|
640
|
+
expect(
|
|
641
|
+
room.getCurrentSnapshot().documents.sort((a, b) => a.state.id.localeCompare(b.state.id))
|
|
642
|
+
).toMatchInlineSnapshot(`
|
|
643
|
+
[
|
|
644
|
+
{
|
|
645
|
+
"lastChangedClock": 1,
|
|
646
|
+
"state": {
|
|
647
|
+
"gridSize": 10,
|
|
648
|
+
"id": "document:document",
|
|
649
|
+
"meta": {},
|
|
650
|
+
"name": "",
|
|
651
|
+
"typeName": "document",
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
"lastChangedClock": 1,
|
|
656
|
+
"state": {
|
|
657
|
+
"id": "page:page",
|
|
658
|
+
"index": "a1",
|
|
659
|
+
"meta": {},
|
|
660
|
+
"name": "Page 1",
|
|
661
|
+
"typeName": "page",
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
]
|
|
665
|
+
`)
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
it('[SR10] increments documentClock when loading snapshot with different data', () => {
|
|
743
669
|
const store = getStore()
|
|
744
670
|
store.ensureStoreIsUsable()
|
|
745
671
|
const room = new TLSocketRoom({
|
|
746
672
|
initialSnapshot: store.getStoreSnapshot(),
|
|
747
673
|
})
|
|
748
674
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
675
|
+
const oldClock = room.getCurrentDocumentClock()
|
|
676
|
+
expect(oldClock).toBe(0)
|
|
677
|
+
|
|
678
|
+
// Add a new page to make the snapshot different
|
|
679
|
+
store.put([PageRecordType.create({ name: 'New Page', index: 'a2' as IndexKey })])
|
|
680
|
+
const newSnapshot = store.getStoreSnapshot()
|
|
681
|
+
room.loadSnapshot(newSnapshot)
|
|
682
|
+
|
|
683
|
+
expect(room.getCurrentDocumentClock()).toBe(oldClock + 1)
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
it('[SR10] does not increment documentClock when loading identical snapshot', () => {
|
|
687
|
+
const store = getStore()
|
|
688
|
+
store.ensureStoreIsUsable()
|
|
689
|
+
const room = new TLSocketRoom({
|
|
690
|
+
initialSnapshot: store.getStoreSnapshot(),
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
const oldClock = room.getCurrentDocumentClock()
|
|
694
|
+
|
|
695
|
+
// Load the same snapshot again
|
|
696
|
+
room.loadSnapshot(store.getStoreSnapshot())
|
|
697
|
+
|
|
698
|
+
// Clock should not change since data is identical
|
|
699
|
+
expect(room.getCurrentDocumentClock()).toBe(oldClock)
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
it('[SR10] preserves existing tombstones with original clock values', async () => {
|
|
703
|
+
// Create a room with initial state
|
|
704
|
+
const store = getStore()
|
|
705
|
+
store.ensureStoreIsUsable()
|
|
706
|
+
const testPageId = PageRecordType.createId('test_page')
|
|
707
|
+
store.put([
|
|
708
|
+
PageRecordType.create({ id: testPageId, name: 'Test Page', index: ZERO_INDEX_KEY }),
|
|
709
|
+
])
|
|
710
|
+
const room = new TLSocketRoom({
|
|
711
|
+
initialSnapshot: store.getStoreSnapshot(),
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
await room.updateStore((store) => {
|
|
715
|
+
store.delete(testPageId)
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
const deletionClock = room.getCurrentDocumentClock()
|
|
719
|
+
expect(room.getCurrentSnapshot().tombstones).toEqual({
|
|
720
|
+
[testPageId]: deletionClock,
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
room.loadSnapshot(room.getCurrentSnapshot())
|
|
724
|
+
|
|
725
|
+
// Tombstones should be preserved
|
|
726
|
+
expect(room.getCurrentSnapshot().tombstones).toEqual({
|
|
727
|
+
[testPageId]: deletionClock,
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
// Clock should not change since we loaded the same snapshot
|
|
731
|
+
expect(room.getCurrentSnapshot().documentClock).toBe(deletionClock)
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
it('[SR10] preserves schema when resetting room state', () => {
|
|
735
|
+
const store = getStore()
|
|
736
|
+
store.ensureStoreIsUsable()
|
|
737
|
+
const room = new TLSocketRoom({
|
|
738
|
+
initialSnapshot: store.getStoreSnapshot(),
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
const originalSchema = room.getCurrentSnapshot().schema
|
|
742
|
+
|
|
743
|
+
// Reset with a new snapshot
|
|
744
|
+
const newSnapshot = store.getStoreSnapshot()
|
|
745
|
+
room.loadSnapshot(newSnapshot)
|
|
746
|
+
|
|
747
|
+
const result = room.getCurrentSnapshot()
|
|
748
|
+
expect(result.schema).toEqual(originalSchema)
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
it('[SR10] broadcasts the resulting changes to connected clients', async () => {
|
|
752
|
+
const store = getStore()
|
|
753
|
+
store.ensureStoreIsUsable()
|
|
754
|
+
const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
|
|
755
|
+
const socket = createMockSocket()
|
|
756
|
+
connectSession(room, 'observer', socket)
|
|
757
|
+
vi.mocked(socket.send).mockClear()
|
|
758
|
+
|
|
759
|
+
store.put([
|
|
760
|
+
PageRecordType.create({
|
|
761
|
+
id: PageRecordType.createId('extra'),
|
|
762
|
+
name: 'Extra page',
|
|
763
|
+
index: 'a3' as IndexKey,
|
|
764
|
+
}),
|
|
765
|
+
])
|
|
766
|
+
room.loadSnapshot(store.getStoreSnapshot())
|
|
767
|
+
|
|
768
|
+
// storage onChange notifications are delivered asynchronously
|
|
769
|
+
await flushAsync()
|
|
770
|
+
|
|
771
|
+
const sent = vi.mocked(socket.send).mock.calls.map((c) => String(c[0]))
|
|
772
|
+
expect(sent.some((m) => m.includes('Extra page'))).toBe(true)
|
|
773
|
+
|
|
774
|
+
room.close()
|
|
754
775
|
})
|
|
755
776
|
})
|
|
756
777
|
|
|
757
|
-
describe('
|
|
758
|
-
it('
|
|
759
|
-
const
|
|
778
|
+
describe('closing rooms and sessions', () => {
|
|
779
|
+
it('[SR11] closes room correctly', () => {
|
|
780
|
+
const room = new TLSocketRoom({})
|
|
760
781
|
const socket = createMockSocket()
|
|
761
|
-
|
|
782
|
+
connectSession(room, 'test-session', socket)
|
|
762
783
|
|
|
763
|
-
|
|
764
|
-
sessionId: 'meta-session',
|
|
765
|
-
socket,
|
|
766
|
-
meta,
|
|
767
|
-
})
|
|
784
|
+
expect(room.getSessions()).toHaveLength(1)
|
|
768
785
|
|
|
769
|
-
|
|
786
|
+
// Close the room
|
|
787
|
+
room.close()
|
|
788
|
+
|
|
789
|
+
// Room should be marked as closed
|
|
790
|
+
expect(room.isClosed()).toBe(true)
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
it('[SR11][SR12] close clears pending session snapshot timers', () => {
|
|
794
|
+
vi.useFakeTimers()
|
|
795
|
+
try {
|
|
796
|
+
const onSessionSnapshot = vi.fn()
|
|
797
|
+
const room = new TLSocketRoom({ onSessionSnapshot })
|
|
798
|
+
const socket = createMockSocket()
|
|
799
|
+
connectSession(room, 'test', socket)
|
|
800
|
+
|
|
801
|
+
// Start a snapshot debounce, then close the room before it fires
|
|
802
|
+
room.handleSocketMessage('test', JSON.stringify({ type: 'ping' }))
|
|
803
|
+
room.close()
|
|
804
|
+
|
|
805
|
+
vi.advanceTimersByTime(6000)
|
|
806
|
+
expect(onSessionSnapshot).not.toHaveBeenCalled()
|
|
807
|
+
} finally {
|
|
808
|
+
vi.useRealTimers()
|
|
809
|
+
}
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
it('[SR11] closes session without fatal reason', () => {
|
|
813
|
+
const room = new TLSocketRoom({})
|
|
814
|
+
const socket = createMockSocket()
|
|
815
|
+
connectSession(room, 'test-session', socket)
|
|
816
|
+
|
|
817
|
+
room.closeSession('test-session')
|
|
818
|
+
|
|
819
|
+
// Session should be removed and the socket closed without a fatal close code
|
|
820
|
+
expect(room.getSessions()).toHaveLength(0)
|
|
821
|
+
expect(socket.close).toHaveBeenCalled()
|
|
822
|
+
expect(vi.mocked(socket.close!).mock.calls[0][0]).toBeUndefined()
|
|
823
|
+
})
|
|
824
|
+
|
|
825
|
+
it('[SR11] closes session with fatal reason', () => {
|
|
826
|
+
const room = new TLSocketRoom({})
|
|
827
|
+
const socket = createMockSocket()
|
|
828
|
+
connectSession(room, 'test-session', socket)
|
|
829
|
+
|
|
830
|
+
room.closeSession('test-session', TLSyncErrorCloseEventReason.FORBIDDEN)
|
|
831
|
+
|
|
832
|
+
// Session should be removed and the socket closed with the fatal close code
|
|
833
|
+
expect(room.getSessions()).toHaveLength(0)
|
|
834
|
+
expect(socket.close).toHaveBeenCalledWith(
|
|
835
|
+
TLSyncErrorCloseEventCode,
|
|
836
|
+
TLSyncErrorCloseEventReason.FORBIDDEN
|
|
837
|
+
)
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
it('[SR11] sends custom messages', () => {
|
|
841
|
+
const json = JSON.stringify
|
|
842
|
+
const store = getStore()
|
|
843
|
+
const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
|
|
844
|
+
|
|
845
|
+
const sessionId = 'test-session-1'
|
|
846
|
+
const send = vi.fn()
|
|
847
|
+
|
|
848
|
+
// Add session to the room (socket without addEventListener support)
|
|
849
|
+
const mockSocket: WebSocketMinimal = { send, close: vi.fn(), readyState: WebSocket.OPEN }
|
|
850
|
+
room.handleSocketConnect({ sessionId, socket: mockSocket })
|
|
851
|
+
|
|
852
|
+
// Send connect message to establish the session
|
|
853
|
+
const connect = {
|
|
770
854
|
type: 'connect' as const,
|
|
771
855
|
connectRequestId: 'connect-1',
|
|
772
856
|
lastServerClock: 0,
|
|
773
857
|
protocolVersion: getTlsyncProtocolVersion(),
|
|
774
|
-
schema:
|
|
858
|
+
schema: store.schema.serialize(),
|
|
859
|
+
}
|
|
860
|
+
room.handleSocketMessage(sessionId, json(connect))
|
|
861
|
+
|
|
862
|
+
room.sendCustomMessage(sessionId, 'hello world')
|
|
863
|
+
expect(send.mock.lastCall).toEqual([json({ type: 'custom', data: 'hello world' })])
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
it('[SR11] sends custom messages to connected sessions', () => {
|
|
867
|
+
const room = new TLSocketRoom({})
|
|
868
|
+
const socket = createMockSocket()
|
|
869
|
+
connectSession(room, 'test-session', socket)
|
|
870
|
+
|
|
871
|
+
const customData = { type: 'notification', message: 'Hello World' }
|
|
872
|
+
room.sendCustomMessage('test-session', customData)
|
|
873
|
+
|
|
874
|
+
expect(socket.send).toHaveBeenCalledWith(JSON.stringify({ type: 'custom', data: customData }))
|
|
875
|
+
})
|
|
876
|
+
|
|
877
|
+
it('[SR11] handles custom message to non-existent session gracefully', () => {
|
|
878
|
+
const room = new TLSocketRoom({})
|
|
879
|
+
// Should not throw an error
|
|
880
|
+
expect(() => {
|
|
881
|
+
room.sendCustomMessage('nonexistent-session', { test: 'data' })
|
|
882
|
+
}).not.toThrow()
|
|
883
|
+
})
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
describe('onSessionSnapshot', () => {
|
|
887
|
+
it('[SR12] calls onSessionSnapshot after debounce on message receipt', () => {
|
|
888
|
+
vi.useFakeTimers()
|
|
889
|
+
try {
|
|
890
|
+
const onSessionSnapshot = vi.fn()
|
|
891
|
+
const room = new TLSocketRoom({ onSessionSnapshot })
|
|
892
|
+
const socket = createMockSocket()
|
|
893
|
+
connectSession(room, 'test', socket)
|
|
894
|
+
|
|
895
|
+
// Send a ping to trigger the debounce
|
|
896
|
+
room.handleSocketMessage('test', JSON.stringify({ type: 'ping' }))
|
|
897
|
+
|
|
898
|
+
// Not called immediately
|
|
899
|
+
expect(onSessionSnapshot).not.toHaveBeenCalled()
|
|
900
|
+
|
|
901
|
+
// Advance past the 5s debounce
|
|
902
|
+
vi.advanceTimersByTime(5100)
|
|
903
|
+
|
|
904
|
+
expect(onSessionSnapshot).toHaveBeenCalledTimes(1)
|
|
905
|
+
expect(onSessionSnapshot).toHaveBeenCalledWith(
|
|
906
|
+
'test',
|
|
907
|
+
expect.objectContaining({
|
|
908
|
+
serializedSchema: expect.anything(),
|
|
909
|
+
isReadonly: false,
|
|
910
|
+
})
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
room.close()
|
|
914
|
+
} finally {
|
|
915
|
+
vi.useRealTimers()
|
|
916
|
+
}
|
|
917
|
+
})
|
|
918
|
+
|
|
919
|
+
it('[SR12][SR6] does not call onSessionSnapshot after socket close (timer cleared)', () => {
|
|
920
|
+
vi.useFakeTimers()
|
|
921
|
+
try {
|
|
922
|
+
const onSessionSnapshot = vi.fn()
|
|
923
|
+
const room = new TLSocketRoom({ onSessionSnapshot })
|
|
924
|
+
const socket = createMockSocket()
|
|
925
|
+
connectSession(room, 'test', socket)
|
|
926
|
+
|
|
927
|
+
// Send a message to start the debounce
|
|
928
|
+
room.handleSocketMessage('test', JSON.stringify({ type: 'ping' }))
|
|
929
|
+
|
|
930
|
+
// Close the socket before the debounce fires
|
|
931
|
+
room.handleSocketClose('test')
|
|
932
|
+
|
|
933
|
+
// Advance past the 5s debounce
|
|
934
|
+
vi.advanceTimersByTime(5100)
|
|
935
|
+
|
|
936
|
+
// Should never have been called - timer was cleared on close
|
|
937
|
+
expect(onSessionSnapshot).not.toHaveBeenCalled()
|
|
938
|
+
|
|
939
|
+
room.close()
|
|
940
|
+
} finally {
|
|
941
|
+
vi.useRealTimers()
|
|
775
942
|
}
|
|
776
|
-
roomWithMeta.handleSocketMessage('meta-session', JSON.stringify(connectRequest))
|
|
777
|
-
|
|
778
|
-
const sessions = roomWithMeta.getSessions()
|
|
779
|
-
expect(sessions[0].meta).toEqual(meta)
|
|
780
943
|
})
|
|
781
|
-
})
|
|
782
944
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
945
|
+
it('[SR12] resets the debounce on subsequent messages', () => {
|
|
946
|
+
vi.useFakeTimers()
|
|
947
|
+
try {
|
|
948
|
+
const onSessionSnapshot = vi.fn()
|
|
949
|
+
const room = new TLSocketRoom({ onSessionSnapshot })
|
|
950
|
+
const socket = createMockSocket()
|
|
951
|
+
connectSession(room, 'test', socket)
|
|
790
952
|
|
|
791
|
-
|
|
953
|
+
// Send first message
|
|
954
|
+
room.handleSocketMessage('test', JSON.stringify({ type: 'ping' }))
|
|
792
955
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
id: PageRecordType.createId('test-page'),
|
|
797
|
-
name: 'Test',
|
|
798
|
-
index: ZERO_INDEX_KEY,
|
|
799
|
-
})
|
|
800
|
-
)
|
|
801
|
-
})
|
|
956
|
+
// Advance 3s (within the 5s window)
|
|
957
|
+
vi.advanceTimersByTime(3000)
|
|
958
|
+
expect(onSessionSnapshot).not.toHaveBeenCalled()
|
|
802
959
|
|
|
803
|
-
|
|
960
|
+
// Send another message, resetting the debounce
|
|
961
|
+
room.handleSocketMessage('test', JSON.stringify({ type: 'ping' }))
|
|
962
|
+
|
|
963
|
+
// Advance another 3s (6s total, but only 3s since last message)
|
|
964
|
+
vi.advanceTimersByTime(3000)
|
|
965
|
+
expect(onSessionSnapshot).not.toHaveBeenCalled()
|
|
966
|
+
|
|
967
|
+
// Advance past the new debounce window
|
|
968
|
+
vi.advanceTimersByTime(2100)
|
|
969
|
+
expect(onSessionSnapshot).toHaveBeenCalledTimes(1)
|
|
970
|
+
|
|
971
|
+
room.close()
|
|
972
|
+
} finally {
|
|
973
|
+
vi.useRealTimers()
|
|
974
|
+
}
|
|
804
975
|
})
|
|
805
976
|
})
|
|
806
|
-
})
|
|
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
977
|
|
|
821
978
|
describe('getSessionSnapshot', () => {
|
|
822
|
-
it('returns null for unknown session', () => {
|
|
979
|
+
it('[SR13] returns null for unknown session', () => {
|
|
823
980
|
const room = new TLSocketRoom({})
|
|
824
981
|
expect(room.getSessionSnapshot('nonexistent')).toBeNull()
|
|
825
982
|
})
|
|
826
983
|
|
|
827
|
-
it('returns null for session not yet connected', () => {
|
|
984
|
+
it('[SR13] returns null for session not yet connected', () => {
|
|
828
985
|
const room = new TLSocketRoom({})
|
|
829
986
|
const socket = createMockSocket()
|
|
830
987
|
room.handleSocketConnect({ sessionId: 'test', socket })
|
|
831
988
|
expect(room.getSessionSnapshot('test')).toBeNull()
|
|
832
989
|
})
|
|
833
990
|
|
|
834
|
-
it('returns snapshot for connected session', () => {
|
|
991
|
+
it('[SR13] returns snapshot for connected session', () => {
|
|
835
992
|
const room = new TLSocketRoom({})
|
|
836
993
|
const socket = createMockSocket()
|
|
837
994
|
connectSession(room, 'test', socket)
|
|
@@ -845,7 +1002,7 @@ describe('Hibernation support', () => {
|
|
|
845
1002
|
expect(snapshot!.supportsStringAppend).toBe(true)
|
|
846
1003
|
})
|
|
847
1004
|
|
|
848
|
-
it('includes presence record when present', () => {
|
|
1005
|
+
it('[SR13] includes presence record when present', () => {
|
|
849
1006
|
const store = getStore()
|
|
850
1007
|
store.ensureStoreIsUsable()
|
|
851
1008
|
const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
|
|
@@ -854,7 +1011,7 @@ describe('Hibernation support', () => {
|
|
|
854
1011
|
|
|
855
1012
|
const presence = InstancePresenceRecordType.create({
|
|
856
1013
|
id: InstancePresenceRecordType.createId('p1'),
|
|
857
|
-
userId: 'user1',
|
|
1014
|
+
userId: createUserId('user1'),
|
|
858
1015
|
userName: 'User 1',
|
|
859
1016
|
currentPageId: PageRecordType.createId('page'),
|
|
860
1017
|
})
|
|
@@ -868,12 +1025,67 @@ describe('Hibernation support', () => {
|
|
|
868
1025
|
const snapshot = room.getSessionSnapshot('test')
|
|
869
1026
|
expect(snapshot).not.toBeNull()
|
|
870
1027
|
expect(snapshot!.presenceRecord).not.toBeNull()
|
|
871
|
-
expect((snapshot!.presenceRecord as
|
|
1028
|
+
expect((snapshot!.presenceRecord as TLInstancePresence).userId).toBe(createUserId('user1'))
|
|
1029
|
+
})
|
|
1030
|
+
|
|
1031
|
+
it('[SR13] strips large presence fields from the snapshot', () => {
|
|
1032
|
+
const store = getStore()
|
|
1033
|
+
store.ensureStoreIsUsable()
|
|
1034
|
+
const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
|
|
1035
|
+
const socket = createMockSocket()
|
|
1036
|
+
connectSession(room, 'test', socket)
|
|
1037
|
+
|
|
1038
|
+
const scribble: TLScribble = {
|
|
1039
|
+
id: 'scribble-1',
|
|
1040
|
+
points: [{ x: 1, y: 2, z: 0.5 }],
|
|
1041
|
+
size: 4,
|
|
1042
|
+
color: 'accent',
|
|
1043
|
+
opacity: 0.8,
|
|
1044
|
+
state: 'active',
|
|
1045
|
+
delay: 0,
|
|
1046
|
+
shrink: 0.1,
|
|
1047
|
+
taper: false,
|
|
1048
|
+
}
|
|
1049
|
+
const presence = InstancePresenceRecordType.create({
|
|
1050
|
+
id: InstancePresenceRecordType.createId('p1'),
|
|
1051
|
+
userId: createUserId('user1'),
|
|
1052
|
+
userName: 'User 1',
|
|
1053
|
+
currentPageId: PageRecordType.createId('page'),
|
|
1054
|
+
chatMessage: 'hello there',
|
|
1055
|
+
brush: { x: 0, y: 0, w: 10, h: 10 },
|
|
1056
|
+
scribbles: [scribble],
|
|
1057
|
+
selectedShapeIds: [createShapeId('a'), createShapeId('b')],
|
|
1058
|
+
})
|
|
1059
|
+
room.handleSocketMessage(
|
|
1060
|
+
'test',
|
|
1061
|
+
JSON.stringify({
|
|
1062
|
+
type: 'push',
|
|
1063
|
+
clientClock: 1,
|
|
1064
|
+
presence: [RecordOpType.Put, presence],
|
|
1065
|
+
})
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
// the snapshot's presence record has the large fields cleared
|
|
1069
|
+
const snapshot = room.getSessionSnapshot('test')!
|
|
1070
|
+
expect(snapshot.presenceRecord).toMatchObject({
|
|
1071
|
+
userId: createUserId('user1'),
|
|
1072
|
+
scribbles: [],
|
|
1073
|
+
chatMessage: '',
|
|
1074
|
+
selectedShapeIds: [],
|
|
1075
|
+
brush: null,
|
|
1076
|
+
})
|
|
1077
|
+
|
|
1078
|
+
// while the live presence record in the room keeps them
|
|
1079
|
+
const live = Object.values(room.getPresenceRecords())[0] as TLInstancePresence
|
|
1080
|
+
expect(live.chatMessage).toBe('hello there')
|
|
1081
|
+
expect(live.scribbles).toHaveLength(1)
|
|
1082
|
+
expect(live.selectedShapeIds).toHaveLength(2)
|
|
1083
|
+
expect(live.brush).toEqual({ x: 0, y: 0, w: 10, h: 10 })
|
|
872
1084
|
})
|
|
873
1085
|
})
|
|
874
1086
|
|
|
875
1087
|
describe('handleSocketResume', () => {
|
|
876
|
-
it('creates a connected session that handles pings', () => {
|
|
1088
|
+
it('[SR14] creates a connected session that handles pings', () => {
|
|
877
1089
|
const room = new TLSocketRoom({})
|
|
878
1090
|
const socket = createMockSocket()
|
|
879
1091
|
|
|
@@ -897,7 +1109,7 @@ describe('Hibernation support', () => {
|
|
|
897
1109
|
expect(socket2.send).toHaveBeenCalledWith(JSON.stringify({ type: 'pong' }))
|
|
898
1110
|
})
|
|
899
1111
|
|
|
900
|
-
it('creates a connected session that handles pushes', () => {
|
|
1112
|
+
it('[SR14] creates a connected session that handles pushes', () => {
|
|
901
1113
|
const store = getStore()
|
|
902
1114
|
store.ensureStoreIsUsable()
|
|
903
1115
|
const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
|
|
@@ -934,7 +1146,7 @@ describe('Hibernation support', () => {
|
|
|
934
1146
|
expect((record as TLPage).name).toBe('New Page')
|
|
935
1147
|
})
|
|
936
1148
|
|
|
937
|
-
it('restores presence into the presence store', () => {
|
|
1149
|
+
it('[SR14] restores presence into the presence store', () => {
|
|
938
1150
|
const store = getStore()
|
|
939
1151
|
store.ensureStoreIsUsable()
|
|
940
1152
|
const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
|
|
@@ -944,7 +1156,7 @@ describe('Hibernation support', () => {
|
|
|
944
1156
|
// Push presence
|
|
945
1157
|
const presence = InstancePresenceRecordType.create({
|
|
946
1158
|
id: InstancePresenceRecordType.createId('p1'),
|
|
947
|
-
userId: 'user1',
|
|
1159
|
+
userId: createUserId('user1'),
|
|
948
1160
|
userName: 'User 1',
|
|
949
1161
|
currentPageId: PageRecordType.createId('page'),
|
|
950
1162
|
})
|
|
@@ -973,10 +1185,10 @@ describe('Hibernation support', () => {
|
|
|
973
1185
|
const presenceRecords = room2.getPresenceRecords()
|
|
974
1186
|
expect(Object.keys(presenceRecords)).toHaveLength(1)
|
|
975
1187
|
const restored = Object.values(presenceRecords)[0]
|
|
976
|
-
expect((restored as
|
|
1188
|
+
expect((restored as TLInstancePresence).userId).toBe(createUserId('user1'))
|
|
977
1189
|
})
|
|
978
1190
|
|
|
979
|
-
it('does not attach event listeners', () => {
|
|
1191
|
+
it('[SR14] does not attach event listeners', () => {
|
|
980
1192
|
const room = new TLSocketRoom({})
|
|
981
1193
|
const socket = createMockSocket()
|
|
982
1194
|
connectSession(room, 'test', socket)
|
|
@@ -994,7 +1206,7 @@ describe('Hibernation support', () => {
|
|
|
994
1206
|
expect(socket2.addEventListener).not.toHaveBeenCalled()
|
|
995
1207
|
})
|
|
996
1208
|
|
|
997
|
-
it('supports session metadata', () => {
|
|
1209
|
+
it('[SR14] supports session metadata', () => {
|
|
998
1210
|
const room = new TLSocketRoom<TLRecord, TestSessionMeta>({})
|
|
999
1211
|
const socket = createMockSocket()
|
|
1000
1212
|
const snapshot = {
|
|
@@ -1017,7 +1229,7 @@ describe('Hibernation support', () => {
|
|
|
1017
1229
|
expect(sessions[0].meta).toEqual({ userId: 'user1', userName: 'Alice' })
|
|
1018
1230
|
})
|
|
1019
1231
|
|
|
1020
|
-
it('handles readonly sessions', () => {
|
|
1232
|
+
it('[SR14] handles readonly sessions', () => {
|
|
1021
1233
|
const room = new TLSocketRoom({})
|
|
1022
1234
|
const socket = createMockSocket()
|
|
1023
1235
|
const snapshot = {
|
|
@@ -1037,125 +1249,8 @@ describe('Hibernation support', () => {
|
|
|
1037
1249
|
|
|
1038
1250
|
expect(room.getSessions()[0].isReadonly).toBe(true)
|
|
1039
1251
|
})
|
|
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
1252
|
|
|
1157
|
-
|
|
1158
|
-
it('removes presence when a resumed session is immediately closed', () => {
|
|
1253
|
+
it('[SR14] removes presence when a resumed session is immediately closed', () => {
|
|
1159
1254
|
vi.useFakeTimers()
|
|
1160
1255
|
try {
|
|
1161
1256
|
const store = getStore()
|
|
@@ -1167,7 +1262,7 @@ describe('Hibernation support', () => {
|
|
|
1167
1262
|
// Push presence
|
|
1168
1263
|
const presence = InstancePresenceRecordType.create({
|
|
1169
1264
|
id: InstancePresenceRecordType.createId('p1'),
|
|
1170
|
-
userId: 'user1',
|
|
1265
|
+
userId: createUserId('user1'),
|
|
1171
1266
|
userName: 'User 1',
|
|
1172
1267
|
currentPageId: PageRecordType.createId('page'),
|
|
1173
1268
|
})
|
|
@@ -1237,93 +1332,93 @@ describe('Hibernation support', () => {
|
|
|
1237
1332
|
})
|
|
1238
1333
|
})
|
|
1239
1334
|
|
|
1240
|
-
describe('
|
|
1241
|
-
it('
|
|
1335
|
+
describe('getPresenceRecords', () => {
|
|
1336
|
+
it('[SR15] getPresenceRecords correctly handles presence records', () => {
|
|
1337
|
+
const store = getStore()
|
|
1338
|
+
store.ensureStoreIsUsable()
|
|
1339
|
+
|
|
1340
|
+
const snapshot = store.getStoreSnapshot()
|
|
1242
1341
|
const room = new TLSocketRoom({
|
|
1243
|
-
|
|
1342
|
+
initialSnapshot: snapshot,
|
|
1244
1343
|
})
|
|
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
1344
|
|
|
1252
|
-
//
|
|
1253
|
-
const
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
// The timed-out socket should have been closed
|
|
1257
|
-
expect(socket.close).toHaveBeenCalled()
|
|
1345
|
+
// Create two separate sessions, each with their own presence record
|
|
1346
|
+
const sessionId1 = 'test-session-1'
|
|
1347
|
+
const sessionId2 = 'test-session-2'
|
|
1258
1348
|
|
|
1259
|
-
room
|
|
1260
|
-
|
|
1349
|
+
connectSession(room, sessionId1, createMockSocket())
|
|
1350
|
+
connectSession(room, sessionId2, createMockSocket())
|
|
1261
1351
|
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
connectSession(room, 'test', socket)
|
|
1352
|
+
// Create presence records for each session
|
|
1353
|
+
const presence1 = InstancePresenceRecordType.create({
|
|
1354
|
+
id: InstancePresenceRecordType.createId('presence1'),
|
|
1355
|
+
userId: createUserId('user1'),
|
|
1356
|
+
userName: 'User 1',
|
|
1357
|
+
currentPageId: PageRecordType.createId('page'),
|
|
1358
|
+
})
|
|
1270
1359
|
|
|
1271
|
-
const
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1360
|
+
const presence2 = InstancePresenceRecordType.create({
|
|
1361
|
+
id: InstancePresenceRecordType.createId('presence2'),
|
|
1362
|
+
userId: createUserId('user2'),
|
|
1363
|
+
userName: 'User 2',
|
|
1364
|
+
currentPageId: PageRecordType.createId('page'),
|
|
1276
1365
|
})
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1366
|
+
|
|
1367
|
+
// Send push messages with presence data for each session
|
|
1368
|
+
const pushRequest1 = {
|
|
1369
|
+
type: 'push' as const,
|
|
1370
|
+
clientClock: 1,
|
|
1371
|
+
presence: [RecordOpType.Put, presence1] as [typeof RecordOpType.Put, typeof presence1],
|
|
1281
1372
|
}
|
|
1282
|
-
|
|
1283
|
-
syncRoom.pruneSessions = wrappedPrune as typeof originalPrune
|
|
1373
|
+
room.handleSocketMessage(sessionId1, JSON.stringify(pushRequest1))
|
|
1284
1374
|
|
|
1285
|
-
|
|
1375
|
+
const pushRequest2 = {
|
|
1376
|
+
type: 'push' as const,
|
|
1377
|
+
clientClock: 2,
|
|
1378
|
+
presence: [RecordOpType.Put, presence2] as [typeof RecordOpType.Put, typeof presence2],
|
|
1379
|
+
}
|
|
1380
|
+
room.handleSocketMessage(sessionId2, JSON.stringify(pushRequest2))
|
|
1286
1381
|
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
room.close()
|
|
1290
|
-
})
|
|
1382
|
+
// Get presence records
|
|
1383
|
+
const presenceRecords = room.getPresenceRecords()
|
|
1291
1384
|
|
|
1292
|
-
|
|
1293
|
-
|
|
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)
|
|
1385
|
+
// Should return the presence records that were added through the protocol
|
|
1386
|
+
expect(Object.keys(presenceRecords)).toHaveLength(2)
|
|
1300
1387
|
|
|
1301
|
-
|
|
1302
|
-
|
|
1388
|
+
// Find the presence records by their user data since the IDs are generated by the room
|
|
1389
|
+
const user1Presence = Object.values(presenceRecords).find(
|
|
1390
|
+
(p) => (p as TLInstancePresence).userId === createUserId('user1')
|
|
1391
|
+
)
|
|
1392
|
+
const user2Presence = Object.values(presenceRecords).find(
|
|
1393
|
+
(p) => (p as TLInstancePresence).userId === createUserId('user2')
|
|
1394
|
+
)
|
|
1303
1395
|
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
expect(onSessionRemoved).not.toHaveBeenCalled()
|
|
1396
|
+
expect(user1Presence).toBeDefined()
|
|
1397
|
+
expect(user2Presence).toBeDefined()
|
|
1307
1398
|
|
|
1308
|
-
|
|
1309
|
-
|
|
1399
|
+
// Verify the records are properly structured
|
|
1400
|
+
expect(user1Presence).toMatchObject({
|
|
1401
|
+
typeName: 'instance_presence',
|
|
1402
|
+
userId: createUserId('user1'),
|
|
1403
|
+
userName: 'User 1',
|
|
1404
|
+
})
|
|
1310
1405
|
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
)
|
|
1406
|
+
expect(user2Presence).toMatchObject({
|
|
1407
|
+
typeName: 'instance_presence',
|
|
1408
|
+
userId: createUserId('user2'),
|
|
1409
|
+
userName: 'User 2',
|
|
1410
|
+
})
|
|
1317
1411
|
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1412
|
+
// Should not include document records
|
|
1413
|
+
const documentRecordIds = Object.keys(presenceRecords).filter(
|
|
1414
|
+
(id) => presenceRecords[id].typeName === 'document'
|
|
1415
|
+
)
|
|
1416
|
+
expect(documentRecordIds).toHaveLength(0)
|
|
1322
1417
|
})
|
|
1323
1418
|
})
|
|
1324
1419
|
})
|
|
1325
1420
|
|
|
1326
|
-
describe('
|
|
1421
|
+
describe('29. updateStore (US)', () => {
|
|
1327
1422
|
let storage = new InMemorySyncStorage<TLRecord>({ snapshot: DEFAULT_INITIAL_SNAPSHOT })
|
|
1328
1423
|
|
|
1329
1424
|
let room = new TLSocketRoom<TLRecord, undefined>({ storage })
|
|
@@ -1337,23 +1432,28 @@ describe('TLSocketRoom.updateStore', () => {
|
|
|
1337
1432
|
init()
|
|
1338
1433
|
})
|
|
1339
1434
|
|
|
1340
|
-
|
|
1341
|
-
const
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
store.put(
|
|
1435
|
+
it('[US1] isolates concurrent transactions from one another', async () => {
|
|
1436
|
+
const page3 = PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey })
|
|
1437
|
+
const didDelete = promiseWithResolve()
|
|
1438
|
+
const didPut = promiseWithResolve()
|
|
1439
|
+
const doneA = room.updateStore(async (store) => {
|
|
1440
|
+
store.put(page3)
|
|
1441
|
+
didPut.resolve(null)
|
|
1442
|
+
await didDelete
|
|
1443
|
+
expect(store.get(page3.id)).toBeTruthy()
|
|
1346
1444
|
})
|
|
1347
|
-
const
|
|
1348
|
-
|
|
1349
|
-
.
|
|
1350
|
-
|
|
1351
|
-
|
|
1445
|
+
const doneB = room.updateStore(async (store) => {
|
|
1446
|
+
await didPut
|
|
1447
|
+
expect(store.get(page3.id)).toBeFalsy()
|
|
1448
|
+
store.delete(page3.id)
|
|
1449
|
+
didDelete.resolve(null)
|
|
1450
|
+
})
|
|
1451
|
+
await Promise.all([doneA, doneB])
|
|
1352
1452
|
})
|
|
1353
1453
|
|
|
1354
|
-
|
|
1454
|
+
it('[US1] does not update unless you call put', async () => {
|
|
1355
1455
|
const clock = storage.getClock()
|
|
1356
|
-
room.updateStore((store) => {
|
|
1456
|
+
await room.updateStore((store) => {
|
|
1357
1457
|
const document = store.get('document:document') as TLDocument
|
|
1358
1458
|
document.name = 'My lovely document'
|
|
1359
1459
|
})
|
|
@@ -1364,46 +1464,153 @@ describe('TLSocketRoom.updateStore', () => {
|
|
|
1364
1464
|
expect(clock).toBe(storage.getClock())
|
|
1365
1465
|
})
|
|
1366
1466
|
|
|
1367
|
-
|
|
1467
|
+
it('[US1] does not commit mutations to records gotten via get unless you put', async () => {
|
|
1468
|
+
const page3 = PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey })
|
|
1469
|
+
let page4 = PageRecordType.create({ name: 'page 4', index: 'a1' as IndexKey })
|
|
1470
|
+
let page1
|
|
1471
|
+
await room.updateStore((store) => {
|
|
1472
|
+
page1 = store.get('page:page') as TLPage
|
|
1473
|
+
page1.name = 'my lovely page 1'
|
|
1474
|
+
store.put(page3)
|
|
1475
|
+
page3.name = 'my lovely page 3'
|
|
1476
|
+
store.put(page4)
|
|
1477
|
+
page4 = store.get(page4.id) as TLPage
|
|
1478
|
+
page4.name = 'my lovely page 4'
|
|
1479
|
+
})
|
|
1480
|
+
|
|
1481
|
+
const getPageNames = () =>
|
|
1482
|
+
room
|
|
1483
|
+
.getCurrentSnapshot()
|
|
1484
|
+
.documents.filter((r) => r.state.typeName === 'page')
|
|
1485
|
+
.map((r) => (r.state as any).name)
|
|
1486
|
+
.sort()
|
|
1487
|
+
|
|
1488
|
+
expect(getPageNames()).toEqual(['Page 1', 'page 3', 'page 4'])
|
|
1489
|
+
|
|
1490
|
+
await room.updateStore((store) => {
|
|
1491
|
+
store.put(page1!)
|
|
1492
|
+
store.put(page3)
|
|
1493
|
+
store.put(page4)
|
|
1494
|
+
})
|
|
1495
|
+
|
|
1496
|
+
expect(getPageNames()).toEqual(['my lovely page 1', 'my lovely page 3', 'my lovely page 4'])
|
|
1497
|
+
})
|
|
1498
|
+
|
|
1499
|
+
it('[US1] returns null from get for records deleted in the same transaction', async () => {
|
|
1500
|
+
await room.updateStore((store) => {
|
|
1501
|
+
expect(store.get('page:page')).toBeTruthy()
|
|
1502
|
+
store.delete('page:page')
|
|
1503
|
+
expect(store.get('page:page')).toBe(null)
|
|
1504
|
+
})
|
|
1505
|
+
})
|
|
1506
|
+
|
|
1507
|
+
it('[US1] returns null from get for records that never existed', async () => {
|
|
1508
|
+
await room.updateStore((store) => {
|
|
1509
|
+
expect(store.get('page:page_3')).toBe(null)
|
|
1510
|
+
})
|
|
1511
|
+
})
|
|
1512
|
+
|
|
1513
|
+
it('[US2] fails if you try to add bad data', async () => {
|
|
1514
|
+
await expect(
|
|
1515
|
+
room.updateStore((store) => {
|
|
1516
|
+
const page = store.get('page:page') as TLPage
|
|
1517
|
+
page.index = 34 as any
|
|
1518
|
+
store.put(page)
|
|
1519
|
+
})
|
|
1520
|
+
).rejects.toMatchInlineSnapshot(
|
|
1521
|
+
`[ValidationError: At page.index: Expected string, got a number]`
|
|
1522
|
+
)
|
|
1523
|
+
})
|
|
1524
|
+
|
|
1525
|
+
it('[US2] fails if you put a record with an unknown type', async () => {
|
|
1526
|
+
await expect(
|
|
1527
|
+
room.updateStore((store) => {
|
|
1528
|
+
store.put({ id: 'whatever:x', typeName: 'whatever' } as any)
|
|
1529
|
+
})
|
|
1530
|
+
).rejects.toThrow('Missing definition for record type whatever')
|
|
1531
|
+
})
|
|
1532
|
+
|
|
1533
|
+
it('[US2] cancels a pending put when a later put is deep-equal to the snapshot state', async () => {
|
|
1534
|
+
const clock = storage.getClock()
|
|
1368
1535
|
const onChange = vi.fn()
|
|
1369
1536
|
storage.onChange(onChange)
|
|
1537
|
+
|
|
1370
1538
|
await room.updateStore((store) => {
|
|
1371
|
-
const
|
|
1372
|
-
|
|
1373
|
-
|
|
1539
|
+
const original = store.get('page:page') as TLPage
|
|
1540
|
+
const page = store.get('page:page') as TLPage
|
|
1541
|
+
page.name = 'changed'
|
|
1542
|
+
store.put(page)
|
|
1543
|
+
// putting the original snapshot-equal state cancels the pending put
|
|
1544
|
+
store.put(original)
|
|
1374
1545
|
})
|
|
1375
|
-
|
|
1546
|
+
await flushAsync()
|
|
1547
|
+
|
|
1548
|
+
expect(storage.getClock()).toBe(clock)
|
|
1549
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
1550
|
+
const pageRecord = storage.getSnapshot().documents.find((r) => r.state.id === 'page:page')
|
|
1551
|
+
expect((pageRecord!.state as TLPage).name).toBe('Page 1')
|
|
1376
1552
|
})
|
|
1377
1553
|
|
|
1378
|
-
|
|
1554
|
+
it('[US2] clears a pending delete when the same id is put again (resurrection)', async () => {
|
|
1555
|
+
const clock = storage.getClock()
|
|
1379
1556
|
const onChange = vi.fn()
|
|
1380
1557
|
storage.onChange(onChange)
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1558
|
+
|
|
1559
|
+
await room.updateStore((store) => {
|
|
1560
|
+
const original = store.get('page:page') as TLPage
|
|
1561
|
+
store.delete('page:page')
|
|
1562
|
+
expect(store.get('page:page')).toBe(null)
|
|
1563
|
+
// a put deep-equal to the snapshot state after a delete of the same id
|
|
1564
|
+
// resurrects the record: the pending delete is cleared
|
|
1565
|
+
store.put(original)
|
|
1566
|
+
expect(store.get('page:page')).toEqual(original)
|
|
1384
1567
|
})
|
|
1568
|
+
await flushAsync()
|
|
1569
|
+
|
|
1570
|
+
// the record survived, and since nothing effectively changed, the clock
|
|
1571
|
+
// did not advance and onChange did not fire
|
|
1572
|
+
expect(storage.getSnapshot().documents.find((r) => r.state.id === 'page:page')).toBeTruthy()
|
|
1573
|
+
expect(storage.getClock()).toBe(clock)
|
|
1385
1574
|
expect(onChange).not.toHaveBeenCalled()
|
|
1386
1575
|
})
|
|
1387
1576
|
|
|
1388
|
-
|
|
1389
|
-
const id = PageRecordType.createId('page_3')
|
|
1577
|
+
it('[US3] allows deleting records by id', async () => {
|
|
1390
1578
|
await room.updateStore((store) => {
|
|
1391
|
-
|
|
1392
|
-
store.
|
|
1579
|
+
expect(store.get('page:page')).toBeTruthy()
|
|
1580
|
+
store.delete('page:page')
|
|
1393
1581
|
})
|
|
1394
1582
|
|
|
1395
|
-
expect(storage.getSnapshot().documents.find((r) => r.state.id ===
|
|
1583
|
+
expect(storage.getSnapshot().documents.find((r) => r.state.id === 'page:page')).toBeFalsy()
|
|
1584
|
+
})
|
|
1585
|
+
|
|
1586
|
+
it('[US3] allows deleting records by passing the record itself', async () => {
|
|
1587
|
+
await room.updateStore((store) => {
|
|
1588
|
+
const page = store.get('page:page') as TLPage
|
|
1589
|
+
store.delete(page)
|
|
1590
|
+
})
|
|
1591
|
+
|
|
1592
|
+
expect(storage.getSnapshot().documents.find((r) => r.state.id === 'page:page')).toBeFalsy()
|
|
1396
1593
|
})
|
|
1397
1594
|
|
|
1398
|
-
|
|
1595
|
+
it('[US3] cancels a pending put on delete and only records deletes for snapshot records', async () => {
|
|
1596
|
+
const clock = storage.getClock()
|
|
1597
|
+
const page3 = PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey })
|
|
1598
|
+
|
|
1399
1599
|
await room.updateStore((store) => {
|
|
1400
|
-
store.
|
|
1600
|
+
store.put(page3)
|
|
1601
|
+
// deleting cancels the pending put; since page3 is not in the snapshot,
|
|
1602
|
+
// no delete is recorded either
|
|
1603
|
+
store.delete(page3.id)
|
|
1604
|
+
// deleting something that never existed records nothing
|
|
1605
|
+
store.delete('page:never_existed')
|
|
1401
1606
|
})
|
|
1402
1607
|
|
|
1403
|
-
expect(storage.getSnapshot().documents.find((r) => r.state.id ===
|
|
1608
|
+
expect(storage.getSnapshot().documents.find((r) => r.state.id === page3.id)).toBeFalsy()
|
|
1609
|
+
// nothing was committed, so the clock did not advance
|
|
1610
|
+
expect(storage.getClock()).toBe(clock)
|
|
1404
1611
|
})
|
|
1405
1612
|
|
|
1406
|
-
|
|
1613
|
+
it('[US4] returns all records if you ask for them', async () => {
|
|
1407
1614
|
let allRecords
|
|
1408
1615
|
await room.updateStore((store) => {
|
|
1409
1616
|
allRecords = store.getAll()
|
|
@@ -1432,108 +1639,116 @@ describe('TLSocketRoom.updateStore', () => {
|
|
|
1432
1639
|
)
|
|
1433
1640
|
})
|
|
1434
1641
|
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
await room.updateStore((
|
|
1438
|
-
|
|
1642
|
+
it('[US5] allows updating records', async () => {
|
|
1643
|
+
const clock = storage.getClock()
|
|
1644
|
+
await room.updateStore((store) => {
|
|
1645
|
+
const document = store.get('document:document') as TLDocument
|
|
1646
|
+
document.name = 'My lovely document'
|
|
1647
|
+
store.put(document)
|
|
1439
1648
|
})
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
expect((
|
|
1444
|
-
|
|
1445
|
-
}).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
|
|
1446
|
-
expect(() => {
|
|
1447
|
-
store!.getAll()
|
|
1448
|
-
}).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
|
|
1449
|
-
expect(() => {
|
|
1450
|
-
store!.get('page:page_2')
|
|
1451
|
-
}).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
|
|
1649
|
+
const docRecord = storage
|
|
1650
|
+
.getSnapshot()
|
|
1651
|
+
.documents.find((r) => r.state.id === 'document:document') as any
|
|
1652
|
+
expect(docRecord.state.name).toBe('My lovely document')
|
|
1653
|
+
expect(clock).toBeLessThan(storage.getClock())
|
|
1452
1654
|
})
|
|
1453
1655
|
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
await
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
).rejects.toMatchInlineSnapshot(`[Error: Cannot update store on a closed room]`)
|
|
1461
|
-
})
|
|
1656
|
+
it('[US5] allows adding new records', async () => {
|
|
1657
|
+
const id = PageRecordType.createId('page_3')
|
|
1658
|
+
await room.updateStore((store) => {
|
|
1659
|
+
const page = PageRecordType.create({ id, name: 'page 3', index: 'a0' as IndexKey })
|
|
1660
|
+
store.put(page)
|
|
1661
|
+
})
|
|
1462
1662
|
|
|
1463
|
-
|
|
1464
|
-
await expect(
|
|
1465
|
-
room.updateStore((store) => {
|
|
1466
|
-
const page = store.get('page:page') as TLPage
|
|
1467
|
-
page.index = 34 as any
|
|
1468
|
-
store.put(page)
|
|
1469
|
-
})
|
|
1470
|
-
).rejects.toMatchInlineSnapshot(
|
|
1471
|
-
`[ValidationError: At page.index: Expected string, got a number]`
|
|
1472
|
-
)
|
|
1663
|
+
expect(storage.getSnapshot().documents.find((r) => r.state.id === id)?.state).toBeTruthy()
|
|
1473
1664
|
})
|
|
1474
1665
|
|
|
1475
|
-
|
|
1476
|
-
const
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
await didPut
|
|
1487
|
-
expect(store.get(page3.id)).toBeFalsy()
|
|
1488
|
-
store.delete(page3.id)
|
|
1489
|
-
didDelete.resolve(null)
|
|
1666
|
+
it('[US5] supports async updaters', async () => {
|
|
1667
|
+
const initialClock = storage.getClock()
|
|
1668
|
+
|
|
1669
|
+
await room.updateStore(async (store) => {
|
|
1670
|
+
await flushAsync()
|
|
1671
|
+
const page = PageRecordType.create({
|
|
1672
|
+
id: PageRecordType.createId('new-page'),
|
|
1673
|
+
name: 'New Page',
|
|
1674
|
+
index: ZERO_INDEX_KEY,
|
|
1675
|
+
})
|
|
1676
|
+
store.put(page)
|
|
1490
1677
|
})
|
|
1491
|
-
|
|
1678
|
+
|
|
1679
|
+
expect(storage.getClock()).toBeGreaterThan(initialClock)
|
|
1492
1680
|
})
|
|
1493
1681
|
|
|
1494
|
-
|
|
1682
|
+
it('[US5] commits all changes in a single transaction, advancing the clock at most once', async () => {
|
|
1683
|
+
const clock = storage.getClock()
|
|
1684
|
+
|
|
1495
1685
|
await room.updateStore((store) => {
|
|
1496
|
-
|
|
1686
|
+
store.put(PageRecordType.create({ name: 'page 3', index: 'a3' as IndexKey }))
|
|
1687
|
+
store.put(PageRecordType.create({ name: 'page 4', index: 'a4' as IndexKey }))
|
|
1497
1688
|
store.delete('page:page')
|
|
1498
|
-
expect(store.get('page:page')).toBe(null)
|
|
1499
1689
|
})
|
|
1690
|
+
|
|
1691
|
+
expect(storage.getClock()).toBe(clock + 1)
|
|
1500
1692
|
})
|
|
1501
1693
|
|
|
1502
|
-
|
|
1694
|
+
it('[US5] triggers onChange events on the storage if something changed', async () => {
|
|
1695
|
+
const onChange = vi.fn()
|
|
1696
|
+
storage.onChange(onChange)
|
|
1503
1697
|
await room.updateStore((store) => {
|
|
1504
|
-
|
|
1698
|
+
const document = store.get('document:document') as TLDocument
|
|
1699
|
+
document.name = 'My lovely document'
|
|
1700
|
+
store.put(document)
|
|
1505
1701
|
})
|
|
1702
|
+
await flushAsync()
|
|
1703
|
+
expect(onChange).toHaveBeenCalled()
|
|
1506
1704
|
})
|
|
1507
1705
|
|
|
1508
|
-
|
|
1509
|
-
const
|
|
1510
|
-
|
|
1511
|
-
let page1
|
|
1706
|
+
it('[US5] does not trigger onChange events if nothing is committed', async () => {
|
|
1707
|
+
const onChange = vi.fn()
|
|
1708
|
+
storage.onChange(onChange)
|
|
1512
1709
|
await room.updateStore((store) => {
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
store.put(page3)
|
|
1516
|
-
page3.name = 'my lovely page 3'
|
|
1517
|
-
store.put(page4)
|
|
1518
|
-
page4 = store.get(page4.id) as TLPage
|
|
1519
|
-
page4.name = 'my lovely page 4'
|
|
1710
|
+
const document = store.get('document:document') as TLDocument
|
|
1711
|
+
document.name = 'My lovely document'
|
|
1520
1712
|
})
|
|
1713
|
+
await flushAsync()
|
|
1714
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
1715
|
+
})
|
|
1521
1716
|
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
expect(
|
|
1717
|
+
it('[US5] propagates updater errors and commits nothing', async () => {
|
|
1718
|
+
const clock = storage.getClock()
|
|
1719
|
+
await expect(async () => {
|
|
1720
|
+
await room.updateStore(() => {
|
|
1721
|
+
throw new Error('Test error')
|
|
1722
|
+
})
|
|
1723
|
+
}).rejects.toThrow('Test error')
|
|
1724
|
+
expect(storage.getClock()).toBe(clock)
|
|
1725
|
+
})
|
|
1530
1726
|
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
store
|
|
1727
|
+
it('[US6] makes all context methods fail after the updater completes', async () => {
|
|
1728
|
+
let store
|
|
1729
|
+
await room.updateStore((s) => {
|
|
1730
|
+
store = s
|
|
1535
1731
|
})
|
|
1732
|
+
expect(() => {
|
|
1733
|
+
store!.put(PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey }))
|
|
1734
|
+
}).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
|
|
1735
|
+
expect(() => {
|
|
1736
|
+
store!.delete('page:page_2')
|
|
1737
|
+
}).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
|
|
1738
|
+
expect(() => {
|
|
1739
|
+
store!.getAll()
|
|
1740
|
+
}).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
|
|
1741
|
+
expect(() => {
|
|
1742
|
+
store!.get('page:page_2')
|
|
1743
|
+
}).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
|
|
1744
|
+
})
|
|
1536
1745
|
|
|
1537
|
-
|
|
1746
|
+
it('[US6] rejects if the room is closed', async () => {
|
|
1747
|
+
room.close()
|
|
1748
|
+
await expect(
|
|
1749
|
+
room.updateStore(() => {
|
|
1750
|
+
// noop
|
|
1751
|
+
})
|
|
1752
|
+
).rejects.toMatchInlineSnapshot(`[Error: Cannot update store on a closed room]`)
|
|
1538
1753
|
})
|
|
1539
1754
|
})
|