@tldraw/sync-core 5.2.0-canary.4a316fdfb2bb → 5.2.0-canary.4e00299c3e28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/DOCS.md +662 -0
  2. package/README.md +9 -1
  3. package/dist-cjs/index.js +1 -1
  4. package/dist-cjs/lib/TLSocketRoom.js +4 -1
  5. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  6. package/dist-cjs/lib/TLSyncClient.js +2 -3
  7. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  8. package/dist-cjs/lib/TLSyncRoom.js +3 -2
  9. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  10. package/dist-esm/index.mjs +1 -1
  11. package/dist-esm/lib/TLSocketRoom.mjs +4 -1
  12. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  13. package/dist-esm/lib/TLSyncClient.mjs +2 -4
  14. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  15. package/dist-esm/lib/TLSyncRoom.mjs +3 -2
  16. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  17. package/package.json +9 -8
  18. package/src/lib/ClientWebSocketAdapter.test.ts +486 -508
  19. package/src/lib/DurableObjectSqliteSyncWrapper.test.ts +102 -0
  20. package/src/lib/InMemorySyncStorage.test.ts +294 -0
  21. package/src/lib/MicrotaskNotifier.test.ts +79 -254
  22. package/src/lib/{NodeSqliteSyncWrapper.test.ts → NodeSqliteWrapper.test.ts} +29 -25
  23. package/src/lib/SQLiteSyncStorage.test.ts +239 -0
  24. package/src/lib/ServerSocketAdapter.test.ts +60 -199
  25. package/src/lib/TLSocketRoom.ts +6 -1
  26. package/src/lib/TLSyncClient.test.ts +1127 -846
  27. package/src/lib/TLSyncClient.ts +4 -4
  28. package/src/lib/TLSyncRoom.ts +5 -2
  29. package/src/lib/TLSyncStorage.test.ts +225 -0
  30. package/src/lib/chunk.test.ts +372 -0
  31. package/src/lib/diff.test.ts +885 -0
  32. package/src/lib/interval.test.ts +43 -0
  33. package/src/lib/protocol.test.ts +43 -0
  34. package/src/lib/recordDiff.test.ts +159 -0
  35. package/src/test/TLSocketRoom.test.ts +1109 -894
  36. package/src/test/TLSyncRoom.test.ts +1735 -893
  37. package/src/test/storageContractSuite.ts +618 -0
  38. package/src/test/upgradeDowngrade.test.ts +137 -37
  39. package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
  40. package/src/lib/RoomSession.test.ts +0 -101
  41. package/src/lib/computeTombstonePruning.test.ts +0 -352
  42. package/src/lib/server-types.test.ts +0 -44
  43. package/src/test/InMemorySyncStorage.test.ts +0 -1780
  44. package/src/test/SQLiteSyncStorage.test.ts +0 -1485
  45. package/src/test/chunk.test.ts +0 -385
  46. package/src/test/customMessages.test.ts +0 -36
  47. package/src/test/diff.test.ts +0 -784
  48. package/src/test/presenceMode.test.ts +0 -149
  49. 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
- it('allows being initialized with a non-empty TLStoreSnapshot', () => {
52
- const store = getStore()
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: store.getStoreSnapshot(),
81
+ initialSnapshot: snapshot,
311
82
  })
312
-
313
- const oldClock = room.getCurrentDocumentClock()
314
- expect(oldClock).toBe(0)
315
-
316
- // Add a new page to make the snapshot different
317
- store.put([PageRecordType.create({ name: 'New Page', index: 'a2' as IndexKey })])
318
- const newSnapshot = store.getStoreSnapshot()
319
- room.loadSnapshot(newSnapshot)
320
-
321
- expect(room.getCurrentDocumentClock()).toBe(oldClock + 1)
322
- })
323
-
324
- it('does not increment documentClock when loading identical snapshot', () => {
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
- const room = new TLSocketRoom({
328
- initialSnapshot: store.getStoreSnapshot(),
329
- })
330
-
331
- const oldClock = room.getCurrentDocumentClock()
332
-
333
- // Load the same snapshot again
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('preserves existing tombstones with original clock values', async () => {
341
- // Create a room with initial state
342
- const store = getStore()
343
- store.ensureStoreIsUsable()
344
- const testPageId = PageRecordType.createId('test_page')
345
- store.put([
346
- PageRecordType.create({ id: testPageId, name: 'Test Page', index: ZERO_INDEX_KEY }),
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
- const room = new TLSocketRoom({
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
- // Clock should not change since we loaded the same snapshot
369
- expect(room.getCurrentSnapshot().documentClock).toBe(deletionClock)
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
- it('preserves schema when resetting room state', () => {
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
- const room = new TLSocketRoom({
376
- initialSnapshot: store.getStoreSnapshot(),
377
- })
160
+ let called = 0
378
161
 
379
- const originalSchema = room.getCurrentSnapshot().schema
162
+ const room = new TLSocketRoom({ onDataChange: () => ++called })
163
+ expect(called).toEqual(0)
380
164
 
381
- // Reset with a new snapshot
382
- const newSnapshot = store.getStoreSnapshot()
383
- room.loadSnapshot(newSnapshot)
165
+ await addPage(room)
166
+ expect(called).toEqual(1)
384
167
 
385
- const result = room.getCurrentSnapshot()
386
- expect(result.schema).toEqual(originalSchema)
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('Constructor options', () => {
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('uses custom logger when provided', () => {
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('Session management', () => {
431
- let room: TLSocketRoom
432
- let onSessionRemoved: ReturnType<typeof vi.fn>
433
-
434
- beforeEach(() => {
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
- const connectRequest = {
450
- type: 'connect' as const,
451
- connectRequestId: `connect-${index}`,
452
- lastServerClock: 0,
453
- protocolVersion: getTlsyncProtocolVersion(),
454
- schema: createTLSchema().serialize(),
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
- describe('Message handling', () => {
489
- let room: TLSocketRoom
490
- let socket: WebSocketMinimal
491
- let onBeforeSendMessage: ReturnType<typeof vi.fn>
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
- beforeEach(() => {
495
- onBeforeSendMessage = vi.fn()
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('calls onBeforeSendMessage for outgoing messages', () => {
505
- room.handleSocketConnect({
506
- sessionId: 'test-session',
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
- room.handleSocketMessage('test-session', JSON.stringify(connectRequest))
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
- room.handleSocketConnect({
528
- sessionId: 'test-session',
529
- socket,
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('WebSocket error handling', () => {
550
- let room: TLSocketRoom
551
- let socket: WebSocketMinimal
552
-
553
- beforeEach(() => {
554
- room = new TLSocketRoom({})
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
- expect(room.getSessions()).toHaveLength(1)
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
- // Trigger socket error - should not throw
576
- expect(() => room.handleSocketError('test-session')).not.toThrow()
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('handles socket close correctly', () => {
580
- room.handleSocketConnect({
581
- sessionId: 'test-session',
582
- socket,
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
- const connectRequest = {
586
- type: 'connect' as const,
587
- connectRequestId: 'connect-1',
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(room.getSessions()).toHaveLength(1)
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
- // Trigger socket close - should not throw
597
- expect(() => room.handleSocketClose('test-session')).not.toThrow()
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
- describe('Custom messages', () => {
602
- let room: TLSocketRoom
603
- let socket: WebSocketMinimal
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
- beforeEach(() => {
606
- room = new TLSocketRoom({})
607
- socket = createMockSocket()
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('sends custom messages to connected sessions', () => {
611
- room.handleSocketConnect({
612
- sessionId: 'test-session',
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
- const connectRequest = {
617
- type: 'connect' as const,
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
- const customData = { type: 'notification', message: 'Hello World' }
626
- room.sendCustomMessage('test-session', customData)
413
+ // Connect a second session and send a message to trigger pruning
414
+ const socket2 = createMockSocket()
415
+ connectSession(room, 'test2', socket2)
627
416
 
628
- expect(socket.send).toHaveBeenCalledWith(JSON.stringify({ type: 'custom', data: customData }))
629
- })
417
+ // The timed-out socket should have been closed
418
+ expect(socket.close).toHaveBeenCalled()
630
419
 
631
- it('handles custom message to non-existent session gracefully', () => {
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
- describe('Session closing', () => {
640
- let room: TLSocketRoom
641
- let socket: WebSocketMinimal
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('closes session without fatal reason', () => {
649
- room.handleSocketConnect({
650
- sessionId: 'test-session',
651
- socket,
652
- })
495
+ it('[SR6] handles socket errors correctly', () => {
496
+ connectSession(room, 'test-session', socket)
653
497
 
654
- const connectRequest = {
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
- room.closeSession('test-session')
500
+ // Trigger socket error - should not throw
501
+ expect(() => room.handleSocketError('test-session')).not.toThrow()
664
502
 
665
- // Session should be removed
666
- expect(room.getSessions()).toHaveLength(0)
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('closes session with fatal reason', () => {
670
- room.handleSocketConnect({
671
- sessionId: 'test-session',
672
- socket,
673
- })
508
+ it('[SR6] handles socket close correctly', () => {
509
+ connectSession(room, 'test-session', socket)
674
510
 
675
- const connectRequest = {
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
- room.closeSession('test-session', TLSyncErrorCloseEventReason.FORBIDDEN)
513
+ // Trigger socket close - should not throw
514
+ expect(() => room.handleSocketClose('test-session')).not.toThrow()
685
515
 
686
- // Session should be removed
687
- expect(room.getSessions()).toHaveLength(0)
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('Room lifecycle', () => {
692
- it('closes room correctly', () => {
522
+ describe('session reporting', () => {
523
+ it('[SR7] handles multiple concurrent sessions', () => {
693
524
  const room = new TLSocketRoom({})
694
- const socket = createMockSocket()
525
+ const sessions = ['session1', 'session2', 'session3']
526
+ const sockets = sessions.map(() => createMockSocket())
695
527
 
696
- room.handleSocketConnect({
697
- sessionId: 'test-session',
698
- socket,
528
+ sessions.forEach((sessionId, index) => {
529
+ connectSession(room, sessionId, sockets[index])
699
530
  })
700
531
 
701
- const connectRequest = {
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
- expect(room.getSessions()).toHaveLength(1)
534
+ const sessionInfo = room.getSessions()
535
+ expect(sessionInfo).toHaveLength(3)
536
+ expect(sessionInfo.every((s) => s.isConnected)).toBe(true)
537
+ })
711
538
 
712
- // Close the room
713
- room.close()
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
- // Room should be marked as closed
716
- expect(room.isClosed()).toBe(true)
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('Store updates', () => {
721
- it('executes async store updates', async () => {
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(async (store) => {
731
- const page = PageRecordType.create({
732
- id: PageRecordType.createId('new-page'),
733
- name: 'New Page',
734
- index: ZERO_INDEX_KEY,
735
- })
736
- store.put(page)
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('handles errors in store updates', async () => {
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
- await expect(async () => {
750
- await room.updateStore(() => {
751
- throw new Error('Test error')
752
- })
753
- }).rejects.toThrow('Test error')
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('Session metadata handling', () => {
758
- it('handles sessions with metadata correctly', () => {
759
- const roomWithMeta = new TLSocketRoom<any, TestSessionMeta>({})
778
+ describe('closing rooms and sessions', () => {
779
+ it('[SR11] closes room correctly', () => {
780
+ const room = new TLSocketRoom({})
760
781
  const socket = createMockSocket()
761
- const meta: TestSessionMeta = { userId: 'user123', userName: 'Alice' }
782
+ connectSession(room, 'test-session', socket)
762
783
 
763
- roomWithMeta.handleSocketConnect({
764
- sessionId: 'meta-session',
765
- socket,
766
- meta,
767
- })
784
+ expect(room.getSessions()).toHaveLength(1)
768
785
 
769
- const connectRequest = {
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: createTLSchema().serialize(),
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
- describe('Clock operations', () => {
784
- it('increments clock after store updates', async () => {
785
- const store = getStore()
786
- store.ensureStoreIsUsable()
787
- const room = new TLSocketRoom({
788
- initialSnapshot: store.getStoreSnapshot(),
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
- const initialClock = room.getCurrentDocumentClock()
953
+ // Send first message
954
+ room.handleSocketMessage('test', JSON.stringify({ type: 'ping' }))
792
955
 
793
- await room.updateStore((store) => {
794
- store.put(
795
- PageRecordType.create({
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
- expect(room.getCurrentDocumentClock()).toBeGreaterThan(initialClock)
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 any).userId).toBe('user1')
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 any).userId).toBe('user1')
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
- describe('resume-then-close (presence cleanup)', () => {
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('on-demand session pruning', () => {
1241
- it('prunes timed-out sessions during handleSocketMessage', async () => {
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
- clientTimeout: 1,
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
- // Connect a second session and send a message to trigger pruning
1253
- const socket2 = createMockSocket()
1254
- connectSession(room, 'test2', socket2)
1255
-
1256
- // The timed-out socket should have been closed
1257
- expect(socket.close).toHaveBeenCalled()
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.close()
1260
- })
1349
+ connectSession(room, sessionId1, createMockSocket())
1350
+ connectSession(room, sessionId2, createMockSocket())
1261
1351
 
1262
- it('runs prune after handleMessage so the sender is not evicted by their own message', () => {
1263
- // If prune ran before handleMessage, an idle client's message would trigger
1264
- // prune first and cancel them before lastInteractionTime is updated. Verify
1265
- // order: handleMessage must run before pruneSessions.
1266
- const room = new TLSocketRoom({})
1267
- const syncRoom = (room as unknown as { room: TLSyncRoom<TLRecord, void> }).room
1268
- const socket = createMockSocket()
1269
- connectSession(room, 'test', socket)
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 callOrder: string[] = []
1272
- const realHandleMessage = syncRoom.handleMessage.bind(syncRoom)
1273
- vi.spyOn(syncRoom, 'handleMessage').mockImplementation((sessionId, message) => {
1274
- callOrder.push('handleMessage')
1275
- return realHandleMessage(sessionId, message)
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
- const originalPrune = syncRoom.pruneSessions
1278
- const wrappedPrune = function (this: typeof syncRoom) {
1279
- callOrder.push('prune')
1280
- return originalPrune.call(this)
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
- wrappedPrune.cancel = originalPrune.cancel?.bind(originalPrune)
1283
- syncRoom.pruneSessions = wrappedPrune as typeof originalPrune
1373
+ room.handleSocketMessage(sessionId1, JSON.stringify(pushRequest1))
1284
1374
 
1285
- room.handleSocketMessage('test', JSON.stringify({ type: 'ping' }))
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
- expect(callOrder).toEqual(['handleMessage', 'prune'])
1288
- vi.restoreAllMocks()
1289
- room.close()
1290
- })
1382
+ // Get presence records
1383
+ const presenceRecords = room.getPresenceRecords()
1291
1384
 
1292
- it('fully removes sessions after disconnect even with no further messages', () => {
1293
- vi.useFakeTimers()
1294
- try {
1295
- const onSessionRemoved = vi.fn()
1296
- const room = new TLSocketRoom({ onSessionRemoved })
1297
- const socket = createMockSocket()
1298
- connectSession(room, 'test', socket)
1299
- expect(room.getNumActiveSessions()).toBe(1)
1385
+ // Should return the presence records that were added through the protocol
1386
+ expect(Object.keys(presenceRecords)).toHaveLength(2)
1300
1387
 
1301
- // Disconnect the only client
1302
- room.handleSocketClose('test')
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
- // Session should be in AwaitingRemoval, not yet fully removed
1305
- expect(room.getNumActiveSessions()).toBe(1)
1306
- expect(onSessionRemoved).not.toHaveBeenCalled()
1396
+ expect(user1Presence).toBeDefined()
1397
+ expect(user2Presence).toBeDefined()
1307
1398
 
1308
- // Advance past SESSION_REMOVAL_WAIT_TIME (5s) + buffer (100ms) + throttle (1s)
1309
- vi.advanceTimersByTime(6200)
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
- // Session should now be fully removed via the scheduled follow-up prune
1312
- expect(room.getNumActiveSessions()).toBe(0)
1313
- expect(onSessionRemoved).toHaveBeenCalledWith(
1314
- room,
1315
- expect.objectContaining({ sessionId: 'test', numSessionsRemaining: 0 })
1316
- )
1406
+ expect(user2Presence).toMatchObject({
1407
+ typeName: 'instance_presence',
1408
+ userId: createUserId('user2'),
1409
+ userName: 'User 2',
1410
+ })
1317
1411
 
1318
- room.close()
1319
- } finally {
1320
- vi.useRealTimers()
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('TLSocketRoom.updateStore', () => {
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
- test('it allows updating records', async () => {
1341
- const clock = storage.getClock()
1342
- await room.updateStore((store) => {
1343
- const document = store.get('document:document') as TLDocument
1344
- document.name = 'My lovely document'
1345
- store.put(document)
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 docRecord = storage
1348
- .getSnapshot()
1349
- .documents.find((r) => r.state.id === 'document:document') as any
1350
- expect(docRecord.state.name).toBe('My lovely document')
1351
- expect(clock).toBeLessThan(storage.getClock())
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
- test('it does not update unless you call .set', () => {
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
- test('triggers onChange events on the storage if something changed', async () => {
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 document = store.get('document:document') as TLDocument
1372
- document.name = 'My lovely document'
1373
- store.put(document)
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
- expect(onChange).toHaveBeenCalled()
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
- test('does not trigger onChange events if the change is not committed', async () => {
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
- room.updateStore((store) => {
1382
- const document = store.get('document:document') as TLDocument
1383
- document.name = 'My lovely document'
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
- test('it allows adding new records', async () => {
1389
- const id = PageRecordType.createId('page_3')
1577
+ it('[US3] allows deleting records by id', async () => {
1390
1578
  await room.updateStore((store) => {
1391
- const page = PageRecordType.create({ id, name: 'page 3', index: 'a0' as IndexKey })
1392
- store.put(page)
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 === id)?.state).toBeTruthy()
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
- test('it allows deleting records', async () => {
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.delete('page:page_2')
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 === 'page:page_2')).toBeFalsy()
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
- test('it returns all records if you ask for them', async () => {
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
- test('all operations fail after the store is closed', async () => {
1436
- let store
1437
- await room.updateStore((s) => {
1438
- store = s
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
- expect(() => {
1441
- store!.put(PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey }))
1442
- }).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
1443
- expect(() => {
1444
- store!.delete('page:page_2')
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
- test('it fails if the room is closed', async () => {
1455
- room.close()
1456
- await expect(
1457
- room.updateStore(() => {
1458
- // noop
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
- test('it fails if you try to add bad data', async () => {
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
- test('changes in multiple transaction are isolated from one another', async () => {
1476
- const page3 = PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey })
1477
- const didDelete = promiseWithResolve()
1478
- const didPut = promiseWithResolve()
1479
- const doneA = room.updateStore(async (store) => {
1480
- store.put(page3)
1481
- didPut.resolve(null)
1482
- await didDelete
1483
- expect(store.get(page3.id)).toBeTruthy()
1484
- })
1485
- const doneB = room.updateStore(async (store) => {
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
- await Promise.all([doneA, doneB])
1678
+
1679
+ expect(storage.getClock()).toBeGreaterThan(initialClock)
1492
1680
  })
1493
1681
 
1494
- test('getting something that was deleted in the same transaction returns null', async () => {
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
- expect(store.get('page:page')).toBeTruthy()
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
- test('getting something that never existed in the first place returns null', async () => {
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
- expect(store.get('page:page_3')).toBe(null)
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
- test('mutations to shapes gotten via .get are not committed unless you .put', async () => {
1509
- const page3 = PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey })
1510
- let page4 = PageRecordType.create({ name: 'page 4', index: 'a1' as IndexKey })
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
- page1 = store.get('page:page') as TLPage
1514
- page1.name = 'my lovely page 1'
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
- const getPageNames = () =>
1523
- room
1524
- .getCurrentSnapshot()
1525
- .documents.filter((r) => r.state.typeName === 'page')
1526
- .map((r) => (r.state as any).name)
1527
- .sort()
1528
-
1529
- expect(getPageNames()).toEqual(['Page 1', 'page 3', 'page 4'])
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
- await room.updateStore((store) => {
1532
- store.put(page1!)
1533
- store.put(page3)
1534
- store.put(page4)
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
- expect(getPageNames()).toEqual(['my lovely page 1', 'my lovely page 3', 'my lovely page 4'])
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
  })