@tldraw/sync-core 5.2.0-next.e2b8d10bf10e → 5.2.0

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 (54) 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/ClientWebSocketAdapter.js +7 -1
  5. package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
  6. package/dist-cjs/lib/TLSocketRoom.js +4 -1
  7. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  8. package/dist-cjs/lib/TLSyncClient.js +2 -3
  9. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  10. package/dist-cjs/lib/TLSyncRoom.js +27 -3
  11. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  12. package/dist-esm/index.mjs +1 -1
  13. package/dist-esm/lib/ClientWebSocketAdapter.mjs +7 -1
  14. package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
  15. package/dist-esm/lib/TLSocketRoom.mjs +4 -1
  16. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  17. package/dist-esm/lib/TLSyncClient.mjs +2 -4
  18. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  19. package/dist-esm/lib/TLSyncRoom.mjs +27 -3
  20. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  21. package/package.json +12 -8
  22. package/src/lib/ClientWebSocketAdapter.test.ts +502 -505
  23. package/src/lib/ClientWebSocketAdapter.ts +7 -1
  24. package/src/lib/DurableObjectSqliteSyncWrapper.test.ts +102 -0
  25. package/src/lib/InMemorySyncStorage.test.ts +294 -0
  26. package/src/lib/MicrotaskNotifier.test.ts +79 -254
  27. package/src/lib/{NodeSqliteSyncWrapper.test.ts → NodeSqliteWrapper.test.ts} +29 -25
  28. package/src/lib/SQLiteSyncStorage.test.ts +239 -0
  29. package/src/lib/ServerSocketAdapter.test.ts +60 -199
  30. package/src/lib/TLSocketRoom.ts +6 -1
  31. package/src/lib/TLSyncClient.test.ts +1127 -846
  32. package/src/lib/TLSyncClient.ts +4 -4
  33. package/src/lib/TLSyncRoom.ts +34 -4
  34. package/src/lib/TLSyncStorage.test.ts +225 -0
  35. package/src/lib/chunk.test.ts +372 -0
  36. package/src/lib/diff.test.ts +885 -0
  37. package/src/lib/interval.test.ts +43 -0
  38. package/src/lib/protocol.test.ts +43 -0
  39. package/src/lib/recordDiff.test.ts +159 -0
  40. package/src/test/TLSocketRoom.test.ts +1109 -894
  41. package/src/test/TLSyncRoom.test.ts +1754 -870
  42. package/src/test/storageContractSuite.ts +618 -0
  43. package/src/test/upgradeDowngrade.test.ts +140 -40
  44. package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
  45. package/src/lib/RoomSession.test.ts +0 -101
  46. package/src/lib/computeTombstonePruning.test.ts +0 -352
  47. package/src/lib/server-types.test.ts +0 -44
  48. package/src/test/InMemorySyncStorage.test.ts +0 -1780
  49. package/src/test/SQLiteSyncStorage.test.ts +0 -1485
  50. package/src/test/chunk.test.ts +0 -385
  51. package/src/test/customMessages.test.ts +0 -36
  52. package/src/test/diff.test.ts +0 -784
  53. package/src/test/presenceMode.test.ts +0 -149
  54. package/src/test/validation.test.ts +0 -186
@@ -1,4 +1,17 @@
1
- import { SerializedSchemaV2 } from '@tldraw/store'
1
+ import { atom, computed } from '@tldraw/state'
2
+ import {
3
+ BaseRecord,
4
+ RecordId,
5
+ SerializedSchema,
6
+ SerializedSchemaV2,
7
+ Store,
8
+ StoreSchema,
9
+ UnknownRecord,
10
+ createMigrationIds,
11
+ createMigrationSequence,
12
+ createRecordMigrationSequence,
13
+ createRecordType,
14
+ } from '@tldraw/store'
2
15
  import {
3
16
  CameraRecordType,
4
17
  DocumentRecordType,
@@ -12,9 +25,11 @@ import {
12
25
  TLRecord,
13
26
  TLShapeId,
14
27
  createTLSchema,
28
+ createUserId,
15
29
  } from '@tldraw/tlschema'
16
30
  import { IndexKey, ZERO_INDEX_KEY, mockUniqueId, sortById } from '@tldraw/utils'
17
- import { vi } from 'vitest'
31
+ import { vi, type Mock } from 'vitest'
32
+ import { RecordOpType } from '../lib/diff'
18
33
  import { InMemorySyncStorage } from '../lib/InMemorySyncStorage'
19
34
  import {
20
35
  TLConnectRequest,
@@ -22,19 +37,52 @@ import {
22
37
  TLSocketServerSentEvent,
23
38
  getTlsyncProtocolVersion,
24
39
  } from '../lib/protocol'
25
- import { RoomSnapshot, TLRoomSocket, TLSyncRoom } from '../lib/TLSyncRoom'
40
+ import {
41
+ RoomSessionState,
42
+ SESSION_IDLE_TIMEOUT,
43
+ SESSION_REMOVAL_WAIT_TIME,
44
+ SESSION_START_WAIT_TIME,
45
+ } from '../lib/RoomSession'
46
+ import {
47
+ TLSyncClient,
48
+ TLSyncErrorCloseEventCode,
49
+ TLSyncErrorCloseEventReason,
50
+ } from '../lib/TLSyncClient'
51
+ import {
52
+ DATA_MESSAGE_DEBOUNCE_INTERVAL,
53
+ RoomSnapshot,
54
+ TLRoomSocket,
55
+ TLSyncRoom,
56
+ } from '../lib/TLSyncRoom'
57
+ import {
58
+ TLSyncStorage,
59
+ TLSyncStorageOnChangeCallbackProps,
60
+ TLSyncStorageTransaction,
61
+ TLSyncStorageTransactionCallback,
62
+ TLSyncStorageTransactionOptions,
63
+ TLSyncStorageTransactionResult,
64
+ } from '../lib/TLSyncStorage'
65
+ import { TestServer } from './TestServer'
66
+ import { TestSocketPair } from './TestSocketPair'
67
+
68
+ // Some tests drive a real TLSyncClient, which schedules its pushes on
69
+ // requestAnimationFrame. Make that synchronous so flushing is deterministic.
70
+ // @ts-expect-error
71
+ global.requestAnimationFrame = (cb: () => any) => {
72
+ cb()
73
+ }
26
74
 
27
75
  const schema = createTLSchema()
28
76
  const compareById = (a: { id: string }, b: { id: string }) => a.id.localeCompare(b.id)
29
77
 
30
- const records = [
31
- DocumentRecordType.create({ id: TLDOCUMENT_ID }),
32
- PageRecordType.create({
33
- index: ZERO_INDEX_KEY,
34
- name: 'page 2',
35
- id: PageRecordType.createId('page_2'),
36
- }),
37
- ].sort(compareById)
78
+ const documentRecord = DocumentRecordType.create({ id: TLDOCUMENT_ID })
79
+ const pageRecord = PageRecordType.create({
80
+ index: ZERO_INDEX_KEY,
81
+ name: 'page 2',
82
+ id: PageRecordType.createId('page_2'),
83
+ })
84
+
85
+ const records: TLRecord[] = [documentRecord, pageRecord].sort(compareById)
38
86
 
39
87
  const makeSnapshot = (records: TLRecord[], others: Partial<RoomSnapshot> = {}) => ({
40
88
  documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
@@ -44,7 +92,7 @@ const makeSnapshot = (records: TLRecord[], others: Partial<RoomSnapshot> = {}) =
44
92
  ...others,
45
93
  })
46
94
 
47
- // Helper to create legacy snapshots without documentClock field
95
+ // Helper to create legacy snapshots without a documentClock field
48
96
  const makeLegacySnapshot = (
49
97
  records: TLRecord[],
50
98
  others: Partial<Omit<RoomSnapshot, 'documentClock'>> = {}
@@ -54,11 +102,6 @@ const makeLegacySnapshot = (
54
102
  ...others,
55
103
  })
56
104
 
57
- beforeEach(() => {
58
- let id = 0
59
- mockUniqueId(() => `id_${id++}`)
60
- })
61
-
62
105
  const oldArrow: TLBaseShape<'arrow', Omit<TLArrowShapeProps, 'labelColor'>> = {
63
106
  typeName: 'shape',
64
107
  type: 'arrow',
@@ -91,8 +134,138 @@ const oldArrow: TLBaseShape<'arrow', Omit<TLArrowShapeProps, 'labelColor'>> = {
91
134
  meta: {},
92
135
  }
93
136
 
94
- describe('TLSyncRoom', () => {
95
- it('can be constructed with a storage', () => {
137
+ type MockSocket = TLRoomSocket<any> & {
138
+ __lastMessage: null | TLSocketServerSentEvent<any>
139
+ __messages: TLSocketServerSentEvent<any>[]
140
+ sendMessage: Mock
141
+ close: Mock
142
+ }
143
+
144
+ function makeSocket(): MockSocket {
145
+ const socket: MockSocket = {
146
+ __lastMessage: null,
147
+ __messages: [],
148
+ // cloning because the room reuses/clears message objects after sending
149
+ // (a real socket serializes the message immediately)
150
+ sendMessage: vi.fn((msg: TLSocketServerSentEvent<any>) => {
151
+ const clone = structuredClone(msg)
152
+ socket.__lastMessage = clone
153
+ socket.__messages.push(clone)
154
+ }),
155
+ close: vi.fn(() => {
156
+ socket.isOpen = false
157
+ }),
158
+ isOpen: true,
159
+ }
160
+ return socket
161
+ }
162
+
163
+ function sentMessages(socket: MockSocket): TLSocketServerSentEvent<any>[] {
164
+ return socket.__messages
165
+ }
166
+
167
+ function sentDataMessages(socket: MockSocket): any[] {
168
+ return sentMessages(socket).flatMap((msg: any) => (msg.type === 'data' ? msg.data : []))
169
+ }
170
+
171
+ function clearSocket(socket: MockSocket) {
172
+ socket.sendMessage.mockClear()
173
+ socket.__messages.length = 0
174
+ socket.__lastMessage = null
175
+ }
176
+
177
+ function combinedPatchDiff(socket: MockSocket): Record<string, unknown> {
178
+ return sentDataMessages(socket)
179
+ .filter((msg) => msg.type === 'patch')
180
+ .reduce((acc, patch) => ({ ...acc, ...patch.diff }), {})
181
+ }
182
+
183
+ const disposables: Array<() => void> = []
184
+
185
+ function makeRoom(
186
+ opts: {
187
+ snapshot?: RoomSnapshot
188
+ clientTimeout?: number
189
+ log?: { warn?: Mock; error?: Mock }
190
+ onPresenceChange?(): void
191
+ } = {}
192
+ ) {
193
+ const storage = new InMemorySyncStorage<TLRecord>({
194
+ snapshot: opts.snapshot ?? makeSnapshot(records),
195
+ })
196
+ const room = new TLSyncRoom<TLRecord, undefined>({
197
+ schema,
198
+ storage,
199
+ clientTimeout: opts.clientTimeout,
200
+ log: opts.log,
201
+ onPresenceChange: opts.onPresenceChange,
202
+ })
203
+ disposables.push(() => room.close())
204
+ return { storage, room }
205
+ }
206
+
207
+ function connectSession(
208
+ room: TLSyncRoom<any, any>,
209
+ sessionId: string,
210
+ opts: {
211
+ isReadonly?: boolean
212
+ protocolVersion?: number
213
+ lastServerClock?: number
214
+ schema?: SerializedSchema
215
+ clear?: boolean
216
+ } = {}
217
+ ): MockSocket {
218
+ const socket = makeSocket()
219
+ room.handleNewSession({
220
+ sessionId,
221
+ socket,
222
+ meta: undefined,
223
+ isReadonly: opts.isReadonly ?? false,
224
+ })
225
+ room.handleMessage(sessionId, {
226
+ type: 'connect',
227
+ connectRequestId: 'connect-' + sessionId,
228
+ lastServerClock: opts.lastServerClock ?? 0,
229
+ protocolVersion: opts.protocolVersion ?? getTlsyncProtocolVersion(),
230
+ schema: opts.schema ?? room.serializedSchema,
231
+ } satisfies TLConnectRequest)
232
+ if (opts.clear !== false) {
233
+ clearSocket(socket)
234
+ }
235
+ return socket
236
+ }
237
+
238
+ function makePage(suffix: string, name: string, index = 'a2' as IndexKey) {
239
+ return PageRecordType.create({ id: PageRecordType.createId(suffix), name, index })
240
+ }
241
+
242
+ function presencePut(userName = 'New User'): TLPushRequest<TLRecord>['presence'] {
243
+ return [
244
+ 'put',
245
+ InstancePresenceRecordType.create({
246
+ id: InstancePresenceRecordType.createId('client-chosen'),
247
+ currentPageId: pageRecord.id,
248
+ userId: createUserId('user'),
249
+ userName,
250
+ }) as any,
251
+ ]
252
+ }
253
+
254
+ beforeEach(() => {
255
+ let id = 0
256
+ mockUniqueId(() => `id_${id++}`)
257
+ })
258
+
259
+ afterEach(() => {
260
+ for (const dispose of disposables) {
261
+ dispose()
262
+ }
263
+ disposables.length = 0
264
+ vi.useRealTimers()
265
+ })
266
+
267
+ describe('22. Room construction (RC)', () => {
268
+ it('[RC3] can be constructed with a storage, leaving current data unchanged', () => {
96
269
  const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
97
270
  const _room = new TLSyncRoom<TLRecord, undefined>({
98
271
  schema,
@@ -109,7 +282,7 @@ describe('TLSyncRoom', () => {
109
282
  expect(storage.getSnapshot().documents.map((r) => r.lastChangedClock)).toEqual([0, 0])
110
283
  })
111
284
 
112
- it('migrates the snapshot if it is dealing with old data', () => {
285
+ it('[RC3] migrates the snapshot if it is dealing with old data', () => {
113
286
  const serializedSchema = schema.serialize()
114
287
  const oldSerializedSchema: SerializedSchemaV2 = {
115
288
  schemaVersion: 2,
@@ -134,7 +307,7 @@ describe('TLSyncRoom', () => {
134
307
  expect(arrow.props.labelColor).toBe('black')
135
308
  })
136
309
 
137
- it('filters out instance state records if a migration occurs, for legacy reasons', () => {
310
+ it('[RC3] filters out instance state records if a migration occurs, for legacy reasons', () => {
138
311
  const schema = createTLSchema()
139
312
  const oldSchema = structuredClone(schema.serialize())
140
313
  oldSchema.sequences['com.tldraw.shape.arrow'] = 0
@@ -168,203 +341,112 @@ describe('TLSyncRoom', () => {
168
341
  .sort(sortById)
169
342
  ).toEqual(records)
170
343
  })
171
- })
172
344
 
173
- type MockSocket = TLRoomSocket<any> & { __lastMessage: null | TLSocketServerSentEvent<any> }
174
- function makeSocket(): MockSocket {
175
- const socket: MockSocket = {
176
- __lastMessage: null,
177
- sendMessage: vi.fn((msg) => {
178
- socket.__lastMessage = msg
179
- }),
180
- close() {
181
- socket.isOpen = false
182
- },
183
- isOpen: true,
184
- }
185
- return socket
186
- }
345
+ it('[RC3] running migrations twice does not cause issues', () => {
346
+ const serializedSchema = schema.serialize()
347
+ const oldSerializedSchema: SerializedSchemaV2 = {
348
+ schemaVersion: 2,
349
+ sequences: {
350
+ ...serializedSchema.sequences,
351
+ 'com.tldraw.shape.arrow': 0,
352
+ },
353
+ }
187
354
 
188
- describe('isReadonly', () => {
189
- const sessionAId = 'sessionA'
190
- const sessionBId = 'sessionB'
191
- let storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
192
- let room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
193
- let socketA = makeSocket()
194
- let socketB = makeSocket()
195
- const getDoc = (id: string) => storage.documents.get(id)?.state
196
- function init(snapshot?: RoomSnapshot) {
197
- storage = new InMemorySyncStorage<TLRecord>({ snapshot: snapshot ?? makeSnapshot(records) })
198
- room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
199
- socketA = makeSocket()
200
- socketB = makeSocket()
201
- room.handleNewSession({
202
- sessionId: sessionAId,
203
- socket: socketA,
204
- meta: null as any,
205
- isReadonly: true,
355
+ const storage = new InMemorySyncStorage<TLRecord>({
356
+ snapshot: makeSnapshot([...records, oldArrow as any], {
357
+ schema: oldSerializedSchema,
358
+ }),
206
359
  })
207
- room.handleNewSession({
208
- sessionId: sessionBId,
209
- socket: socketB,
210
- meta: null as any,
211
- isReadonly: false,
360
+
361
+ // First migration (simulating what TLSyncRoom constructor does)
362
+ const _room1 = new TLSyncRoom<TLRecord, undefined>({
363
+ schema,
364
+ storage,
212
365
  })
213
- room.handleMessage(sessionAId, {
214
- connectRequestId: 'connectRequestId' + sessionAId,
215
- lastServerClock: 0,
216
- protocolVersion: getTlsyncProtocolVersion(),
217
- schema: room.serializedSchema,
218
- type: 'connect',
219
- } satisfies TLConnectRequest)
220
- room.handleMessage(sessionBId, {
221
- connectRequestId: 'connectRequestId' + sessionBId,
222
- lastServerClock: 0,
223
- protocolVersion: getTlsyncProtocolVersion(),
224
- schema: room.serializedSchema,
225
- type: 'connect',
226
- } satisfies TLConnectRequest)
227
- expect(room.sessions.get(sessionAId)?.state).toBe('connected')
228
- expect(room.sessions.get(sessionBId)?.state).toBe('connected')
229
- socketA.__lastMessage = null
230
- socketB.__lastMessage = null
231
- }
232
- beforeEach(() => {
233
- init()
234
- })
235
366
 
236
- it('does not allow updates from users who are marked as readonly', async () => {
237
- const push: TLPushRequest<any> = {
238
- clientClock: 0,
239
- diff: {
240
- 'page:page_3': [
241
- 'put',
242
- PageRecordType.create({
243
- id: 'page:page_3' as any,
244
- name: 'my lovely page 3',
245
- index: 'ab34' as IndexKey,
246
- }),
247
- ],
248
- },
249
- presence: undefined,
250
- type: 'push',
251
- }
367
+ // Get state after first migration
368
+ const snapshotAfterFirst = storage.getSnapshot()
369
+ const arrowAfterFirst = snapshotAfterFirst.documents.find((r) => r.state.id === oldArrow.id)
370
+ ?.state as TLArrowShape
252
371
 
253
- // sessionA is readonly
254
- room.handleMessage(sessionAId, push)
372
+ // Verify migration happened
373
+ expect(arrowAfterFirst.props.labelColor).toBe('black')
255
374
 
256
- expect(getDoc('page:page_3')).toBe(undefined)
257
- // should tell the session to discard it
258
- expect(socketA.__lastMessage).toMatchInlineSnapshot(`
259
- {
260
- "data": [
261
- {
262
- "action": "discard",
263
- "clientClock": 0,
264
- "serverClock": 0,
265
- "type": "push_result",
266
- },
267
- ],
268
- "type": "data",
269
- }
270
- `)
271
- // should not have sent anything to sessionB
272
- expect(socketB.__lastMessage).toBe(null)
375
+ // Create another room with the same storage (simulating a second migration attempt)
376
+ const _room2 = new TLSyncRoom<TLRecord, undefined>({
377
+ schema,
378
+ storage,
379
+ })
273
380
 
274
- // sessionB is not readonly
275
- room.handleMessage(sessionBId, push)
381
+ // Get state after second "migration"
382
+ const snapshotAfterSecond = storage.getSnapshot()
383
+ const arrowAfterSecond = snapshotAfterSecond.documents.find((r) => r.state.id === oldArrow.id)
384
+ ?.state as TLArrowShape
276
385
 
277
- expect(getDoc('page:page_3')).not.toBe(undefined)
386
+ // Should still have the migrated value
387
+ expect(arrowAfterSecond.props.labelColor).toBe('black')
278
388
 
279
- // should tell the session to commit it
280
- expect(socketB.__lastMessage).toMatchInlineSnapshot(`
281
- {
282
- "data": [
283
- {
284
- "action": "commit",
285
- "clientClock": 0,
286
- "serverClock": 1,
287
- "type": "push_result",
288
- },
289
- ],
290
- "type": "data",
291
- }
292
- `)
389
+ // Document count should be the same
390
+ expect(snapshotAfterSecond.documents.length).toBe(snapshotAfterFirst.documents.length)
391
+
392
+ // Schema should be up to date
393
+ expect(snapshotAfterSecond.schema).toEqual(schema.serialize())
293
394
  })
294
395
 
295
- it('still allows presence updates from readonly users', async () => {
296
- const presencePush: TLPushRequest<any> = {
297
- clientClock: 0,
298
- diff: undefined,
299
- presence: [
300
- 'put',
301
- InstancePresenceRecordType.create({
302
- id: InstancePresenceRecordType.createId('foo'),
303
- currentPageId: 'page:page_2' as any,
304
- userId: 'foo',
305
- userName: 'Jimbo',
306
- }),
307
- ],
308
- type: 'push',
309
- }
396
+ it('[RC3] already migrated data is not modified again', () => {
397
+ // Start with current schema (no migration needed)
398
+ const storage = new InMemorySyncStorage<TLRecord>({
399
+ snapshot: makeSnapshot(records, {
400
+ documentClock: 10,
401
+ }),
402
+ })
310
403
 
311
- // sessionA is readonly
312
- room.handleMessage(sessionAId, presencePush)
404
+ const clockBefore = storage.getClock()
313
405
 
314
- // commit for sessionA
315
- expect(socketA.__lastMessage).toMatchInlineSnapshot(`
316
- {
317
- "data": [
318
- {
319
- "action": "commit",
320
- "clientClock": 0,
321
- "serverClock": 0,
322
- "type": "push_result",
323
- },
324
- ],
325
- "type": "data",
326
- }
327
- `)
328
-
329
- // patch for sessionB
330
- expect(socketB.__lastMessage).toMatchInlineSnapshot(`
331
- {
332
- "data": [
333
- {
334
- "diff": {
335
- "instance_presence:id_0": [
336
- "put",
337
- {
338
- "brush": null,
339
- "camera": null,
340
- "chatMessage": "",
341
- "color": "#FF0000",
342
- "currentPageId": "page:page_2",
343
- "cursor": null,
344
- "followingUserId": null,
345
- "id": "instance_presence:id_0",
346
- "lastActivityTimestamp": null,
347
- "meta": {},
348
- "screenBounds": null,
349
- "scribbles": [],
350
- "selectedShapeIds": [],
351
- "typeName": "instance_presence",
352
- "userId": "foo",
353
- "userName": "Jimbo",
354
- },
355
- ],
356
- },
357
- "serverClock": 0,
358
- "type": "patch",
359
- },
360
- ],
361
- "type": "data",
406
+ // Create room - should not modify anything since schema is current
407
+ const _room = new TLSyncRoom<TLRecord, undefined>({
408
+ schema,
409
+ storage,
410
+ })
411
+
412
+ // Clock should not have changed since no migration was needed
413
+ expect(storage.getClock()).toBe(clockBefore)
414
+
415
+ // Documents should be unchanged
416
+ expect(storage.getSnapshot().documents.length).toBe(2)
417
+ })
418
+
419
+ it('[RC3] migration updates the schema version in storage', () => {
420
+ const serializedSchema = schema.serialize()
421
+ const oldSerializedSchema: SerializedSchemaV2 = {
422
+ schemaVersion: 2,
423
+ sequences: {
424
+ ...serializedSchema.sequences,
425
+ 'com.tldraw.shape.arrow': 0,
426
+ },
362
427
  }
363
- `)
428
+
429
+ const storage = new InMemorySyncStorage<TLRecord>({
430
+ snapshot: makeSnapshot([...records, oldArrow as any], {
431
+ schema: oldSerializedSchema,
432
+ }),
433
+ })
434
+
435
+ // Verify old schema before room creation
436
+ expect(storage.schema.get()).toEqual(oldSerializedSchema)
437
+
438
+ // Create room which triggers migration
439
+ const _room = new TLSyncRoom<TLRecord, undefined>({
440
+ schema,
441
+ storage,
442
+ })
443
+
444
+ // Schema should now be updated to current version
445
+ expect(storage.schema.get()).toEqual(schema.serialize())
364
446
  })
365
447
 
366
- describe('Backward compatibility with existing snapshots', () => {
367
- it('can load snapshot without documentClock field', () => {
448
+ describe('legacy snapshots without a documentClock field', () => {
449
+ it('[SS17] can load a snapshot without a documentClock field', () => {
368
450
  const legacySnapshot = makeLegacySnapshot(records)
369
451
 
370
452
  const storage = new InMemorySyncStorage<TLRecord>({ snapshot: legacySnapshot })
@@ -378,7 +460,7 @@ describe('isReadonly', () => {
378
460
  expect(snapshot.documentClock).toBe(0) // max lastChangedClock from documents
379
461
  })
380
462
 
381
- it('calculates documentClock correctly from documents with different lastChangedClock values', () => {
463
+ it('[IM1] calculates documentClock correctly from documents with different lastChangedClock values', () => {
382
464
  const legacySnapshot = makeLegacySnapshot(records, {
383
465
  documents: [
384
466
  { state: records[0], lastChangedClock: 5 },
@@ -393,7 +475,7 @@ describe('isReadonly', () => {
393
475
  expect(snapshot.documentClock).toBe(10) // max lastChangedClock
394
476
  })
395
477
 
396
- it('calculates documentClock correctly from tombstones', () => {
478
+ it('[IM1] calculates documentClock correctly from tombstones', () => {
397
479
  const legacySnapshot = makeLegacySnapshot(records, {
398
480
  documents: [{ state: records[0], lastChangedClock: 3 }],
399
481
  tombstones: {
@@ -409,7 +491,7 @@ describe('isReadonly', () => {
409
491
  expect(snapshot.documentClock).toBe(12) // max of document (3) and tombstones (7, 12)
410
492
  })
411
493
 
412
- it('handles empty snapshot gracefully', () => {
494
+ it('[SS17] handles an empty snapshot gracefully', () => {
413
495
  const emptyLegacySnapshot = makeLegacySnapshot([], {
414
496
  documents: [],
415
497
  tombstones: {},
@@ -422,7 +504,7 @@ describe('isReadonly', () => {
422
504
  expect(snapshot.documentClock).toBe(0) // no documents or tombstones
423
505
  })
424
506
 
425
- it('handles snapshot with only tombstones', () => {
507
+ it('[IM1] handles a snapshot with only tombstones', () => {
426
508
  const legacySnapshot = makeLegacySnapshot([], {
427
509
  documents: [],
428
510
  tombstones: {
@@ -438,7 +520,7 @@ describe('isReadonly', () => {
438
520
  expect(snapshot.documentClock).toBe(8) // max tombstone clock
439
521
  })
440
522
 
441
- it('preserves explicit documentClock when present', () => {
523
+ it('[SS17] preserves an explicit documentClock when present', () => {
442
524
  const snapshotWithDocumentClock = makeSnapshot(records, {
443
525
  documentClock: 15,
444
526
  })
@@ -450,54 +532,59 @@ describe('isReadonly', () => {
450
532
  expect(snapshot.documentClock).toBe(15) // should preserve explicit value
451
533
  })
452
534
  })
453
- })
454
535
 
455
- describe('External storage changes', () => {
456
- it('broadcasts external storage changes to connected clients', async () => {
457
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
458
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
536
+ it('[RC1] the serialized schema contains no undefined values', () => {
537
+ const { room } = makeRoom()
459
538
 
460
- const socket = makeSocket()
461
- const sessionId = 'test-session'
539
+ expect(room.serializedSchema).toEqual(JSON.parse(JSON.stringify(schema.serialize())))
462
540
 
463
- room.handleNewSession({
464
- sessionId,
465
- socket,
466
- meta: undefined,
467
- isReadonly: false,
468
- })
541
+ const hasUndefined = (value: unknown): boolean => {
542
+ if (value === undefined) return true
543
+ if (value && typeof value === 'object') {
544
+ return Object.values(value).some(hasUndefined)
545
+ }
546
+ return false
547
+ }
548
+ expect(hasUndefined(room.serializedSchema)).toBe(false)
549
+ })
469
550
 
470
- room.handleMessage(sessionId, {
471
- connectRequestId: 'connect-1',
472
- lastServerClock: 0,
473
- protocolVersion: getTlsyncProtocolVersion(),
474
- schema: room.serializedSchema,
475
- type: 'connect',
551
+ it('[RC2] a schema with more than one presence type throws at construction', () => {
552
+ const presenceA = createRecordType<any>('presenceA', {
553
+ scope: 'presence',
554
+ validator: { validate: (r) => r },
555
+ })
556
+ const presenceB = createRecordType<any>('presenceB', {
557
+ scope: 'presence',
558
+ validator: { validate: (r) => r },
476
559
  })
560
+ const badSchema = StoreSchema.create<any>({ presenceA, presenceB })
561
+ const storage = new InMemorySyncStorage<any>({
562
+ snapshot: { documents: [], clock: 0, documentClock: 0, schema: badSchema.serialize() },
563
+ })
564
+
565
+ expect(() => new TLSyncRoom<any, undefined>({ schema: badSchema, storage })).toThrow(
566
+ /exactly zero or one presence type/
567
+ )
568
+ })
477
569
 
478
- expect(room.sessions.get(sessionId)?.state).toBe('connected')
479
- socket.__lastMessage = null
570
+ it('[RC4] broadcasts external storage changes to connected clients', async () => {
571
+ const { room, storage } = makeRoom()
572
+ const socket = connectSession(room, 'test-session')
480
573
 
481
574
  // Simulate external storage change (as if another room instance modified it)
482
- const newPage = PageRecordType.create({
483
- id: PageRecordType.createId('external_page'),
484
- name: 'External Page',
485
- index: 'a2' as IndexKey,
486
- })
575
+ const newPage = makePage('external_page', 'External Page')
487
576
 
488
577
  storage.transaction((txn) => {
489
578
  txn.set(newPage.id, newPage)
490
579
  })
491
580
 
492
- // Wait for onChange microtask to fire
581
+ // Wait for onChange microtask to fire and the room to process the change
493
582
  await Promise.resolve()
494
- // Wait for the room to process the change
495
583
  await Promise.resolve()
496
584
 
497
585
  // The client should have received a patch with the new page
498
586
  expect(socket.sendMessage).toHaveBeenCalled()
499
- const calls = (socket.sendMessage as any).mock.calls
500
- const lastCall = calls[calls.length - 1][0]
587
+ const lastCall = sentMessages(socket).at(-1)! as any
501
588
 
502
589
  // Should be a data message containing a patch
503
590
  expect(lastCall.type).toBe('data')
@@ -513,56 +600,15 @@ describe('External storage changes', () => {
513
600
  )
514
601
  })
515
602
 
516
- it('does not broadcast changes from its own transactions (via internalTxnId)', async () => {
517
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
518
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
519
-
520
- const socketA = makeSocket()
521
- const socketB = makeSocket()
522
- const sessionAId = 'session-a'
523
- const sessionBId = 'session-b'
524
-
525
- // Connect two clients
526
- room.handleNewSession({
527
- sessionId: sessionAId,
528
- socket: socketA,
529
- meta: undefined,
530
- isReadonly: false,
531
- })
532
- room.handleNewSession({
533
- sessionId: sessionBId,
534
- socket: socketB,
535
- meta: undefined,
536
- isReadonly: false,
537
- })
538
-
539
- room.handleMessage(sessionAId, {
540
- connectRequestId: 'connect-a',
541
- lastServerClock: 0,
542
- protocolVersion: getTlsyncProtocolVersion(),
543
- schema: room.serializedSchema,
544
- type: 'connect',
545
- })
546
- room.handleMessage(sessionBId, {
547
- connectRequestId: 'connect-b',
548
- lastServerClock: 0,
549
- protocolVersion: getTlsyncProtocolVersion(),
550
- schema: room.serializedSchema,
551
- type: 'connect',
552
- })
553
-
554
- // Clear message history
555
- ;(socketA.sendMessage as any).mockClear()
556
- ;(socketB.sendMessage as any).mockClear()
603
+ it('[RC4] does not broadcast changes from its own transactions (via internalTxnId)', async () => {
604
+ const { room } = makeRoom()
605
+ const socketA = connectSession(room, 'session-a')
606
+ const socketB = connectSession(room, 'session-b')
557
607
 
558
608
  // Client A pushes a change through the room
559
- const newPage = PageRecordType.create({
560
- id: PageRecordType.createId('client_page'),
561
- name: 'Client Page',
562
- index: 'a2' as IndexKey,
563
- })
609
+ const newPage = makePage('client_page', 'Client Page')
564
610
 
565
- room.handleMessage(sessionAId, {
611
+ room.handleMessage('session-a', {
566
612
  type: 'push',
567
613
  clientClock: 1,
568
614
  diff: {
@@ -574,53 +620,27 @@ describe('External storage changes', () => {
574
620
  await Promise.resolve()
575
621
 
576
622
  // Client A should get push_result
577
- const clientACalls = (socketA.sendMessage as any).mock.calls
578
- expect(clientACalls.length).toBeGreaterThan(0)
579
- const lastAMessage = clientACalls[clientACalls.length - 1][0]
623
+ const clientAMessages = sentMessages(socketA)
624
+ expect(clientAMessages.length).toBeGreaterThan(0)
625
+ const lastAMessage = clientAMessages.at(-1)! as any
580
626
  expect(lastAMessage.type).toBe('data')
581
627
  expect(lastAMessage.data[0].type).toBe('push_result')
582
628
 
583
- // Client B should get exactly one patch (from the push), not a duplicate from external change detection
584
- const clientBCalls = (socketB.sendMessage as any).mock.calls
585
- expect(clientBCalls.length).toBe(1)
586
- expect(clientBCalls[0][0].type).toBe('data')
587
- expect(clientBCalls[0][0].data[0].type).toBe('patch')
629
+ // Client B should get exactly one patch (from the push), not a duplicate
630
+ // from external change detection
631
+ const clientBMessages = sentMessages(socketB) as any[]
632
+ expect(clientBMessages.length).toBe(1)
633
+ expect(clientBMessages[0].type).toBe('data')
634
+ expect(clientBMessages[0].data[0].type).toBe('patch')
588
635
  })
589
636
 
590
- it('handles multiple rapid external changes', async () => {
591
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
592
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
593
-
594
- const socket = makeSocket()
595
- const sessionId = 'test-session'
596
-
597
- room.handleNewSession({
598
- sessionId,
599
- socket,
600
- meta: undefined,
601
- isReadonly: false,
602
- })
603
-
604
- room.handleMessage(sessionId, {
605
- connectRequestId: 'connect-1',
606
- lastServerClock: 0,
607
- protocolVersion: getTlsyncProtocolVersion(),
608
- schema: room.serializedSchema,
609
- type: 'connect',
610
- })
611
- ;(socket.sendMessage as any).mockClear()
637
+ it('[RC4] handles multiple rapid external changes', async () => {
638
+ const { room, storage } = makeRoom()
639
+ const socket = connectSession(room, 'test-session')
612
640
 
613
641
  // Make multiple rapid external changes
614
- const page1 = PageRecordType.create({
615
- id: PageRecordType.createId('rapid_page_1'),
616
- name: 'Rapid Page 1',
617
- index: 'a2' as IndexKey,
618
- })
619
- const page2 = PageRecordType.create({
620
- id: PageRecordType.createId('rapid_page_2'),
621
- name: 'Rapid Page 2',
622
- index: 'a3' as IndexKey,
623
- })
642
+ const page1 = makePage('rapid_page_1', 'Rapid Page 1', 'a2' as IndexKey)
643
+ const page2 = makePage('rapid_page_2', 'Rapid Page 2', 'a3' as IndexKey)
624
644
 
625
645
  storage.transaction((txn) => {
626
646
  txn.set(page1.id, page1)
@@ -633,416 +653,251 @@ describe('External storage changes', () => {
633
653
  await Promise.resolve()
634
654
 
635
655
  // Client should have received patches for both changes
636
- const calls = (socket.sendMessage as any).mock.calls
637
- expect(calls.length).toBeGreaterThan(0)
638
-
639
- // Collect all patches received
640
- const allPatches: any[] = []
641
- for (const call of calls) {
642
- const msg = call[0]
643
- if (msg.type === 'data') {
644
- for (const item of msg.data) {
645
- if (item.type === 'patch') {
646
- allPatches.push(item)
647
- }
648
- }
649
- }
650
- }
651
-
652
- // Should have received both pages in patches
653
- const allDiffs = allPatches.reduce((acc, patch) => ({ ...acc, ...patch.diff }), {})
656
+ expect(socket.sendMessage).toHaveBeenCalled()
657
+ const allDiffs = combinedPatchDiff(socket)
654
658
  expect(allDiffs[page1.id]).toBeDefined()
655
659
  expect(allDiffs[page2.id]).toBeDefined()
656
660
  })
657
- })
658
661
 
659
- describe('Migration idempotency', () => {
660
- it('running migrations twice does not cause issues', () => {
661
- const serializedSchema = schema.serialize()
662
- const oldSerializedSchema: SerializedSchemaV2 = {
663
- schemaVersion: 2,
664
- sequences: {
665
- ...serializedSchema.sequences,
666
- 'com.tldraw.shape.arrow': 0,
667
- },
668
- }
662
+ it('[RC4] broadcasts changes when a snapshot is loaded via a storage transaction during an active session', async () => {
663
+ const { room, storage } = makeRoom()
664
+ const socket = connectSession(room, 'active-session')
669
665
 
670
- const storage = new InMemorySyncStorage<TLRecord>({
671
- snapshot: makeSnapshot([...records, oldArrow as any], {
672
- schema: oldSerializedSchema,
673
- }),
674
- })
666
+ // Load a new snapshot with additional page
667
+ const newPage = makePage('new_page', 'New Page')
675
668
 
676
- // First migration (simulating what TLSyncRoom constructor does)
677
- const _room1 = new TLSyncRoom<TLRecord, undefined>({
678
- schema,
679
- storage,
669
+ storage.transaction((txn) => {
670
+ txn.set(newPage.id, newPage)
680
671
  })
681
672
 
682
- // Get state after first migration
683
- const snapshotAfterFirst = storage.getSnapshot()
684
- const arrowAfterFirst = snapshotAfterFirst.documents.find((r) => r.state.id === oldArrow.id)
685
- ?.state as TLArrowShape
686
-
687
- // Verify migration happened
688
- expect(arrowAfterFirst.props.labelColor).toBe('black')
673
+ // Wait for onChange to fire
674
+ await Promise.resolve()
689
675
 
690
- // Create another room with the same storage (simulating a second migration attempt)
691
- // This would happen if TestServer ran migrations before TLSyncRoom constructor
692
- const _room2 = new TLSyncRoom<TLRecord, undefined>({
693
- schema,
694
- storage,
695
- })
676
+ // Client should have received the new page
677
+ expect(socket.sendMessage).toHaveBeenCalled()
678
+ const allDiffs = combinedPatchDiff(socket)
679
+ expect(allDiffs[newPage.id]).toBeDefined()
680
+ })
696
681
 
697
- // Get state after second "migration"
698
- const snapshotAfterSecond = storage.getSnapshot()
699
- const arrowAfterSecond = snapshotAfterSecond.documents.find((r) => r.state.id === oldArrow.id)
700
- ?.state as TLArrowShape
682
+ it('[RC4] broadcasts document deletions made during an active session', async () => {
683
+ const extraPage = makePage('extra_page', 'Extra Page')
684
+ const { room, storage } = makeRoom({ snapshot: makeSnapshot([...records, extraPage]) })
685
+ const socket = connectSession(room, 'active-session')
701
686
 
702
- // Should still have the migrated value
703
- expect(arrowAfterSecond.props.labelColor).toBe('black')
687
+ // Delete the extra page via storage
688
+ storage.transaction((txn) => {
689
+ txn.delete(extraPage.id)
690
+ })
704
691
 
705
- // Document count should be the same
706
- expect(snapshotAfterSecond.documents.length).toBe(snapshotAfterFirst.documents.length)
692
+ await Promise.resolve()
707
693
 
708
- // Schema should be up to date
709
- expect(snapshotAfterSecond.schema).toEqual(schema.serialize())
694
+ // Client should have received the delete
695
+ expect(socket.sendMessage).toHaveBeenCalled()
696
+ const allDiffs = combinedPatchDiff(socket)
697
+ expect(allDiffs[extraPage.id]).toEqual(['remove'])
710
698
  })
711
699
 
712
- it('already migrated data is not modified again', () => {
713
- // Start with current schema (no migration needed)
714
- const storage = new InMemorySyncStorage<TLRecord>({
715
- snapshot: makeSnapshot(records, {
716
- documentClock: 10,
717
- }),
718
- })
719
-
720
- const clockBefore = storage.getClock()
721
-
722
- // Create room - should not modify anything since schema is current
723
- const _room = new TLSyncRoom<TLRecord, undefined>({
724
- schema,
725
- storage,
700
+ it('[RC5] closes every session when an external change cannot be diffed incrementally', async () => {
701
+ const { room, storage } = makeRoom()
702
+ const socketA = connectSession(room, 'a')
703
+ const socketB = connectSession(room, 'b')
704
+ const becameEmpty = vi.fn()
705
+ room.events.on('room_became_empty', becameEmpty)
706
+
707
+ // An external transaction advances the clock, and the storage simultaneously
708
+ // loses the tombstone history needed for an incremental diff (as happens when
709
+ // a snapshot is loaded over the top of existing data).
710
+ const newPage = makePage('wipe_page', 'Wipe Page')
711
+ storage.transaction((txn) => {
712
+ txn.set(newPage.id, newPage)
726
713
  })
714
+ storage.tombstoneHistoryStartsAtClock.set(storage.getClock())
727
715
 
728
- // Clock should not have changed since no migration was needed
729
- expect(storage.getClock()).toBe(clockBefore)
716
+ await Promise.resolve()
717
+ await Promise.resolve()
730
718
 
731
- // Documents should be unchanged
732
- expect(storage.getSnapshot().documents.length).toBe(2)
719
+ // All sessions are removed so the clients reconnect and re-hydrate
720
+ expect(room.sessions.size).toBe(0)
721
+ expect(socketA.close).toHaveBeenCalled()
722
+ expect(socketB.close).toHaveBeenCalled()
723
+ expect(becameEmpty).toHaveBeenCalledTimes(1)
733
724
  })
734
725
 
735
- it('migration updates schema version in storage', () => {
736
- const serializedSchema = schema.serialize()
737
- const oldSerializedSchema: SerializedSchemaV2 = {
738
- schemaVersion: 2,
739
- sequences: {
740
- ...serializedSchema.sequences,
741
- 'com.tldraw.shape.arrow': 0,
742
- },
743
- }
726
+ it('[RC6][SES1] the default client timeout prunes idle sessions via the periodic interval', () => {
727
+ vi.useFakeTimers()
728
+ const { room } = makeRoom()
729
+ const socket = connectSession(room, 'idle')
730
+ const removed = vi.fn()
731
+ const becameEmpty = vi.fn()
732
+ room.events.on('session_removed', removed)
733
+ room.events.on('room_became_empty', becameEmpty)
734
+
735
+ // Advance past the idle timeout; the periodic prune interval cancels the session
736
+ vi.advanceTimersByTime(SESSION_IDLE_TIMEOUT + 2001)
737
+ expect(room.sessions.get('idle')?.state).toBe(RoomSessionState.AwaitingRemoval)
738
+ expect(socket.isOpen).toBe(false)
744
739
 
745
- const storage = new InMemorySyncStorage<TLRecord>({
746
- snapshot: makeSnapshot([...records, oldArrow as any], {
747
- schema: oldSerializedSchema,
748
- }),
749
- })
740
+ // After the removal grace period it is removed entirely
741
+ vi.advanceTimersByTime(SESSION_REMOVAL_WAIT_TIME + 2001)
742
+ expect(room.sessions.size).toBe(0)
743
+ expect(removed).toHaveBeenCalledWith({ sessionId: 'idle', meta: undefined })
744
+ expect(becameEmpty).toHaveBeenCalledTimes(1)
745
+ })
750
746
 
751
- // Verify old schema before room creation
752
- expect(storage.schema.get()).toEqual(oldSerializedSchema)
747
+ it('[RC6] a clientTimeout of Infinity or 0 disables the periodic prune interval', () => {
748
+ vi.useFakeTimers()
749
+ for (const clientTimeout of [Infinity, 0]) {
750
+ const { room } = makeRoom({ clientTimeout })
751
+ connectSession(room, 'idle')
753
752
 
754
- // Create room which triggers migration
755
- const _room = new TLSyncRoom<TLRecord, undefined>({
756
- schema,
757
- storage,
758
- })
753
+ vi.advanceTimersByTime(10 * SESSION_IDLE_TIMEOUT)
759
754
 
760
- // Schema should now be updated to current version
761
- expect(storage.schema.get()).toEqual(schema.serialize())
755
+ expect(room.sessions.get('idle')?.state).toBe(RoomSessionState.Connected)
756
+ room.close()
757
+ }
762
758
  })
763
- })
764
-
765
- describe('Protocol version handling', () => {
766
- it('sets supportsStringAppend to false for protocol version 7', () => {
767
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
768
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
769
759
 
770
- const socket = makeSocket()
771
- const sessionId = 'v7-session'
760
+ it('[RC7] close() closes every session socket and isClosed() reports it', () => {
761
+ const { room } = makeRoom()
762
+ const socketA = connectSession(room, 'a')
763
+ const socketB = connectSession(room, 'b')
772
764
 
773
- room.handleNewSession({
774
- sessionId,
775
- socket,
776
- meta: undefined,
777
- isReadonly: false,
778
- })
765
+ expect(room.isClosed()).toBe(false)
779
766
 
780
- // Connect with protocol version 7 (which gets upgraded to 8 but with supportsStringAppend = false)
781
- room.handleMessage(sessionId, {
782
- connectRequestId: 'connect-1',
783
- lastServerClock: 0,
784
- protocolVersion: 7,
785
- schema: room.serializedSchema,
786
- type: 'connect',
787
- })
767
+ room.close()
788
768
 
789
- const session = room.sessions.get(sessionId)
790
- expect(session?.state).toBe('connected')
791
- if (session?.state === 'connected') {
792
- expect(session.supportsStringAppend).toBe(false)
793
- }
769
+ expect(socketA.close).toHaveBeenCalled()
770
+ expect(socketB.close).toHaveBeenCalled()
771
+ expect(socketA.isOpen).toBe(false)
772
+ expect(socketB.isOpen).toBe(false)
773
+ expect(room.isClosed()).toBe(true)
794
774
  })
775
+ })
795
776
 
796
- it('sets supportsStringAppend to true for protocol version 8', () => {
797
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
798
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
799
-
800
- const socket = makeSocket()
801
- const sessionId = 'v8-session'
777
+ describe('23. Connect handshake (HS)', () => {
778
+ it('[HS1] a session re-registered under the same id keeps its previous presence id', () => {
779
+ const { room } = makeRoom()
802
780
 
803
781
  room.handleNewSession({
804
- sessionId,
805
- socket,
782
+ sessionId: 's1',
783
+ socket: makeSocket(),
806
784
  meta: undefined,
807
785
  isReadonly: false,
808
786
  })
787
+ const presenceId = room.sessions.get('s1')!.presenceId
788
+ expect(presenceId).not.toBeNull()
809
789
 
810
- room.handleMessage(sessionId, {
811
- connectRequestId: 'connect-1',
812
- lastServerClock: 0,
813
- protocolVersion: getTlsyncProtocolVersion(), // version 8
814
- schema: room.serializedSchema,
815
- type: 'connect',
816
- })
817
-
818
- const session = room.sessions.get(sessionId)
819
- expect(session?.state).toBe('connected')
820
- if (session?.state === 'connected') {
821
- expect(session.supportsStringAppend).toBe(true)
822
- }
823
- })
824
-
825
- it('getCanEmitStringAppend returns false when any client lacks string append support', () => {
826
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
827
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
828
-
829
- const socketV7 = makeSocket()
830
- const socketV8 = makeSocket()
831
-
832
- // Connect v8 client first
790
+ // Re-register the same session id (e.g. a quick reconnect)
833
791
  room.handleNewSession({
834
- sessionId: 'v8-client',
835
- socket: socketV8,
792
+ sessionId: 's1',
793
+ socket: makeSocket(),
836
794
  meta: undefined,
837
795
  isReadonly: false,
838
796
  })
839
- room.handleMessage('v8-client', {
840
- connectRequestId: 'connect-v8',
841
- lastServerClock: 0,
842
- protocolVersion: 8,
843
- schema: room.serializedSchema,
844
- type: 'connect',
845
- })
846
-
847
- // With only v8 client, should be true
848
- expect(room.getCanEmitStringAppend()).toBe(true)
797
+ expect(room.sessions.get('s1')!.presenceId).toBe(presenceId)
849
798
 
850
- // Connect v7 client
799
+ // A different session gets a different presence id
851
800
  room.handleNewSession({
852
- sessionId: 'v7-client',
853
- socket: socketV7,
801
+ sessionId: 's2',
802
+ socket: makeSocket(),
854
803
  meta: undefined,
855
804
  isReadonly: false,
856
805
  })
857
- room.handleMessage('v7-client', {
858
- connectRequestId: 'connect-v7',
859
- lastServerClock: 0,
860
- protocolVersion: 7,
861
- schema: room.serializedSchema,
862
- type: 'connect',
863
- })
864
-
865
- // With mixed clients, should be false
866
- expect(room.getCanEmitStringAppend()).toBe(false)
806
+ expect(room.sessions.get('s2')!.presenceId).not.toBe(presenceId)
867
807
  })
868
- })
869
808
 
870
- describe('Presence store isolation', () => {
871
- it('presence changes do not affect document clock', () => {
872
- const storage = new InMemorySyncStorage<TLRecord>({
873
- snapshot: makeSnapshot(records, { documentClock: 10 }),
874
- })
875
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
809
+ it('[HS3] rejects a client whose schema version lacks down migrations', () => {
810
+ // Use an old schema version that can't connect (arrow v0 has retired down migrations)
811
+ const serializedSchema = schema.serialize()
812
+ const oldSerializedSchema: SerializedSchemaV2 = {
813
+ schemaVersion: 2,
814
+ sequences: {
815
+ ...serializedSchema.sequences,
816
+ 'com.tldraw.shape.arrow': 0,
817
+ },
818
+ }
876
819
 
820
+ const { room } = makeRoom()
877
821
  const socket = makeSocket()
878
- const sessionId = 'presence-test'
879
822
 
880
823
  room.handleNewSession({
881
- sessionId,
824
+ sessionId: 'old-client-session',
882
825
  socket,
883
826
  meta: undefined,
884
827
  isReadonly: false,
885
828
  })
886
829
 
887
- room.handleMessage(sessionId, {
830
+ room.handleMessage('old-client-session', {
888
831
  connectRequestId: 'connect-1',
889
- lastServerClock: 10,
832
+ lastServerClock: 0,
890
833
  protocolVersion: getTlsyncProtocolVersion(),
891
- schema: room.serializedSchema,
834
+ schema: oldSerializedSchema,
892
835
  type: 'connect',
893
836
  })
894
837
 
895
- const clockBefore = storage.getClock()
896
-
897
- // Send presence update
898
- room.handleMessage(sessionId, {
899
- type: 'push',
900
- clientClock: 1,
901
- diff: undefined,
902
- presence: [
903
- 'put',
904
- InstancePresenceRecordType.create({
905
- id: InstancePresenceRecordType.createId('presence-1'),
906
- currentPageId: PageRecordType.createId('page_2'),
907
- userId: 'user-1',
908
- userName: 'Test User',
909
- }),
910
- ],
911
- } as TLPushRequest<TLRecord>)
838
+ // Session should not be connected - it was rejected due to incompatible schema
839
+ expect(room.sessions.get('old-client-session')?.state).not.toBe(RoomSessionState.Connected)
912
840
 
913
- // Document clock should not have changed
914
- expect(storage.getClock()).toBe(clockBefore)
841
+ // Socket should be closed with CLIENT_TOO_OLD
842
+ expect(socket.close).toHaveBeenCalledWith(
843
+ TLSyncErrorCloseEventCode,
844
+ TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
845
+ )
915
846
  })
916
847
 
917
- it('presence is stored in presenceStore, not document storage', () => {
918
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
919
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
848
+ it('rejects a client running a newer schema with SERVER_TOO_OLD, not CLIENT_TOO_OLD', () => {
849
+ // Regression test for #6169
850
+ // A client running a newer SDK than the server: its schema has a sequence version higher
851
+ // than anything this server knows about. The server can't reconcile it, but the server is
852
+ // the one that's behind, so it should report SERVER_TOO_OLD (not CLIENT_TOO_OLD).
853
+ const serializedSchema = schema.serialize()
854
+ const newerSerializedSchema: SerializedSchemaV2 = {
855
+ schemaVersion: 2,
856
+ sequences: {
857
+ ...serializedSchema.sequences,
858
+ 'com.tldraw.shape.arrow': 999,
859
+ },
860
+ }
920
861
 
862
+ const { room } = makeRoom()
921
863
  const socket = makeSocket()
922
- const sessionId = 'presence-test'
923
864
 
924
865
  room.handleNewSession({
925
- sessionId,
866
+ sessionId: 'newer-client-session',
926
867
  socket,
927
868
  meta: undefined,
928
869
  isReadonly: false,
929
870
  })
930
871
 
931
- room.handleMessage(sessionId, {
872
+ room.handleMessage('newer-client-session', {
932
873
  connectRequestId: 'connect-1',
933
874
  lastServerClock: 0,
934
875
  protocolVersion: getTlsyncProtocolVersion(),
935
- schema: room.serializedSchema,
876
+ schema: newerSerializedSchema,
936
877
  type: 'connect',
937
878
  })
938
879
 
939
- const session = room.sessions.get(sessionId)
940
- const presenceId = session?.presenceId
941
-
942
- // Send presence update
943
- room.handleMessage(sessionId, {
944
- type: 'push',
945
- clientClock: 1,
946
- diff: undefined,
947
- presence: [
948
- 'put',
949
- InstancePresenceRecordType.create({
950
- id: InstancePresenceRecordType.createId('any'),
951
- currentPageId: PageRecordType.createId('page_2'),
952
- userId: 'user-1',
953
- userName: 'Test User',
954
- }),
955
- ],
956
- } as TLPushRequest<TLRecord>)
957
-
958
- // Presence should be in presenceStore
959
- expect(room.presenceStore.get(presenceId!)).toBeDefined()
880
+ // Session should not be connected - it was rejected due to incompatible schema
881
+ expect(room.sessions.get('newer-client-session')?.state).not.toBe(RoomSessionState.Connected)
960
882
 
961
- // Presence should NOT be in document storage
962
- storage.transaction((txn) => {
963
- expect(txn.get(presenceId!)).toBeUndefined()
964
- })
883
+ // Socket should be closed with SERVER_TOO_OLD, since the client is on a newer version
884
+ expect(socket.close).toHaveBeenCalledWith(
885
+ TLSyncErrorCloseEventCode,
886
+ TLSyncErrorCloseEventReason.SERVER_TOO_OLD
887
+ )
965
888
  })
966
889
 
967
- it('presence is removed from presenceStore when session is removed', async () => {
968
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
969
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
970
-
971
- const socket = makeSocket()
972
- const sessionId = 'presence-test'
973
-
974
- room.handleNewSession({
975
- sessionId,
976
- socket,
977
- meta: undefined,
978
- isReadonly: false,
979
- })
890
+ it('[HS4] handles a client with lastServerClock greater than the current documentClock', () => {
891
+ const { room } = makeRoom({ snapshot: makeSnapshot(records, { documentClock: 10 }) })
980
892
 
981
- room.handleMessage(sessionId, {
982
- connectRequestId: 'connect-1',
983
- lastServerClock: 0,
984
- protocolVersion: getTlsyncProtocolVersion(),
985
- schema: room.serializedSchema,
986
- type: 'connect',
893
+ // Client claims to have clock 100, but server is only at 10
894
+ const socket = connectSession(room, 'future-clock-client', {
895
+ lastServerClock: 100,
896
+ clear: false,
987
897
  })
988
898
 
989
- const session = room.sessions.get(sessionId)!
990
- const presenceId = session.presenceId!
991
-
992
- // Send presence
993
- room.handleMessage(sessionId, {
994
- type: 'push',
995
- clientClock: 1,
996
- diff: undefined,
997
- presence: [
998
- 'put',
999
- InstancePresenceRecordType.create({
1000
- id: InstancePresenceRecordType.createId('any'),
1001
- currentPageId: PageRecordType.createId('page_2'),
1002
- userId: 'user-1',
1003
- userName: 'Test User',
1004
- }),
1005
- ],
1006
- } as TLPushRequest<TLRecord>)
1007
-
1008
- expect(room.presenceStore.get(presenceId)).toBeDefined()
1009
-
1010
- // Close the session
1011
- room.rejectSession(sessionId)
1012
-
1013
- // Presence should be removed
1014
- expect(room.presenceStore.get(presenceId)).toBeUndefined()
1015
- })
1016
- })
1017
-
1018
- describe('Client with future clock', () => {
1019
- it('handles client with lastServerClock greater than current documentClock', () => {
1020
- const storage = new InMemorySyncStorage<TLRecord>({
1021
- snapshot: makeSnapshot(records, { documentClock: 10 }),
1022
- })
1023
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
1024
-
1025
- const socket = makeSocket()
1026
- const sessionId = 'future-clock-client'
1027
-
1028
- room.handleNewSession({
1029
- sessionId,
1030
- socket,
1031
- meta: undefined,
1032
- isReadonly: false,
1033
- })
1034
-
1035
- // Client claims to have clock 100, but server is only at 10
1036
- room.handleMessage(sessionId, {
1037
- connectRequestId: 'connect-1',
1038
- lastServerClock: 100, // Future clock!
1039
- protocolVersion: getTlsyncProtocolVersion(),
1040
- schema: room.serializedSchema,
1041
- type: 'connect',
1042
- })
1043
-
1044
- // Session should still connect successfully
1045
- expect(room.sessions.get(sessionId)?.state).toBe('connected')
899
+ // Session should still connect successfully
900
+ expect(room.sessions.get('future-clock-client')?.state).toBe(RoomSessionState.Connected)
1046
901
 
1047
902
  // Check the connect response
1048
903
  const connectResponse = socket.__lastMessage
@@ -1055,8 +910,8 @@ describe('Client with future clock', () => {
1055
910
  }
1056
911
  })
1057
912
 
1058
- it('provides all documents when client has future clock', () => {
1059
- const storage = new InMemorySyncStorage<TLRecord>({
913
+ it('[HS4][HS5] provides all documents when the client has a future clock', () => {
914
+ const { room } = makeRoom({
1060
915
  snapshot: makeSnapshot(records, {
1061
916
  documentClock: 5,
1062
917
  documents: [
@@ -1065,24 +920,10 @@ describe('Client with future clock', () => {
1065
920
  ],
1066
921
  }),
1067
922
  })
1068
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
1069
-
1070
- const socket = makeSocket()
1071
- const sessionId = 'future-client'
1072
-
1073
- room.handleNewSession({
1074
- sessionId,
1075
- socket,
1076
- meta: undefined,
1077
- isReadonly: false,
1078
- })
1079
923
 
1080
- room.handleMessage(sessionId, {
1081
- connectRequestId: 'connect-1',
924
+ const socket = connectSession(room, 'future-client', {
1082
925
  lastServerClock: 999,
1083
- protocolVersion: getTlsyncProtocolVersion(),
1084
- schema: room.serializedSchema,
1085
- type: 'connect',
926
+ clear: false,
1086
927
  })
1087
928
 
1088
929
  const connectResponse = socket.__lastMessage
@@ -1093,175 +934,385 @@ describe('Client with future clock', () => {
1093
934
  expect(connectResponse.diff[records[1].id]).toBeDefined()
1094
935
  }
1095
936
  })
937
+
938
+ it('[HS4][HS5] the connect response carries wipe_presence and other sessions presence, excluding the connecting session own presence', () => {
939
+ const { room, storage } = makeRoom()
940
+
941
+ connectSession(room, 'a')
942
+ room.handleMessage('a', {
943
+ type: 'push',
944
+ clientClock: 0,
945
+ presence: presencePut('Alice'),
946
+ } as TLPushRequest<TLRecord>)
947
+ const aPresenceId = room.sessions.get('a')!.presenceId!
948
+
949
+ // B connects and sees A's presence in the connect diff
950
+ const socketB = connectSession(room, 'b', {
951
+ isReadonly: true,
952
+ lastServerClock: storage.getClock(),
953
+ clear: false,
954
+ })
955
+ const response = socketB.__lastMessage as any
956
+ expect(response.type).toBe('connect')
957
+ expect(response.connectRequestId).toBe('connect-b')
958
+ expect(response.hydrationType).toBe('wipe_presence')
959
+ expect(response.isReadonly).toBe(true)
960
+ expect(response.serverClock).toBe(storage.getClock())
961
+ expect(response.diff).toEqual({
962
+ [aPresenceId]: ['put', expect.objectContaining({ id: aPresenceId, userName: 'Alice' })],
963
+ })
964
+
965
+ // B pushes presence, then A reconnects under the same session id
966
+ room.handleMessage('b', {
967
+ type: 'push',
968
+ clientClock: 0,
969
+ presence: presencePut('Bob'),
970
+ } as TLPushRequest<TLRecord>)
971
+ const bPresenceId = room.sessions.get('b')!.presenceId!
972
+
973
+ room.handleClose('a')
974
+ const socketA2 = connectSession(room, 'a', {
975
+ lastServerClock: storage.getClock(),
976
+ clear: false,
977
+ })
978
+ const response2 = socketA2.__lastMessage as any
979
+ expect(response2.type).toBe('connect')
980
+
981
+ // A's own (still stored) presence is excluded, B's presence is included
982
+ expect(room.presenceStore.get(aPresenceId)).toBeDefined()
983
+ expect(response2.diff[aPresenceId]).toBeUndefined()
984
+ expect(response2.diff[bPresenceId]).toEqual([
985
+ 'put',
986
+ expect.objectContaining({ id: bPresenceId, userName: 'Bob' }),
987
+ ])
988
+ })
989
+
990
+ it('[HS6] accepts a client with the same schema version and moves the session to Connected', () => {
991
+ const { room } = makeRoom()
992
+ const socket = connectSession(room, 'current-client-session', { clear: false })
993
+
994
+ expect(room.sessions.get('current-client-session')?.state).toBe(RoomSessionState.Connected)
995
+ expect(socket.__lastMessage?.type).toBe('connect')
996
+ })
1096
997
  })
1097
998
 
1098
- describe('Loading snapshot during active session', () => {
1099
- it('broadcasts changes when snapshot is loaded via storage transaction', async () => {
1100
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
1101
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
999
+ describe('24. Push handling (RP)', () => {
1000
+ function setupTwoSessions() {
1001
+ const { room, storage } = makeRoom()
1002
+ const socketA = connectSession(room, 'a')
1003
+ const socketB = connectSession(room, 'b')
1004
+ return { room, storage, socketA, socketB }
1005
+ }
1102
1006
 
1007
+ it('[RP1] ignores pushes from sessions that have not completed the handshake', () => {
1008
+ const { room, storage } = makeRoom()
1103
1009
  const socket = makeSocket()
1104
- const sessionId = 'active-session'
1010
+ room.handleNewSession({ sessionId: 's1', socket, meta: undefined, isReadonly: false })
1105
1011
 
1106
- room.handleNewSession({
1107
- sessionId,
1108
- socket,
1109
- meta: undefined,
1110
- isReadonly: false,
1111
- })
1012
+ const clockBefore = storage.getClock()
1013
+ const newPage = makePage('early_page', 'Too early')
1112
1014
 
1113
- room.handleMessage(sessionId, {
1114
- connectRequestId: 'connect-1',
1115
- lastServerClock: 0,
1116
- protocolVersion: getTlsyncProtocolVersion(),
1117
- schema: room.serializedSchema,
1118
- type: 'connect',
1015
+ room.handleMessage('s1', {
1016
+ type: 'push',
1017
+ clientClock: 0,
1018
+ diff: { [newPage.id]: ['put', newPage] },
1019
+ } as TLPushRequest<TLRecord>)
1020
+
1021
+ expect(storage.getClock()).toBe(clockBefore)
1022
+ expect(storage.documents.get(newPage.id)).toBeUndefined()
1023
+ expect(socket.sendMessage).not.toHaveBeenCalled()
1024
+ expect(room.sessions.get('s1')?.state).toBe(RoomSessionState.AwaitingConnectMessage)
1025
+ })
1026
+
1027
+ it('[RP2] rejects the session when a push contains a record with an unknown type', () => {
1028
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1029
+ try {
1030
+ const { room } = makeRoom()
1031
+ const socket = connectSession(room, 'invalid-record-session')
1032
+
1033
+ // Try to push a record with an unknown type
1034
+ room.handleMessage('invalid-record-session', {
1035
+ type: 'push',
1036
+ clientClock: 1,
1037
+ diff: {
1038
+ 'unknown:record': [
1039
+ 'put',
1040
+ {
1041
+ id: 'unknown:record',
1042
+ typeName: 'unknown_type_that_does_not_exist',
1043
+ } as any,
1044
+ ],
1045
+ },
1046
+ } as TLPushRequest<TLRecord>)
1047
+
1048
+ // Session should be rejected/removed
1049
+ expect(room.sessions.get('invalid-record-session')).toBeUndefined()
1050
+ expect(socket.close).toHaveBeenCalledWith(
1051
+ TLSyncErrorCloseEventCode,
1052
+ TLSyncErrorCloseEventReason.INVALID_RECORD
1053
+ )
1054
+ } finally {
1055
+ consoleSpy.mockRestore()
1056
+ }
1057
+ })
1058
+
1059
+ describe('schema validation of pushed records', () => {
1060
+ interface Book {
1061
+ typeName: 'book'
1062
+ id: RecordId<Book>
1063
+ title: string
1064
+ }
1065
+ const Book = createRecordType<Book>('book', {
1066
+ scope: 'document',
1067
+ validator: {
1068
+ validate: (record: unknown): Book => {
1069
+ if (typeof record !== 'object' || record === null) {
1070
+ throw new Error('Expected object')
1071
+ }
1072
+ if (!('title' in record)) {
1073
+ throw new Error('Expected title')
1074
+ }
1075
+ if (typeof record.title !== 'string') {
1076
+ throw new Error('Expected title to be a string')
1077
+ }
1078
+ return record as Book
1079
+ },
1080
+ },
1081
+ })
1082
+ const BookWithoutValidator = createRecordType<Book>('book', {
1083
+ scope: 'document',
1084
+ validator: { validate: (record) => record as Book },
1085
+ })
1086
+ type Presence = UnknownRecord & { typeName: 'presence' }
1087
+ const presenceType = createRecordType<Presence>('presence', {
1088
+ scope: 'presence',
1089
+ validator: { validate: (record) => record as Presence },
1119
1090
  })
1120
- ;(socket.sendMessage as any).mockClear()
1121
1091
 
1122
- // Load a new snapshot with additional page
1123
- const newPage = PageRecordType.create({
1124
- id: PageRecordType.createId('new_page'),
1125
- name: 'New Page',
1126
- index: 'a2' as IndexKey,
1092
+ const bookSchema = StoreSchema.create<Book | Presence>({ book: Book, presence: presenceType })
1093
+ const bookSchemaWithoutValidator = StoreSchema.create<Book | Presence>({
1094
+ book: BookWithoutValidator,
1095
+ presence: presenceType,
1127
1096
  })
1128
1097
 
1129
- storage.transaction((txn) => {
1130
- txn.set(newPage.id, newPage)
1098
+ let consoleSpy: ReturnType<typeof vi.spyOn>
1099
+ beforeEach(() => {
1100
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1101
+ })
1102
+ afterEach(() => {
1103
+ consoleSpy.mockRestore()
1131
1104
  })
1132
1105
 
1133
- // Wait for onChange to fire
1134
- await Promise.resolve()
1106
+ async function makeTestInstance() {
1107
+ const server = new TestServer(bookSchema)
1108
+ const socketPair = new TestSocketPair('test', server)
1109
+ socketPair.connect()
1135
1110
 
1136
- // Client should have received the new page
1137
- const calls = (socket.sendMessage as any).mock.calls
1138
- expect(calls.length).toBeGreaterThan(0)
1139
-
1140
- const allPatches: any[] = []
1141
- for (const call of calls) {
1142
- const msg = call[0]
1143
- if (msg.type === 'data') {
1144
- for (const item of msg.data) {
1145
- if (item.type === 'patch') {
1146
- allPatches.push(item)
1147
- }
1111
+ const flush = async () => {
1112
+ await Promise.resolve()
1113
+ while (socketPair.getNeedsFlushing()) {
1114
+ socketPair.flushClientSentEvents()
1115
+ socketPair.flushServerSentEvents()
1148
1116
  }
1149
1117
  }
1118
+ let onSyncError = vi.fn()
1119
+ const client = await new Promise<TLSyncClient<Book | Presence>>((resolve, reject) => {
1120
+ onSyncError = vi.fn(reject)
1121
+ const client = new TLSyncClient({
1122
+ store: new Store<Book | Presence, unknown>({
1123
+ schema: bookSchemaWithoutValidator,
1124
+ props: {},
1125
+ }),
1126
+ socket: socketPair.clientSocket as any,
1127
+ onLoad: resolve,
1128
+ onSyncError,
1129
+ presence: computed('', () => null),
1130
+ })
1131
+ disposables.push(() => client.close())
1132
+ flush()
1133
+ })
1134
+
1135
+ return {
1136
+ server,
1137
+ socketPair,
1138
+ client,
1139
+ flush,
1140
+ onSyncError,
1141
+ }
1150
1142
  }
1151
1143
 
1152
- const allDiffs = allPatches.reduce((acc, patch) => ({ ...acc, ...patch.diff }), {})
1153
- expect(allDiffs[newPage.id]).toBeDefined()
1154
- })
1144
+ it('[RP2] rejects invalid put operations that create a new document', async () => {
1145
+ const { client, flush, onSyncError, server } = await makeTestInstance()
1155
1146
 
1156
- it('handles document deletion during active session', async () => {
1157
- const extraPage = PageRecordType.create({
1158
- id: PageRecordType.createId('extra_page'),
1159
- name: 'Extra Page',
1160
- index: 'a2' as IndexKey,
1161
- })
1147
+ const prevServerDocs = server.storage.getSnapshot().documents
1162
1148
 
1163
- const storage = new InMemorySyncStorage<TLRecord>({
1164
- snapshot: makeSnapshot([...records, extraPage]),
1149
+ client.store.put([
1150
+ {
1151
+ typeName: 'book',
1152
+ id: Book.createId('1'),
1153
+ // @ts-expect-error - deliberate invalid data
1154
+ title: 123 as string,
1155
+ },
1156
+ ])
1157
+ await flush()
1158
+
1159
+ expect(onSyncError).toHaveBeenCalledTimes(1)
1160
+ expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
1161
+ expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
1162
+ })
1163
+
1164
+ it('[RP2] rejects invalid put operations that replace an existing document', async () => {
1165
+ const { client, flush, onSyncError, server } = await makeTestInstance()
1166
+
1167
+ let prevServerDocs = server.storage.getSnapshot().documents
1168
+ const book: Book = { typeName: 'book', id: Book.createId('1'), title: 'Annihilation' }
1169
+ client.store.put([book])
1170
+ await flush()
1171
+
1172
+ expect(onSyncError).toHaveBeenCalledTimes(0)
1173
+ expect(server.storage.getSnapshot().documents).not.toStrictEqual(prevServerDocs)
1174
+ prevServerDocs = server.storage.getSnapshot().documents
1175
+
1176
+ client.socket.sendMessage({
1177
+ type: 'push',
1178
+ // @ts-expect-error clientClock is private
1179
+ clientClock: client.clientClock++,
1180
+ diff: {
1181
+ [book.id]: [
1182
+ RecordOpType.Put,
1183
+ {
1184
+ ...book,
1185
+ // @ts-expect-error - deliberate invalid data
1186
+ title: 123 as string,
1187
+ },
1188
+ ],
1189
+ },
1190
+ })
1191
+ await flush()
1192
+
1193
+ expect(onSyncError).toHaveBeenCalledTimes(1)
1194
+ expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
1195
+ expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
1165
1196
  })
1166
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
1167
1197
 
1168
- const socket = makeSocket()
1169
- const sessionId = 'active-session'
1198
+ it('[RP2] rejects invalid patch operations', async () => {
1199
+ const { client, flush, onSyncError, server } = await makeTestInstance()
1170
1200
 
1171
- room.handleNewSession({
1172
- sessionId,
1173
- socket,
1174
- meta: undefined,
1175
- isReadonly: false,
1176
- })
1201
+ let prevServerDocs = server.storage.getSnapshot().documents
1177
1202
 
1178
- room.handleMessage(sessionId, {
1179
- connectRequestId: 'connect-1',
1180
- lastServerClock: 0,
1181
- protocolVersion: getTlsyncProtocolVersion(),
1182
- schema: room.serializedSchema,
1183
- type: 'connect',
1184
- })
1185
- ;(socket.sendMessage as any).mockClear()
1203
+ // create the book
1204
+ client.store.put([
1205
+ {
1206
+ typeName: 'book',
1207
+ id: Book.createId('1'),
1208
+ title: 'The silence of the girls',
1209
+ },
1210
+ ])
1211
+ await flush()
1186
1212
 
1187
- // Delete the extra page via storage
1188
- storage.transaction((txn) => {
1189
- txn.delete(extraPage.id)
1213
+ expect(onSyncError).toHaveBeenCalledTimes(0)
1214
+ expect(server.storage.getSnapshot().documents).not.toStrictEqual(prevServerDocs)
1215
+ prevServerDocs = server.storage.getSnapshot().documents
1216
+
1217
+ // update the title to be wrong
1218
+ client.store.put([
1219
+ {
1220
+ typeName: 'book',
1221
+ id: Book.createId('1'),
1222
+ // @ts-expect-error - deliberate invalid data
1223
+ title: 123 as string,
1224
+ },
1225
+ ])
1226
+ await flush()
1227
+ expect(onSyncError).toHaveBeenCalledTimes(1)
1228
+ expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
1229
+ expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
1190
1230
  })
1231
+ })
1191
1232
 
1192
- await Promise.resolve()
1233
+ it('[RP3][RP7][RP8] a put of a new id stores the record, broadcasts a put, and commits', () => {
1234
+ const { storage, room, socketA, socketB } = setupTwoSessions()
1235
+ const newPage = makePage('page_3', 'my lovely page 3')
1193
1236
 
1194
- // Client should have received the delete
1195
- const calls = (socket.sendMessage as any).mock.calls
1196
- expect(calls.length).toBeGreaterThan(0)
1197
-
1198
- const allPatches: any[] = []
1199
- for (const call of calls) {
1200
- const msg = call[0]
1201
- if (msg.type === 'data') {
1202
- for (const item of msg.data) {
1203
- if (item.type === 'patch') {
1204
- allPatches.push(item)
1205
- }
1206
- }
1207
- }
1208
- }
1237
+ room.handleMessage('a', {
1238
+ type: 'push',
1239
+ clientClock: 7,
1240
+ diff: { [newPage.id]: ['put', newPage] },
1241
+ } as TLPushRequest<TLRecord>)
1209
1242
 
1210
- const allDiffs = allPatches.reduce((acc, patch) => ({ ...acc, ...patch.diff }), {})
1211
- expect(allDiffs[extraPage.id]).toEqual(['remove'])
1243
+ expect(storage.documents.get(newPage.id)?.state).toEqual(newPage)
1244
+
1245
+ // The pusher gets a commit and never its own patch back
1246
+ expect(sentDataMessages(socketA)).toEqual([
1247
+ { type: 'push_result', clientClock: 7, serverClock: 1, action: 'commit' },
1248
+ ])
1249
+
1250
+ // The other session gets the put broadcast
1251
+ expect(sentDataMessages(socketB)).toEqual([
1252
+ { type: 'patch', diff: { [newPage.id]: ['put', newPage] }, serverClock: 1 },
1253
+ ])
1212
1254
  })
1213
- })
1214
1255
 
1215
- describe('Invalid record handling', () => {
1216
- it('rejects session when push contains record with unknown type', () => {
1217
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1218
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
1219
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
1256
+ it('[RP3][RP7] a put over an existing record broadcasts only the difference and rebases the pusher', () => {
1257
+ const { storage, room, socketA, socketB } = setupTwoSessions()
1258
+ const renamed = { ...pageRecord, name: 'renamed' }
1220
1259
 
1221
- const socket = makeSocket()
1222
- const sessionId = 'invalid-record-session'
1260
+ room.handleMessage('a', {
1261
+ type: 'push',
1262
+ clientClock: 1,
1263
+ diff: { [pageRecord.id]: ['put', renamed] },
1264
+ } as TLPushRequest<TLRecord>)
1223
1265
 
1224
- room.handleNewSession({
1225
- sessionId,
1226
- socket,
1227
- meta: undefined,
1228
- isReadonly: false,
1229
- })
1266
+ expect(storage.documents.get(pageRecord.id)?.state).toEqual(renamed)
1267
+
1268
+ const expectedDiff = { [pageRecord.id]: ['patch', { name: ['put', 'renamed'] }] }
1269
+ expect(sentDataMessages(socketB)).toEqual([
1270
+ { type: 'patch', diff: expectedDiff, serverClock: 1 },
1271
+ ])
1272
+ expect(sentDataMessages(socketA)).toEqual([
1273
+ {
1274
+ type: 'push_result',
1275
+ clientClock: 1,
1276
+ serverClock: 1,
1277
+ action: { rebaseWithDiff: expectedDiff },
1278
+ },
1279
+ ])
1280
+ })
1230
1281
 
1231
- room.handleMessage(sessionId, {
1232
- connectRequestId: 'connect-1',
1233
- lastServerClock: 0,
1234
- protocolVersion: getTlsyncProtocolVersion(),
1235
- schema: room.serializedSchema,
1236
- type: 'connect',
1237
- })
1282
+ it('[RP3][RP7] a put equal to the stored record changes nothing and is discarded', () => {
1283
+ const { storage, room, socketA, socketB } = setupTwoSessions()
1284
+
1285
+ room.handleMessage('a', {
1286
+ type: 'push',
1287
+ clientClock: 1,
1288
+ diff: { [pageRecord.id]: ['put', { ...pageRecord }] },
1289
+ } as TLPushRequest<TLRecord>)
1290
+
1291
+ expect(storage.getClock()).toBe(0)
1292
+ expect(sentDataMessages(socketA)).toEqual([
1293
+ { type: 'push_result', clientClock: 1, serverClock: 0, action: 'discard' },
1294
+ ])
1295
+ expect(socketB.sendMessage).not.toHaveBeenCalled()
1296
+ })
1238
1297
 
1239
- expect(room.sessions.get(sessionId)?.state).toBe('connected')
1298
+ it('[RP4][RP7] a patch for a missing record is silently ignored and the push discarded', () => {
1299
+ const { storage, room, socketA, socketB } = setupTwoSessions()
1240
1300
 
1241
- // Try to push a record with an unknown type
1242
- room.handleMessage(sessionId, {
1301
+ room.handleMessage('a', {
1243
1302
  type: 'push',
1244
1303
  clientClock: 1,
1245
- diff: {
1246
- 'unknown:record': [
1247
- 'put',
1248
- {
1249
- id: 'unknown:record',
1250
- typeName: 'unknown_type_that_does_not_exist',
1251
- } as any,
1252
- ],
1253
- },
1304
+ diff: { 'page:does_not_exist': ['patch', { name: ['put', 'whatever'] }] },
1254
1305
  } as TLPushRequest<TLRecord>)
1255
1306
 
1256
- // Session should be rejected/removed
1257
- const session = room.sessions.get(sessionId)
1258
- expect(session?.state === 'connected').toBe(false)
1259
- consoleSpy.mockRestore()
1307
+ expect(storage.getClock()).toBe(0)
1308
+ expect(room.sessions.get('a')?.state).toBe(RoomSessionState.Connected)
1309
+ expect(sentDataMessages(socketA)).toEqual([
1310
+ { type: 'push_result', clientClock: 1, serverClock: 0, action: 'discard' },
1311
+ ])
1312
+ expect(socketB.sendMessage).not.toHaveBeenCalled()
1260
1313
  })
1261
- })
1262
1314
 
1263
- describe('Migration and patch handling', () => {
1264
- it('successfully patches arrow shape with current schema', () => {
1315
+ it('[RP5] successfully patches an arrow shape with the current schema', () => {
1265
1316
  // Create a document in the room first
1266
1317
  const existingArrow: TLArrowShape = {
1267
1318
  typeName: 'shape',
@@ -1295,34 +1346,11 @@ describe('Migration and patch handling', () => {
1295
1346
  meta: {},
1296
1347
  }
1297
1348
 
1298
- const storage = new InMemorySyncStorage<TLRecord>({
1299
- snapshot: makeSnapshot([...records, existingArrow]),
1300
- })
1301
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
1302
-
1303
- const socket = makeSocket()
1304
- const sessionId = 'patch-migration-session'
1305
-
1306
- room.handleNewSession({
1307
- sessionId,
1308
- socket,
1309
- meta: undefined,
1310
- isReadonly: false,
1311
- })
1312
-
1313
- // Connect with current schema
1314
- room.handleMessage(sessionId, {
1315
- connectRequestId: 'connect-1',
1316
- lastServerClock: 0,
1317
- protocolVersion: getTlsyncProtocolVersion(),
1318
- schema: room.serializedSchema,
1319
- type: 'connect',
1320
- })
1321
-
1322
- expect(room.sessions.get(sessionId)?.state).toBe('connected')
1349
+ const { room, storage } = makeRoom({ snapshot: makeSnapshot([...records, existingArrow]) })
1350
+ connectSession(room, 'patch-migration-session')
1323
1351
 
1324
1352
  // Patch the arrow - this should work normally
1325
- room.handleMessage(sessionId, {
1353
+ room.handleMessage('patch-migration-session', {
1326
1354
  type: 'push',
1327
1355
  clientClock: 1,
1328
1356
  diff: {
@@ -1331,78 +1359,934 @@ describe('Migration and patch handling', () => {
1331
1359
  } as TLPushRequest<TLRecord>)
1332
1360
 
1333
1361
  // Session should still be connected after valid patch
1334
- expect(room.sessions.get(sessionId)?.state).toBe('connected')
1362
+ expect(room.sessions.get('patch-migration-session')?.state).toBe(RoomSessionState.Connected)
1335
1363
 
1336
1364
  // Verify the patch was applied
1337
1365
  const patchedArrow = storage.documents.get(existingArrow.id)?.state as TLArrowShape
1338
1366
  expect(patchedArrow.props.color).toBe('red')
1339
1367
  })
1340
1368
 
1341
- it('rejects client with incompatible schema version that lacks down migrations', () => {
1342
- // Use an old schema version that can't connect (arrow v0 has no down migration)
1343
- const serializedSchema = schema.serialize()
1344
- const oldSerializedSchema: SerializedSchemaV2 = {
1345
- schemaVersion: 2,
1346
- sequences: {
1347
- ...serializedSchema.sequences,
1348
- // Set arrow shape to version 0, which has retired down migrations
1349
- 'com.tldraw.shape.arrow': 0,
1350
- },
1351
- }
1369
+ it('[RP6][RP7] a remove deletes the record, writes a tombstone, and broadcasts the removal', () => {
1370
+ const { storage, room, socketA, socketB } = setupTwoSessions()
1352
1371
 
1353
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
1354
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
1372
+ room.handleMessage('a', {
1373
+ type: 'push',
1374
+ clientClock: 1,
1375
+ diff: { [pageRecord.id]: ['remove'] },
1376
+ } as TLPushRequest<TLRecord>)
1355
1377
 
1356
- const socket = makeSocket()
1357
- const sessionId = 'old-client-session'
1378
+ expect(storage.documents.get(pageRecord.id)).toBeUndefined()
1379
+ expect(storage.tombstones.get(pageRecord.id)).toBe(1)
1358
1380
 
1359
- room.handleNewSession({
1360
- sessionId,
1361
- socket,
1362
- meta: undefined,
1363
- isReadonly: false,
1364
- })
1381
+ expect(sentDataMessages(socketA)).toEqual([
1382
+ { type: 'push_result', clientClock: 1, serverClock: 1, action: 'commit' },
1383
+ ])
1384
+ expect(sentDataMessages(socketB)).toEqual([
1385
+ { type: 'patch', diff: { [pageRecord.id]: ['remove'] }, serverClock: 1 },
1386
+ ])
1387
+ })
1365
1388
 
1366
- // Connect with old schema - this should fail because arrow v1 migration has no down function
1367
- room.handleMessage(sessionId, {
1368
- connectRequestId: 'connect-1',
1369
- lastServerClock: 0,
1370
- protocolVersion: getTlsyncProtocolVersion(),
1371
- schema: oldSerializedSchema,
1372
- type: 'connect',
1373
- })
1389
+ it('[RP6][RP7] a remove of a missing id is ignored', () => {
1390
+ const { storage, room, socketA, socketB } = setupTwoSessions()
1374
1391
 
1375
- // Session should not be connected - it was rejected due to incompatible schema
1376
- expect(room.sessions.get(sessionId)?.state).not.toBe('connected')
1392
+ room.handleMessage('a', {
1393
+ type: 'push',
1394
+ clientClock: 1,
1395
+ diff: { 'page:does_not_exist': ['remove'] },
1396
+ } as TLPushRequest<TLRecord>)
1377
1397
 
1378
- // Socket should be closed with CLIENT_TOO_OLD
1379
- expect(socket.isOpen).toBe(false)
1398
+ expect(storage.getClock()).toBe(0)
1399
+ expect(storage.tombstones.get('page:does_not_exist')).toBeUndefined()
1400
+ expect(sentDataMessages(socketA)).toEqual([
1401
+ { type: 'push_result', clientClock: 1, serverClock: 0, action: 'discard' },
1402
+ ])
1403
+ expect(socketB.sendMessage).not.toHaveBeenCalled()
1380
1404
  })
1381
1405
 
1382
- it('accepts client with same schema version', () => {
1383
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
1384
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
1406
+ it('[RP7] a patch that only partially applies gets rebaseWithDiff carrying the effective diff', () => {
1407
+ const { room, socketA, socketB } = setupTwoSessions()
1385
1408
 
1386
- const socket = makeSocket()
1387
- const sessionId = 'current-client-session'
1409
+ // The name op has an effect, the index op is a no-op
1410
+ room.handleMessage('a', {
1411
+ type: 'push',
1412
+ clientClock: 1,
1413
+ diff: {
1414
+ [pageRecord.id]: ['patch', { name: ['put', 'renamed'], index: ['put', ZERO_INDEX_KEY] }],
1415
+ },
1416
+ } as TLPushRequest<TLRecord>)
1388
1417
 
1389
- room.handleNewSession({
1390
- sessionId,
1391
- socket,
1392
- meta: undefined,
1393
- isReadonly: false,
1394
- })
1418
+ const effectiveDiff = { [pageRecord.id]: ['patch', { name: ['put', 'renamed'] }] }
1419
+ expect(sentDataMessages(socketA)).toEqual([
1420
+ {
1421
+ type: 'push_result',
1422
+ clientClock: 1,
1423
+ serverClock: 1,
1424
+ action: { rebaseWithDiff: effectiveDiff },
1425
+ },
1426
+ ])
1427
+ expect(sentDataMessages(socketB)).toEqual([
1428
+ { type: 'patch', diff: effectiveDiff, serverClock: 1 },
1429
+ ])
1430
+ })
1395
1431
 
1396
- // Connect with same schema
1397
- room.handleMessage(sessionId, {
1398
- connectRequestId: 'connect-1',
1432
+ it('[RP7] a presence-only push gets a commit', () => {
1433
+ const { room, socketA } = setupTwoSessions()
1434
+
1435
+ room.handleMessage('a', {
1436
+ type: 'push',
1437
+ clientClock: 9,
1438
+ presence: presencePut(),
1439
+ } as TLPushRequest<TLRecord>)
1440
+
1441
+ expect(sentDataMessages(socketA)).toEqual([
1442
+ { type: 'push_result', clientClock: 9, serverClock: 0, action: 'commit' },
1443
+ ])
1444
+ })
1445
+
1446
+ it('[RP9] does not allow updates from users who are marked as readonly', () => {
1447
+ const { room, storage } = makeRoom()
1448
+ const socketA = connectSession(room, 'sessionA', { isReadonly: true })
1449
+ const socketB = connectSession(room, 'sessionB', { isReadonly: false })
1450
+ const getDoc = (id: string) => storage.documents.get(id)?.state
1451
+
1452
+ const push: TLPushRequest<any> = {
1453
+ clientClock: 0,
1454
+ diff: {
1455
+ 'page:page_3': [
1456
+ 'put',
1457
+ PageRecordType.create({
1458
+ id: 'page:page_3' as any,
1459
+ name: 'my lovely page 3',
1460
+ index: 'ab34' as IndexKey,
1461
+ }),
1462
+ ],
1463
+ },
1464
+ presence: undefined,
1465
+ type: 'push',
1466
+ }
1467
+
1468
+ // sessionA is readonly
1469
+ room.handleMessage('sessionA', push)
1470
+
1471
+ expect(getDoc('page:page_3')).toBe(undefined)
1472
+ // should tell the session to discard it
1473
+ expect(socketA.__lastMessage).toEqual({
1474
+ type: 'data',
1475
+ data: [{ type: 'push_result', clientClock: 0, serverClock: 0, action: 'discard' }],
1476
+ })
1477
+ // should not have sent anything to sessionB
1478
+ expect(socketB.__lastMessage).toBe(null)
1479
+
1480
+ // sessionB is not readonly
1481
+ room.handleMessage('sessionB', push)
1482
+
1483
+ expect(getDoc('page:page_3')).not.toBe(undefined)
1484
+
1485
+ // should tell the session to commit it
1486
+ expect(socketB.__lastMessage).toEqual({
1487
+ type: 'data',
1488
+ data: [{ type: 'push_result', clientClock: 0, serverClock: 1, action: 'commit' }],
1489
+ })
1490
+ })
1491
+
1492
+ it('[RP9][RP7][RP10] still allows presence updates from readonly users', () => {
1493
+ const { room } = makeRoom()
1494
+ const socketA = connectSession(room, 'sessionA', { isReadonly: true })
1495
+ const socketB = connectSession(room, 'sessionB', { isReadonly: false })
1496
+ const presenceIdA = room.sessions.get('sessionA')!.presenceId!
1497
+
1498
+ const presencePushRequest: TLPushRequest<any> = {
1499
+ clientClock: 0,
1500
+ diff: undefined,
1501
+ presence: [
1502
+ 'put',
1503
+ InstancePresenceRecordType.create({
1504
+ id: InstancePresenceRecordType.createId('foo'),
1505
+ currentPageId: 'page:page_2' as any,
1506
+ userId: createUserId('foo'),
1507
+ userName: 'Jimbo',
1508
+ }),
1509
+ ],
1510
+ type: 'push',
1511
+ }
1512
+
1513
+ // sessionA is readonly
1514
+ room.handleMessage('sessionA', presencePushRequest)
1515
+
1516
+ // commit for sessionA
1517
+ expect(socketA.__lastMessage).toEqual({
1518
+ type: 'data',
1519
+ data: [{ type: 'push_result', clientClock: 0, serverClock: 0, action: 'commit' }],
1520
+ })
1521
+
1522
+ // patch for sessionB, stored under sessionA's presence id
1523
+ expect(socketB.__lastMessage).toEqual({
1524
+ type: 'data',
1525
+ data: [
1526
+ {
1527
+ type: 'patch',
1528
+ serverClock: 0,
1529
+ diff: {
1530
+ [presenceIdA]: [
1531
+ 'put',
1532
+ expect.objectContaining({
1533
+ id: presenceIdA,
1534
+ typeName: 'instance_presence',
1535
+ currentPageId: 'page:page_2',
1536
+ userId: 'user:foo',
1537
+ userName: 'Jimbo',
1538
+ }),
1539
+ ],
1540
+ },
1541
+ },
1542
+ ],
1543
+ })
1544
+ })
1545
+
1546
+ it('[RP10] presence changes do not affect the document clock', () => {
1547
+ const { room, storage } = makeRoom({ snapshot: makeSnapshot(records, { documentClock: 10 }) })
1548
+ connectSession(room, 'presence-test', { lastServerClock: 10 })
1549
+
1550
+ const clockBefore = storage.getClock()
1551
+
1552
+ // Send presence update
1553
+ room.handleMessage('presence-test', {
1554
+ type: 'push',
1555
+ clientClock: 1,
1556
+ diff: undefined,
1557
+ presence: presencePut('Test User'),
1558
+ } as TLPushRequest<TLRecord>)
1559
+
1560
+ // Document clock should not have changed
1561
+ expect(storage.getClock()).toBe(clockBefore)
1562
+ })
1563
+
1564
+ it('[RP10] presence is stored in the presence store with a forced id and typeName, never in document storage', () => {
1565
+ const { room, storage } = makeRoom()
1566
+ connectSession(room, 'presence-test')
1567
+
1568
+ const presenceId = room.sessions.get('presence-test')!.presenceId!
1569
+
1570
+ // Send a presence update whose record has a client-chosen id
1571
+ room.handleMessage('presence-test', {
1572
+ type: 'push',
1573
+ clientClock: 1,
1574
+ diff: undefined,
1575
+ presence: presencePut('Test User'),
1576
+ } as TLPushRequest<TLRecord>)
1577
+
1578
+ // Presence should be in presenceStore under the session's presence id,
1579
+ // with id and typeName forced server-side
1580
+ const stored = room.presenceStore.get(presenceId) as any
1581
+ expect(stored).toBeDefined()
1582
+ expect(stored.id).toBe(presenceId)
1583
+ expect(stored.typeName).toBe('instance_presence')
1584
+ expect(stored.userName).toBe('Test User')
1585
+
1586
+ // Presence should NOT be in document storage
1587
+ storage.transaction((txn) => {
1588
+ expect(txn.get(presenceId)).toBeUndefined()
1589
+ })
1590
+ })
1591
+
1592
+ it('[RP10][SES3] presence is removed from the presence store when the session is removed', () => {
1593
+ const { room } = makeRoom()
1594
+ connectSession(room, 'presence-test')
1595
+
1596
+ const presenceId = room.sessions.get('presence-test')!.presenceId!
1597
+
1598
+ room.handleMessage('presence-test', {
1599
+ type: 'push',
1600
+ clientClock: 1,
1601
+ diff: undefined,
1602
+ presence: presencePut('Test User'),
1603
+ } as TLPushRequest<TLRecord>)
1604
+
1605
+ expect(room.presenceStore.get(presenceId)).toBeDefined()
1606
+
1607
+ // Close the session
1608
+ room.rejectSession('presence-test')
1609
+
1610
+ // Presence should be removed
1611
+ expect(room.presenceStore.get(presenceId)).toBeUndefined()
1612
+ })
1613
+
1614
+ it('[RP10] onPresenceChange fires on a microtask after presence changes', async () => {
1615
+ const onPresenceChange = vi.fn()
1616
+ const { room } = makeRoom({ onPresenceChange })
1617
+ connectSession(room, 'a')
1618
+
1619
+ room.handleMessage('a', {
1620
+ type: 'push',
1621
+ clientClock: 1,
1622
+ presence: presencePut(),
1623
+ } as TLPushRequest<TLRecord>)
1624
+
1625
+ // Not called synchronously
1626
+ expect(onPresenceChange).not.toHaveBeenCalled()
1627
+
1628
+ await Promise.resolve()
1629
+ expect(onPresenceChange).toHaveBeenCalledTimes(1)
1630
+ })
1631
+
1632
+ it('[RP11] pings are answered with pong, and pings and pushes update lastInteractionTime', () => {
1633
+ vi.useFakeTimers()
1634
+ const { room } = makeRoom()
1635
+ const socket = connectSession(room, 'a')
1636
+ const t0 = (room.sessions.get('a') as any).lastInteractionTime
1637
+
1638
+ vi.advanceTimersByTime(1234)
1639
+ room.handleMessage('a', { type: 'ping' })
1640
+ expect(socket.__lastMessage).toEqual({ type: 'pong' })
1641
+ expect((room.sessions.get('a') as any).lastInteractionTime).toBe(t0 + 1234)
1642
+
1643
+ vi.advanceTimersByTime(1000)
1644
+ room.handleMessage('a', {
1645
+ type: 'push',
1646
+ clientClock: 1,
1647
+ diff: { [pageRecord.id]: ['patch', { name: ['put', 'renamed'] }] },
1648
+ } as TLPushRequest<TLRecord>)
1649
+ expect((room.sessions.get('a') as any).lastInteractionTime).toBe(t0 + 2234)
1650
+ })
1651
+
1652
+ it('[RP12][RP2] a TLSyncError mid-push rejects only the offending session', () => {
1653
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1654
+ try {
1655
+ const { room, socketA, socketB } = setupTwoSessions()
1656
+
1657
+ room.handleMessage('a', {
1658
+ type: 'push',
1659
+ clientClock: 1,
1660
+ diff: {
1661
+ 'unknown:record': [
1662
+ 'put',
1663
+ { id: 'unknown:record', typeName: 'unknown_type_that_does_not_exist' } as any,
1664
+ ],
1665
+ },
1666
+ } as TLPushRequest<TLRecord>)
1667
+
1668
+ // Only the offending session is rejected
1669
+ expect(room.sessions.get('a')).toBeUndefined()
1670
+ expect(socketA.close).toHaveBeenCalledWith(
1671
+ TLSyncErrorCloseEventCode,
1672
+ TLSyncErrorCloseEventReason.INVALID_RECORD
1673
+ )
1674
+ expect(room.sessions.get('b')?.state).toBe(RoomSessionState.Connected)
1675
+ expect(socketB.close).not.toHaveBeenCalled()
1676
+ } finally {
1677
+ consoleSpy.mockRestore()
1678
+ }
1679
+ })
1680
+
1681
+ it('[RP13] when storage modifies a pushed change, the pusher is rebased with the storage actual changes and others receive them', () => {
1682
+ // A storage layer that embellishes every written record (e.g. with server
1683
+ // metadata) and reports the actual changes back to the room.
1684
+ class EmbellishingStorage implements TLSyncStorage<TLRecord> {
1685
+ constructor(readonly inner: InMemorySyncStorage<TLRecord>) {}
1686
+ getClock() {
1687
+ return this.inner.getClock()
1688
+ }
1689
+ onChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown) {
1690
+ return this.inner.onChange(callback)
1691
+ }
1692
+ getSnapshot() {
1693
+ return this.inner.getSnapshot()
1694
+ }
1695
+ transaction<T>(
1696
+ callback: TLSyncStorageTransactionCallback<TLRecord, T>,
1697
+ opts?: TLSyncStorageTransactionOptions
1698
+ ): TLSyncStorageTransactionResult<T, TLRecord> {
1699
+ return this.inner.transaction<T>(
1700
+ ((txn: TLSyncStorageTransaction<TLRecord>) => {
1701
+ const wrapped: TLSyncStorageTransaction<TLRecord> = {
1702
+ getClock: () => txn.getClock(),
1703
+ getChangesSince: (since) => txn.getChangesSince(since),
1704
+ get: (id) => txn.get(id),
1705
+ set: (id, record) =>
1706
+ txn.set(id, {
1707
+ ...record,
1708
+ meta: { ...(record as any).meta, embellished: true },
1709
+ } as TLRecord),
1710
+ delete: (id) => txn.delete(id),
1711
+ entries: () => txn.entries(),
1712
+ keys: () => txn.keys(),
1713
+ values: () => txn.values(),
1714
+ getSchema: () => txn.getSchema(),
1715
+ setSchema: (s) => txn.setSchema(s),
1716
+ }
1717
+ return (callback as any)(wrapped)
1718
+ }) as any,
1719
+ // Always emit the actual changes so the room sees that they differ
1720
+ // from what the client requested
1721
+ { ...opts, emitChanges: 'always' }
1722
+ )
1723
+ }
1724
+ }
1725
+
1726
+ const inner = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
1727
+ const storage = new EmbellishingStorage(inner)
1728
+ const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
1729
+ disposables.push(() => room.close())
1730
+
1731
+ const socketA = connectSession(room, 'a')
1732
+ const socketB = connectSession(room, 'b')
1733
+
1734
+ const newPage = makePage('page_3', 'embellish me')
1735
+ room.handleMessage('a', {
1736
+ type: 'push',
1737
+ clientClock: 1,
1738
+ diff: { [newPage.id]: ['put', newPage] },
1739
+ } as TLPushRequest<TLRecord>)
1740
+
1741
+ const embellished = { ...newPage, meta: { embellished: true } }
1742
+ expect(inner.documents.get(newPage.id)?.state).toEqual(embellished)
1743
+
1744
+ // The pusher is rebased onto the storage's actual changes
1745
+ expect(sentDataMessages(socketA)).toEqual([
1746
+ {
1747
+ type: 'push_result',
1748
+ clientClock: 1,
1749
+ serverClock: 1,
1750
+ action: { rebaseWithDiff: { [newPage.id]: ['put', embellished] } },
1751
+ },
1752
+ ])
1753
+
1754
+ // Other sessions receive the actual changes too
1755
+ expect(sentDataMessages(socketB)).toEqual([
1756
+ { type: 'patch', diff: { [newPage.id]: ['put', embellished] }, serverClock: 1 },
1757
+ ])
1758
+ })
1759
+ })
1760
+
1761
+ describe('25. Messaging and broadcast (RB)', () => {
1762
+ function setupTwoSessions() {
1763
+ const { room, storage } = makeRoom()
1764
+ const socketA = connectSession(room, 'a')
1765
+ const socketB = connectSession(room, 'b')
1766
+ return { room, storage, socketA, socketB }
1767
+ }
1768
+
1769
+ it('[RB1] data messages are debounced: the first is sent immediately, the rest are buffered until the flush', () => {
1770
+ vi.useFakeTimers()
1771
+ const { room, socketB } = setupTwoSessions()
1772
+ const newPage = makePage('page_3', 'v1')
1773
+
1774
+ // First data message is sent immediately, wrapped as a data event
1775
+ room.handleMessage('a', {
1776
+ type: 'push',
1777
+ clientClock: 1,
1778
+ diff: { [newPage.id]: ['put', newPage] },
1779
+ } as TLPushRequest<TLRecord>)
1780
+
1781
+ expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
1782
+ expect(sentMessages(socketB)[0]).toEqual({
1783
+ type: 'data',
1784
+ data: [{ type: 'patch', diff: { [newPage.id]: ['put', newPage] }, serverClock: 1 }],
1785
+ })
1786
+
1787
+ // Messages within the debounce interval are buffered
1788
+ room.handleMessage('a', {
1789
+ type: 'push',
1790
+ clientClock: 2,
1791
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
1792
+ } as TLPushRequest<TLRecord>)
1793
+ room.handleMessage('a', {
1794
+ type: 'push',
1795
+ clientClock: 3,
1796
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v3'] }] },
1797
+ } as TLPushRequest<TLRecord>)
1798
+
1799
+ expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
1800
+
1801
+ // ...and flushed together as a single data message
1802
+ vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
1803
+
1804
+ expect(socketB.sendMessage).toHaveBeenCalledTimes(2)
1805
+ expect(sentMessages(socketB)[1]).toEqual({
1806
+ type: 'data',
1807
+ data: [
1808
+ {
1809
+ type: 'patch',
1810
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
1811
+ serverClock: 2,
1812
+ },
1813
+ {
1814
+ type: 'patch',
1815
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v3'] }] },
1816
+ serverClock: 3,
1817
+ },
1818
+ ],
1819
+ })
1820
+ })
1821
+
1822
+ it('[RB1] the flushed data array is handed off to the socket, not truncated in place', () => {
1823
+ vi.useFakeTimers()
1824
+ const { room, socketB } = setupTwoSessions()
1825
+ const newPage = makePage('page_3', 'v1')
1826
+
1827
+ room.handleMessage('a', {
1828
+ type: 'push',
1829
+ clientClock: 1,
1830
+ diff: { [newPage.id]: ['put', newPage] },
1831
+ } as TLPushRequest<TLRecord>)
1832
+ room.handleMessage('a', {
1833
+ type: 'push',
1834
+ clientClock: 2,
1835
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
1836
+ } as TLPushRequest<TLRecord>)
1837
+ room.handleMessage('a', {
1838
+ type: 'push',
1839
+ clientClock: 3,
1840
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v3'] }] },
1841
+ } as TLPushRequest<TLRecord>)
1842
+
1843
+ // capture the raw message object without cloning, as a socket that defers
1844
+ // serialization would observe it
1845
+ const rawMessages: any[] = []
1846
+ socketB.sendMessage.mockImplementation((msg: any) => rawMessages.push(msg))
1847
+ vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
1848
+
1849
+ expect(rawMessages).toHaveLength(1)
1850
+ expect(rawMessages[0].data).toHaveLength(2)
1851
+ })
1852
+
1853
+ it('[RB2] a custom message flushes buffered data messages first, preserving order', () => {
1854
+ vi.useFakeTimers()
1855
+ const { room, socketB } = setupTwoSessions()
1856
+ const newPage = makePage('page_3', 'v1')
1857
+
1858
+ room.handleMessage('a', {
1859
+ type: 'push',
1860
+ clientClock: 1,
1861
+ diff: { [newPage.id]: ['put', newPage] },
1862
+ } as TLPushRequest<TLRecord>)
1863
+ room.handleMessage('a', {
1864
+ type: 'push',
1865
+ clientClock: 2,
1866
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
1867
+ } as TLPushRequest<TLRecord>)
1868
+
1869
+ // One immediate data message, one buffered
1870
+ expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
1871
+
1872
+ room.sendCustomMessage('b', 'hello')
1873
+
1874
+ const messages = sentMessages(socketB) as any[]
1875
+ expect(messages.map((m) => m.type)).toEqual(['data', 'data', 'custom'])
1876
+ expect(messages[1].data[0].diff).toEqual({
1877
+ [newPage.id]: ['patch', { name: ['put', 'v2'] }],
1878
+ })
1879
+ expect(messages[2]).toEqual({ type: 'custom', data: 'hello' })
1880
+ })
1881
+
1882
+ it('[RB2] pong does not flush buffered data messages', () => {
1883
+ vi.useFakeTimers()
1884
+ const { room, socketB } = setupTwoSessions()
1885
+ const newPage = makePage('page_3', 'v1')
1886
+
1887
+ room.handleMessage('a', {
1888
+ type: 'push',
1889
+ clientClock: 1,
1890
+ diff: { [newPage.id]: ['put', newPage] },
1891
+ } as TLPushRequest<TLRecord>)
1892
+ room.handleMessage('a', {
1893
+ type: 'push',
1894
+ clientClock: 2,
1895
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
1896
+ } as TLPushRequest<TLRecord>)
1897
+ expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
1898
+
1899
+ // B pings while it has a buffered data message: the pong arrives first
1900
+ room.handleMessage('b', { type: 'ping' })
1901
+
1902
+ let messages = sentMessages(socketB) as any[]
1903
+ expect(messages.map((m) => m.type)).toEqual(['data', 'pong'])
1904
+
1905
+ // The buffered message is delivered by the regular flush afterwards
1906
+ vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
1907
+ messages = sentMessages(socketB) as any[]
1908
+ expect(messages.map((m) => m.type)).toEqual(['data', 'pong', 'data'])
1909
+ })
1910
+
1911
+ it('[RB3] sending to a session whose socket is closed cancels that session', () => {
1912
+ const { room, socketB } = setupTwoSessions()
1913
+
1914
+ socketB.isOpen = false
1915
+
1916
+ const newPage = makePage('page_3', 'hello')
1917
+ room.handleMessage('a', {
1918
+ type: 'push',
1919
+ clientClock: 1,
1920
+ diff: { [newPage.id]: ['put', newPage] },
1921
+ } as TLPushRequest<TLRecord>)
1922
+
1923
+ expect(room.sessions.get('b')?.state).toBe(RoomSessionState.AwaitingRemoval)
1924
+ expect(socketB.sendMessage).not.toHaveBeenCalled()
1925
+ })
1926
+
1927
+ it('[RB4] a per-session migration failure during broadcast rejects only the affected session', () => {
1928
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1929
+ try {
1930
+ interface RBUser extends BaseRecord<'user', RecordId<RBUser>> {
1931
+ name: string
1932
+ }
1933
+ const RBUser = createRecordType<RBUser>('user', {
1934
+ scope: 'document',
1935
+ validator: { validate: (r) => r as RBUser },
1936
+ })
1937
+ const rbUserVersions = createMigrationIds('test.rb.user', { Touch: 1 } as const)
1938
+ const rbServerSchema = StoreSchema.create<RBUser>(
1939
+ { user: RBUser },
1940
+ {
1941
+ migrations: [
1942
+ createRecordMigrationSequence({
1943
+ sequenceId: 'test.rb.user',
1944
+ recordType: 'user',
1945
+ sequence: [
1946
+ {
1947
+ id: rbUserVersions.Touch,
1948
+ up: (r: any) => r,
1949
+ down: (r: any) => {
1950
+ if (r.name === 'explode') throw new Error('cannot down-migrate')
1951
+ return r
1952
+ },
1953
+ },
1954
+ ],
1955
+ }),
1956
+ ],
1957
+ }
1958
+ )
1959
+ const rbOldSchema = StoreSchema.create<RBUser>(
1960
+ { user: RBUser },
1961
+ {
1962
+ migrations: [
1963
+ createMigrationSequence({
1964
+ sequenceId: 'test.rb.user',
1965
+ sequence: [],
1966
+ retroactive: true,
1967
+ }),
1968
+ ],
1969
+ }
1970
+ )
1971
+
1972
+ const storage = new InMemorySyncStorage<RBUser>({
1973
+ snapshot: { documents: [], clock: 0, documentClock: 0, schema: rbServerSchema.serialize() },
1974
+ })
1975
+ const room = new TLSyncRoom<RBUser, undefined>({ schema: rbServerSchema, storage })
1976
+ disposables.push(() => room.close())
1977
+
1978
+ const oldSocket = connectSession(room, 'old', { schema: rbOldSchema.serialize() })
1979
+ const peerSocket = connectSession(room, 'peer')
1980
+ const pusherSocket = connectSession(room, 'pusher')
1981
+
1982
+ const user = RBUser.create({ id: RBUser.createId('boom'), name: 'explode' })
1983
+ room.handleMessage('pusher', {
1984
+ type: 'push',
1985
+ clientClock: 1,
1986
+ diff: { [user.id]: ['put', user] },
1987
+ } as TLPushRequest<RBUser>)
1988
+
1989
+ // The old client could not down-migrate the record and was rejected
1990
+ expect(room.sessions.get('old')).toBeUndefined()
1991
+ expect(oldSocket.close).toHaveBeenCalledWith(
1992
+ TLSyncErrorCloseEventCode,
1993
+ TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
1994
+ )
1995
+
1996
+ // The broadcast proceeded for the same-version session
1997
+ expect(room.sessions.get('peer')?.state).toBe(RoomSessionState.Connected)
1998
+ expect(sentDataMessages(peerSocket)).toEqual([
1999
+ { type: 'patch', diff: { [user.id]: ['put', user] }, serverClock: 1 },
2000
+ ])
2001
+
2002
+ // And the pusher got its commit
2003
+ expect(sentDataMessages(pusherSocket)).toEqual([
2004
+ { type: 'push_result', clientClock: 1, serverClock: 1, action: 'commit' },
2005
+ ])
2006
+ } finally {
2007
+ consoleSpy.mockRestore()
2008
+ }
2009
+ })
2010
+
2011
+ it('[RB5] sends a custom message to a connected client', async () => {
2012
+ type Presence = UnknownRecord & { typeName: 'presence' }
2013
+ const presenceType = createRecordType<Presence>('presence', {
2014
+ scope: 'presence',
2015
+ validator: { validate: (record) => record as Presence },
2016
+ })
2017
+ const customSchema = StoreSchema.create<Presence>({ presence: presenceType })
2018
+
2019
+ const store = new Store<any, any>({ schema: customSchema, props: {} })
2020
+
2021
+ const sessionId = 'test-session-1'
2022
+ const server = new TestServer(customSchema)
2023
+ const socketPair = new TestSocketPair(sessionId, server)
2024
+ socketPair.connect()
2025
+
2026
+ const onCustomMessageReceived = vi.fn()
2027
+ const client = new TLSyncClient({
2028
+ store,
2029
+ socket: socketPair.clientSocket,
2030
+ onCustomMessageReceived,
2031
+ onLoad: vi.fn(),
2032
+ onSyncError: vi.fn(),
2033
+ presence: atom('', null),
2034
+ })
2035
+ disposables.push(() => client.close())
2036
+ await socketPair.flushAllEvents()
2037
+ server.room.sendCustomMessage(sessionId, 'hello world')
2038
+ await socketPair.flushAllEvents()
2039
+ expect(onCustomMessageReceived.mock.lastCall).toEqual(['hello world'])
2040
+ })
2041
+
2042
+ it('[RB5] sending a custom message to an unknown or not-yet-connected session warns and does nothing', () => {
2043
+ const warn = vi.fn()
2044
+ const { room } = makeRoom({ log: { warn } })
2045
+
2046
+ room.sendCustomMessage('nobody', 'hello')
2047
+ expect(warn).toHaveBeenCalledWith('Tried to send message to unknown session', 'custom')
2048
+
2049
+ warn.mockClear()
2050
+ const socket = makeSocket()
2051
+ room.handleNewSession({ sessionId: 'pending', socket, meta: undefined, isReadonly: false })
2052
+ room.sendCustomMessage('pending', 'hello')
2053
+ expect(warn).toHaveBeenCalledWith('Tried to send message to disconnected client', 'custom')
2054
+ expect(socket.sendMessage).not.toHaveBeenCalled()
2055
+ })
2056
+ })
2057
+
2058
+ describe('26. Session lifecycle (SES)', () => {
2059
+ it('[SES1][SES2][SES3] an idle connected session is cancelled, kept for a grace period, then removed', () => {
2060
+ vi.useFakeTimers()
2061
+ const { room } = makeRoom({ clientTimeout: SESSION_IDLE_TIMEOUT })
2062
+ const socket = connectSession(room, 'idle')
2063
+ const presenceId = room.sessions.get('idle')!.presenceId
2064
+ const removed = vi.fn()
2065
+ const becameEmpty = vi.fn()
2066
+ room.events.on('session_removed', removed)
2067
+ room.events.on('room_became_empty', becameEmpty)
2068
+
2069
+ // Shift the clock past the idle timeout without firing the interval so we
2070
+ // can observe the prune pass directly
2071
+ vi.setSystemTime(Date.now() + SESSION_IDLE_TIMEOUT + 1)
2072
+ room.pruneSessions()
2073
+
2074
+ const session = room.sessions.get('idle')!
2075
+ expect(session.state).toBe(RoomSessionState.AwaitingRemoval)
2076
+ // [SES2] presence id is kept for a quick reconnect, and the socket is closed
2077
+ expect(session.presenceId).toBe(presenceId)
2078
+ expect(socket.isOpen).toBe(false)
2079
+ expect(removed).not.toHaveBeenCalled()
2080
+
2081
+ // Not removed before the grace period has elapsed
2082
+ vi.setSystemTime(Date.now() + 1001)
2083
+ room.pruneSessions()
2084
+ expect(room.sessions.get('idle')?.state).toBe(RoomSessionState.AwaitingRemoval)
2085
+
2086
+ // [SES1] removed once it has been awaiting removal for SESSION_REMOVAL_WAIT_TIME
2087
+ vi.setSystemTime(Date.now() + SESSION_REMOVAL_WAIT_TIME + 1)
2088
+ room.pruneSessions()
2089
+ expect(room.sessions.size).toBe(0)
2090
+ // [SES3] events fire on removal
2091
+ expect(removed).toHaveBeenCalledWith({ sessionId: 'idle', meta: undefined })
2092
+ expect(becameEmpty).toHaveBeenCalledTimes(1)
2093
+ })
2094
+
2095
+ it('[SES1] a session that never sends a connect message is removed after SESSION_START_WAIT_TIME', () => {
2096
+ vi.useFakeTimers()
2097
+ const { room } = makeRoom()
2098
+ room.handleNewSession({
2099
+ sessionId: 'never-connects',
2100
+ socket: makeSocket(),
2101
+ meta: undefined,
2102
+ isReadonly: false,
2103
+ })
2104
+
2105
+ vi.setSystemTime(Date.now() + SESSION_START_WAIT_TIME + 1)
2106
+ room.pruneSessions()
2107
+
2108
+ expect(room.sessions.size).toBe(0)
2109
+ })
2110
+
2111
+ it('[SES1] a connected session whose socket has closed is cancelled on prune', () => {
2112
+ const { room } = makeRoom()
2113
+ const socket = connectSession(room, 's1')
2114
+
2115
+ socket.isOpen = false
2116
+ room.pruneSessions()
2117
+
2118
+ expect(room.sessions.get('s1')?.state).toBe(RoomSessionState.AwaitingRemoval)
2119
+ })
2120
+
2121
+ it('[SES2] handleClose moves the session to AwaitingRemoval keeping its presence id and meta', () => {
2122
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
2123
+ const room = new TLSyncRoom<TLRecord, { userId: string }>({ schema, storage })
2124
+ disposables.push(() => room.close())
2125
+
2126
+ const socket = makeSocket()
2127
+ room.handleNewSession({
2128
+ sessionId: 's1',
2129
+ socket,
2130
+ meta: { userId: 'user-1' },
2131
+ isReadonly: false,
2132
+ })
2133
+ room.handleMessage('s1', {
2134
+ type: 'connect',
2135
+ connectRequestId: 'connect-s1',
1399
2136
  lastServerClock: 0,
1400
2137
  protocolVersion: getTlsyncProtocolVersion(),
1401
2138
  schema: room.serializedSchema,
1402
- type: 'connect',
2139
+ } satisfies TLConnectRequest)
2140
+ const presenceId = room.sessions.get('s1')!.presenceId
2141
+
2142
+ room.handleClose('s1')
2143
+
2144
+ const session = room.sessions.get('s1')!
2145
+ expect(session.state).toBe(RoomSessionState.AwaitingRemoval)
2146
+ expect(session.presenceId).toBe(presenceId)
2147
+ expect(session.meta).toEqual({ userId: 'user-1' })
2148
+ expect(socket.close).toHaveBeenCalled()
2149
+ })
2150
+
2151
+ it('[SES3] removing a session deletes its presence record and broadcasts the deletion to everyone', () => {
2152
+ const { room } = makeRoom()
2153
+ connectSession(room, 'a')
2154
+ const socketB = connectSession(room, 'b')
2155
+ const presenceIdA = room.sessions.get('a')!.presenceId!
2156
+
2157
+ room.handleMessage('a', {
2158
+ type: 'push',
2159
+ clientClock: 1,
2160
+ presence: presencePut('Leaving Soon'),
2161
+ } as TLPushRequest<TLRecord>)
2162
+ expect(room.presenceStore.get(presenceIdA)).toBeDefined()
2163
+
2164
+ // Flush B's debounced data messages and start from a clean slate
2165
+ room._flushDataMessages('b')
2166
+ clearSocket(socketB)
2167
+
2168
+ room.rejectSession('a')
2169
+
2170
+ expect(room.presenceStore.get(presenceIdA)).toBeUndefined()
2171
+ expect(sentDataMessages(socketB)).toEqual([
2172
+ { type: 'patch', diff: { [presenceIdA]: ['remove'] }, serverClock: 0 },
2173
+ ])
2174
+ })
2175
+
2176
+ it('[SES4] legacy protocol clients are rejected with a mapped incompatibility_error and no close code', () => {
2177
+ const cases = [
2178
+ [TLSyncErrorCloseEventReason.CLIENT_TOO_OLD, 'clientTooOld'],
2179
+ [TLSyncErrorCloseEventReason.SERVER_TOO_OLD, 'serverTooOld'],
2180
+ [TLSyncErrorCloseEventReason.INVALID_RECORD, 'invalidRecord'],
2181
+ [TLSyncErrorCloseEventReason.FORBIDDEN, 'invalidOperation'],
2182
+ ] as const
2183
+
2184
+ for (const [reason, legacyReason] of cases) {
2185
+ const { room } = makeRoom()
2186
+ // Protocol version 6 clients require legacy rejection
2187
+ const socket = connectSession(room, 'legacy', { protocolVersion: 6 })
2188
+
2189
+ room.rejectSession('legacy', reason)
2190
+
2191
+ expect(sentMessages(socket)).toEqual([
2192
+ { type: 'incompatibility_error', reason: legacyReason },
2193
+ ])
2194
+ expect(socket.close).toHaveBeenCalledWith()
2195
+ expect(room.sessions.size).toBe(0)
2196
+ }
2197
+ })
2198
+
2199
+ it('[SES4] modern clients are rejected by closing the socket with code 4099 and the reason', () => {
2200
+ const { room } = makeRoom()
2201
+ const socket = connectSession(room, 'modern')
2202
+
2203
+ room.rejectSession('modern', TLSyncErrorCloseEventReason.FORBIDDEN)
2204
+
2205
+ expect(sentMessages(socket)).toEqual([])
2206
+ expect(socket.close).toHaveBeenCalledWith(
2207
+ TLSyncErrorCloseEventCode,
2208
+ TLSyncErrorCloseEventReason.FORBIDDEN
2209
+ )
2210
+ expect(room.sessions.size).toBe(0)
2211
+ })
2212
+
2213
+ it('[HS2][SES5] sets supportsStringAppend to false for protocol version 7', () => {
2214
+ const { room } = makeRoom()
2215
+ connectSession(room, 'v7-session', { protocolVersion: 7 })
2216
+
2217
+ const session = room.sessions.get('v7-session')
2218
+ expect(session?.state).toBe(RoomSessionState.Connected)
2219
+ if (session?.state === RoomSessionState.Connected) {
2220
+ expect(session.supportsStringAppend).toBe(false)
2221
+ }
2222
+ })
2223
+
2224
+ it('[HS2][SES5] sets supportsStringAppend to true for protocol version 8', () => {
2225
+ const { room } = makeRoom()
2226
+ connectSession(room, 'v8-session', { protocolVersion: getTlsyncProtocolVersion() })
2227
+
2228
+ const session = room.sessions.get('v8-session')
2229
+ expect(session?.state).toBe(RoomSessionState.Connected)
2230
+ if (session?.state === RoomSessionState.Connected) {
2231
+ expect(session.supportsStringAppend).toBe(true)
2232
+ }
2233
+ })
2234
+
2235
+ it('[SES5] getCanEmitStringAppend returns false when any connected client lacks string append support', () => {
2236
+ const { room } = makeRoom()
2237
+
2238
+ connectSession(room, 'v8-client', { protocolVersion: 8 })
2239
+
2240
+ // With only a v8 client, should be true
2241
+ expect(room.getCanEmitStringAppend()).toBe(true)
2242
+
2243
+ connectSession(room, 'v7-client', { protocolVersion: 7 })
2244
+
2245
+ // With mixed clients, should be false
2246
+ expect(room.getCanEmitStringAppend()).toBe(false)
2247
+ })
2248
+
2249
+ it('[SES6] handleResumedSession registers a session directly in Connected state and restores its presence', () => {
2250
+ const { room } = makeRoom()
2251
+ const socket = makeSocket()
2252
+ const presenceId = InstancePresenceRecordType.createId('resumed')
2253
+ const presenceRecord = InstancePresenceRecordType.create({
2254
+ id: presenceId,
2255
+ currentPageId: pageRecord.id,
2256
+ userId: createUserId('resumed'),
2257
+ userName: 'Resumed',
1403
2258
  })
1404
2259
 
1405
- // Session should be connected
1406
- expect(room.sessions.get(sessionId)?.state).toBe('connected')
2260
+ room.handleResumedSession({
2261
+ sessionId: 'resumed',
2262
+ socket,
2263
+ meta: undefined,
2264
+ isReadonly: false,
2265
+ serializedSchema: room.serializedSchema,
2266
+ presenceId,
2267
+ presenceRecord,
2268
+ requiresLegacyRejection: false,
2269
+ supportsStringAppend: true,
2270
+ })
2271
+
2272
+ const session = room.sessions.get('resumed')! as any
2273
+ expect(session.state).toBe(RoomSessionState.Connected)
2274
+ // requiresDownMigrations is recomputed from the supplied schema
2275
+ expect(session.requiresDownMigrations).toBe(false)
2276
+ // the presence record is restored into the presence store
2277
+ expect(room.presenceStore.get(presenceId)).toEqual(presenceRecord)
2278
+
2279
+ // the resumed session handles messages like any connected session
2280
+ room.handleMessage('resumed', { type: 'ping' })
2281
+ expect(socket.__lastMessage).toEqual({ type: 'pong' })
2282
+ })
2283
+
2284
+ it('[SES7] a message from an unknown session id logs a warning and is ignored', () => {
2285
+ const warn = vi.fn()
2286
+ const { room } = makeRoom({ log: { warn } })
2287
+
2288
+ room.handleMessage('unknown-session', { type: 'ping' })
2289
+
2290
+ expect(warn).toHaveBeenCalledWith('Received message from unknown session')
1407
2291
  })
1408
2292
  })