@tldraw/sync-core 5.1.1 → 5.2.0-canary.05c017c18b15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/DOCS.md +662 -0
  2. package/README.md +9 -1
  3. package/dist-cjs/index.js +1 -1
  4. package/dist-cjs/lib/TLSocketRoom.js +4 -1
  5. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  6. package/dist-cjs/lib/TLSyncClient.js +2 -3
  7. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  8. package/dist-cjs/lib/TLSyncRoom.js +3 -2
  9. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  10. package/dist-esm/index.mjs +1 -1
  11. package/dist-esm/lib/TLSocketRoom.mjs +4 -1
  12. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  13. package/dist-esm/lib/TLSyncClient.mjs +2 -4
  14. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  15. package/dist-esm/lib/TLSyncRoom.mjs +3 -2
  16. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  17. package/package.json +9 -8
  18. package/src/lib/ClientWebSocketAdapter.test.ts +486 -508
  19. package/src/lib/DurableObjectSqliteSyncWrapper.test.ts +102 -0
  20. package/src/lib/InMemorySyncStorage.test.ts +294 -0
  21. package/src/lib/MicrotaskNotifier.test.ts +79 -254
  22. package/src/lib/{NodeSqliteSyncWrapper.test.ts → NodeSqliteWrapper.test.ts} +29 -25
  23. package/src/lib/SQLiteSyncStorage.test.ts +239 -0
  24. package/src/lib/ServerSocketAdapter.test.ts +60 -199
  25. package/src/lib/TLSocketRoom.ts +6 -1
  26. package/src/lib/TLSyncClient.test.ts +1127 -846
  27. package/src/lib/TLSyncClient.ts +4 -4
  28. package/src/lib/TLSyncRoom.ts +5 -2
  29. package/src/lib/TLSyncStorage.test.ts +225 -0
  30. package/src/lib/chunk.test.ts +372 -0
  31. package/src/lib/diff.test.ts +885 -0
  32. package/src/lib/interval.test.ts +43 -0
  33. package/src/lib/protocol.test.ts +43 -0
  34. package/src/lib/recordDiff.test.ts +159 -0
  35. package/src/test/TLSocketRoom.test.ts +1109 -894
  36. package/src/test/TLSyncRoom.test.ts +1735 -893
  37. package/src/test/storageContractSuite.ts +618 -0
  38. package/src/test/upgradeDowngrade.test.ts +137 -37
  39. package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
  40. package/src/lib/RoomSession.test.ts +0 -101
  41. package/src/lib/computeTombstonePruning.test.ts +0 -352
  42. package/src/lib/server-types.test.ts +0 -44
  43. package/src/test/InMemorySyncStorage.test.ts +0 -1780
  44. package/src/test/SQLiteSyncStorage.test.ts +0 -1485
  45. package/src/test/chunk.test.ts +0 -385
  46. package/src/test/customMessages.test.ts +0 -36
  47. package/src/test/diff.test.ts +0 -784
  48. package/src/test/presenceMode.test.ts +0 -149
  49. package/src/test/validation.test.ts +0 -186
@@ -1,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,635 +653,624 @@ 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
- }),
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)
718
713
  })
714
+ storage.tombstoneHistoryStartsAtClock.set(storage.getClock())
719
715
 
720
- const clockBefore = storage.getClock()
716
+ await Promise.resolve()
717
+ await Promise.resolve()
721
718
 
722
- // Create room - should not modify anything since schema is current
723
- const _room = new TLSyncRoom<TLRecord, undefined>({
724
- schema,
725
- storage,
726
- })
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)
724
+ })
727
725
 
728
- // Clock should not have changed since no migration was needed
729
- expect(storage.getClock()).toBe(clockBefore)
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)
730
739
 
731
- // Documents should be unchanged
732
- expect(storage.getSnapshot().documents.length).toBe(2)
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)
733
745
  })
734
746
 
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
- },
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')
752
+
753
+ vi.advanceTimersByTime(10 * SESSION_IDLE_TIMEOUT)
754
+
755
+ expect(room.sessions.get('idle')?.state).toBe(RoomSessionState.Connected)
756
+ room.close()
743
757
  }
758
+ })
744
759
 
745
- const storage = new InMemorySyncStorage<TLRecord>({
746
- snapshot: makeSnapshot([...records, oldArrow as any], {
747
- schema: oldSerializedSchema,
748
- }),
749
- })
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')
750
764
 
751
- // Verify old schema before room creation
752
- expect(storage.schema.get()).toEqual(oldSerializedSchema)
765
+ expect(room.isClosed()).toBe(false)
753
766
 
754
- // Create room which triggers migration
755
- const _room = new TLSyncRoom<TLRecord, undefined>({
756
- schema,
757
- storage,
758
- })
767
+ room.close()
759
768
 
760
- // Schema should now be updated to current version
761
- expect(storage.schema.get()).toEqual(schema.serialize())
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)
762
774
  })
763
775
  })
764
776
 
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
-
770
- const socket = makeSocket()
771
- const sessionId = 'v7-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()
772
780
 
773
781
  room.handleNewSession({
774
- sessionId,
775
- socket,
782
+ sessionId: 's1',
783
+ socket: makeSocket(),
776
784
  meta: undefined,
777
785
  isReadonly: false,
778
786
  })
787
+ const presenceId = room.sessions.get('s1')!.presenceId
788
+ expect(presenceId).not.toBeNull()
779
789
 
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',
790
+ // Re-register the same session id (e.g. a quick reconnect)
791
+ room.handleNewSession({
792
+ sessionId: 's1',
793
+ socket: makeSocket(),
794
+ meta: undefined,
795
+ isReadonly: false,
787
796
  })
797
+ expect(room.sessions.get('s1')!.presenceId).toBe(presenceId)
788
798
 
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
- }
799
+ // A different session gets a different presence id
800
+ room.handleNewSession({
801
+ sessionId: 's2',
802
+ socket: makeSocket(),
803
+ meta: undefined,
804
+ isReadonly: false,
805
+ })
806
+ expect(room.sessions.get('s2')!.presenceId).not.toBe(presenceId)
794
807
  })
795
808
 
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 })
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
+ }
799
819
 
820
+ const { room } = makeRoom()
800
821
  const socket = makeSocket()
801
- const sessionId = 'v8-session'
802
822
 
803
823
  room.handleNewSession({
804
- sessionId,
824
+ sessionId: 'old-client-session',
805
825
  socket,
806
826
  meta: undefined,
807
827
  isReadonly: false,
808
828
  })
809
829
 
810
- room.handleMessage(sessionId, {
830
+ room.handleMessage('old-client-session', {
811
831
  connectRequestId: 'connect-1',
812
832
  lastServerClock: 0,
813
- protocolVersion: getTlsyncProtocolVersion(), // version 8
814
- schema: room.serializedSchema,
833
+ protocolVersion: getTlsyncProtocolVersion(),
834
+ schema: oldSerializedSchema,
815
835
  type: 'connect',
816
836
  })
817
837
 
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
- }
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)
840
+
841
+ // Socket should be closed with CLIENT_TOO_OLD
842
+ expect(socket.close).toHaveBeenCalledWith(
843
+ TLSyncErrorCloseEventCode,
844
+ TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
845
+ )
823
846
  })
824
847
 
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 })
848
+ it('[HS4] handles a client with lastServerClock greater than the current documentClock', () => {
849
+ const { room } = makeRoom({ snapshot: makeSnapshot(records, { documentClock: 10 }) })
828
850
 
829
- const socketV7 = makeSocket()
830
- const socketV8 = makeSocket()
851
+ // Client claims to have clock 100, but server is only at 10
852
+ const socket = connectSession(room, 'future-clock-client', {
853
+ lastServerClock: 100,
854
+ clear: false,
855
+ })
831
856
 
832
- // Connect v8 client first
833
- room.handleNewSession({
834
- sessionId: 'v8-client',
835
- socket: socketV8,
836
- meta: undefined,
837
- isReadonly: false,
857
+ // Session should still connect successfully
858
+ expect(room.sessions.get('future-clock-client')?.state).toBe(RoomSessionState.Connected)
859
+
860
+ // Check the connect response
861
+ const connectResponse = socket.__lastMessage
862
+ expect(connectResponse?.type).toBe('connect')
863
+ if (connectResponse?.type === 'connect') {
864
+ // Should receive wipe_all to reset client state
865
+ expect(connectResponse.hydrationType).toBe('wipe_all')
866
+ // Should receive all documents
867
+ expect(Object.keys(connectResponse.diff).length).toBe(2)
868
+ }
869
+ })
870
+
871
+ it('[HS4][HS5] provides all documents when the client has a future clock', () => {
872
+ const { room } = makeRoom({
873
+ snapshot: makeSnapshot(records, {
874
+ documentClock: 5,
875
+ documents: [
876
+ { state: records[0], lastChangedClock: 3 },
877
+ { state: records[1], lastChangedClock: 5 },
878
+ ],
879
+ }),
838
880
  })
839
- room.handleMessage('v8-client', {
840
- connectRequestId: 'connect-v8',
841
- lastServerClock: 0,
842
- protocolVersion: 8,
843
- schema: room.serializedSchema,
844
- type: 'connect',
881
+
882
+ const socket = connectSession(room, 'future-client', {
883
+ lastServerClock: 999,
884
+ clear: false,
845
885
  })
846
886
 
847
- // With only v8 client, should be true
848
- expect(room.getCanEmitStringAppend()).toBe(true)
887
+ const connectResponse = socket.__lastMessage
888
+ expect(connectResponse?.type).toBe('connect')
889
+ if (connectResponse?.type === 'connect') {
890
+ // Both documents should be included regardless of their lastChangedClock
891
+ expect(connectResponse.diff[records[0].id]).toBeDefined()
892
+ expect(connectResponse.diff[records[1].id]).toBeDefined()
893
+ }
894
+ })
849
895
 
850
- // Connect v7 client
851
- room.handleNewSession({
852
- sessionId: 'v7-client',
853
- socket: socketV7,
854
- meta: undefined,
855
- isReadonly: false,
896
+ it('[HS4][HS5] the connect response carries wipe_presence and other sessions presence, excluding the connecting session own presence', () => {
897
+ const { room, storage } = makeRoom()
898
+
899
+ connectSession(room, 'a')
900
+ room.handleMessage('a', {
901
+ type: 'push',
902
+ clientClock: 0,
903
+ presence: presencePut('Alice'),
904
+ } as TLPushRequest<TLRecord>)
905
+ const aPresenceId = room.sessions.get('a')!.presenceId!
906
+
907
+ // B connects and sees A's presence in the connect diff
908
+ const socketB = connectSession(room, 'b', {
909
+ isReadonly: true,
910
+ lastServerClock: storage.getClock(),
911
+ clear: false,
856
912
  })
857
- room.handleMessage('v7-client', {
858
- connectRequestId: 'connect-v7',
859
- lastServerClock: 0,
860
- protocolVersion: 7,
861
- schema: room.serializedSchema,
862
- type: 'connect',
913
+ const response = socketB.__lastMessage as any
914
+ expect(response.type).toBe('connect')
915
+ expect(response.connectRequestId).toBe('connect-b')
916
+ expect(response.hydrationType).toBe('wipe_presence')
917
+ expect(response.isReadonly).toBe(true)
918
+ expect(response.serverClock).toBe(storage.getClock())
919
+ expect(response.diff).toEqual({
920
+ [aPresenceId]: ['put', expect.objectContaining({ id: aPresenceId, userName: 'Alice' })],
863
921
  })
864
922
 
865
- // With mixed clients, should be false
866
- expect(room.getCanEmitStringAppend()).toBe(false)
923
+ // B pushes presence, then A reconnects under the same session id
924
+ room.handleMessage('b', {
925
+ type: 'push',
926
+ clientClock: 0,
927
+ presence: presencePut('Bob'),
928
+ } as TLPushRequest<TLRecord>)
929
+ const bPresenceId = room.sessions.get('b')!.presenceId!
930
+
931
+ room.handleClose('a')
932
+ const socketA2 = connectSession(room, 'a', {
933
+ lastServerClock: storage.getClock(),
934
+ clear: false,
935
+ })
936
+ const response2 = socketA2.__lastMessage as any
937
+ expect(response2.type).toBe('connect')
938
+
939
+ // A's own (still stored) presence is excluded, B's presence is included
940
+ expect(room.presenceStore.get(aPresenceId)).toBeDefined()
941
+ expect(response2.diff[aPresenceId]).toBeUndefined()
942
+ expect(response2.diff[bPresenceId]).toEqual([
943
+ 'put',
944
+ expect.objectContaining({ id: bPresenceId, userName: 'Bob' }),
945
+ ])
867
946
  })
868
- })
869
947
 
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 })
948
+ it('[HS6] accepts a client with the same schema version and moves the session to Connected', () => {
949
+ const { room } = makeRoom()
950
+ const socket = connectSession(room, 'current-client-session', { clear: false })
876
951
 
877
- const socket = makeSocket()
878
- const sessionId = 'presence-test'
952
+ expect(room.sessions.get('current-client-session')?.state).toBe(RoomSessionState.Connected)
953
+ expect(socket.__lastMessage?.type).toBe('connect')
954
+ })
955
+ })
879
956
 
880
- room.handleNewSession({
881
- sessionId,
882
- socket,
883
- meta: undefined,
884
- isReadonly: false,
885
- })
957
+ describe('24. Push handling (RP)', () => {
958
+ function setupTwoSessions() {
959
+ const { room, storage } = makeRoom()
960
+ const socketA = connectSession(room, 'a')
961
+ const socketB = connectSession(room, 'b')
962
+ return { room, storage, socketA, socketB }
963
+ }
886
964
 
887
- room.handleMessage(sessionId, {
888
- connectRequestId: 'connect-1',
889
- lastServerClock: 10,
890
- protocolVersion: getTlsyncProtocolVersion(),
891
- schema: room.serializedSchema,
892
- type: 'connect',
893
- })
965
+ it('[RP1] ignores pushes from sessions that have not completed the handshake', () => {
966
+ const { room, storage } = makeRoom()
967
+ const socket = makeSocket()
968
+ room.handleNewSession({ sessionId: 's1', socket, meta: undefined, isReadonly: false })
894
969
 
895
970
  const clockBefore = storage.getClock()
971
+ const newPage = makePage('early_page', 'Too early')
896
972
 
897
- // Send presence update
898
- room.handleMessage(sessionId, {
973
+ room.handleMessage('s1', {
899
974
  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
- ],
975
+ clientClock: 0,
976
+ diff: { [newPage.id]: ['put', newPage] },
911
977
  } as TLPushRequest<TLRecord>)
912
978
 
913
- // Document clock should not have changed
914
979
  expect(storage.getClock()).toBe(clockBefore)
980
+ expect(storage.documents.get(newPage.id)).toBeUndefined()
981
+ expect(socket.sendMessage).not.toHaveBeenCalled()
982
+ expect(room.sessions.get('s1')?.state).toBe(RoomSessionState.AwaitingConnectMessage)
915
983
  })
916
984
 
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 })
985
+ it('[RP2] rejects the session when a push contains a record with an unknown type', () => {
986
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
987
+ try {
988
+ const { room } = makeRoom()
989
+ const socket = connectSession(room, 'invalid-record-session')
990
+
991
+ // Try to push a record with an unknown type
992
+ room.handleMessage('invalid-record-session', {
993
+ type: 'push',
994
+ clientClock: 1,
995
+ diff: {
996
+ 'unknown:record': [
997
+ 'put',
998
+ {
999
+ id: 'unknown:record',
1000
+ typeName: 'unknown_type_that_does_not_exist',
1001
+ } as any,
1002
+ ],
1003
+ },
1004
+ } as TLPushRequest<TLRecord>)
1005
+
1006
+ // Session should be rejected/removed
1007
+ expect(room.sessions.get('invalid-record-session')).toBeUndefined()
1008
+ expect(socket.close).toHaveBeenCalledWith(
1009
+ TLSyncErrorCloseEventCode,
1010
+ TLSyncErrorCloseEventReason.INVALID_RECORD
1011
+ )
1012
+ } finally {
1013
+ consoleSpy.mockRestore()
1014
+ }
1015
+ })
920
1016
 
921
- const socket = makeSocket()
922
- const sessionId = 'presence-test'
923
-
924
- room.handleNewSession({
925
- sessionId,
926
- socket,
927
- meta: undefined,
928
- isReadonly: false,
929
- })
930
-
931
- room.handleMessage(sessionId, {
932
- connectRequestId: 'connect-1',
933
- lastServerClock: 0,
934
- protocolVersion: getTlsyncProtocolVersion(),
935
- schema: room.serializedSchema,
936
- type: 'connect',
1017
+ describe('schema validation of pushed records', () => {
1018
+ interface Book {
1019
+ typeName: 'book'
1020
+ id: RecordId<Book>
1021
+ title: string
1022
+ }
1023
+ const Book = createRecordType<Book>('book', {
1024
+ scope: 'document',
1025
+ validator: {
1026
+ validate: (record: unknown): Book => {
1027
+ if (typeof record !== 'object' || record === null) {
1028
+ throw new Error('Expected object')
1029
+ }
1030
+ if (!('title' in record)) {
1031
+ throw new Error('Expected title')
1032
+ }
1033
+ if (typeof record.title !== 'string') {
1034
+ throw new Error('Expected title to be a string')
1035
+ }
1036
+ return record as Book
1037
+ },
1038
+ },
937
1039
  })
938
-
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()
960
-
961
- // Presence should NOT be in document storage
962
- storage.transaction((txn) => {
963
- expect(txn.get(presenceId!)).toBeUndefined()
1040
+ const BookWithoutValidator = createRecordType<Book>('book', {
1041
+ scope: 'document',
1042
+ validator: { validate: (record) => record as Book },
964
1043
  })
965
- })
966
-
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,
1044
+ type Presence = UnknownRecord & { typeName: 'presence' }
1045
+ const presenceType = createRecordType<Presence>('presence', {
1046
+ scope: 'presence',
1047
+ validator: { validate: (record) => record as Presence },
979
1048
  })
980
1049
 
981
- room.handleMessage(sessionId, {
982
- connectRequestId: 'connect-1',
983
- lastServerClock: 0,
984
- protocolVersion: getTlsyncProtocolVersion(),
985
- schema: room.serializedSchema,
986
- type: 'connect',
1050
+ const bookSchema = StoreSchema.create<Book | Presence>({ book: Book, presence: presenceType })
1051
+ const bookSchemaWithoutValidator = StoreSchema.create<Book | Presence>({
1052
+ book: BookWithoutValidator,
1053
+ presence: presenceType,
987
1054
  })
988
1055
 
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 }),
1056
+ let consoleSpy: ReturnType<typeof vi.spyOn>
1057
+ beforeEach(() => {
1058
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1022
1059
  })
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,
1060
+ afterEach(() => {
1061
+ consoleSpy.mockRestore()
1033
1062
  })
1034
1063
 
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
- })
1064
+ async function makeTestInstance() {
1065
+ const server = new TestServer(bookSchema)
1066
+ const socketPair = new TestSocketPair('test', server)
1067
+ socketPair.connect()
1043
1068
 
1044
- // Session should still connect successfully
1045
- expect(room.sessions.get(sessionId)?.state).toBe('connected')
1069
+ const flush = async () => {
1070
+ await Promise.resolve()
1071
+ while (socketPair.getNeedsFlushing()) {
1072
+ socketPair.flushClientSentEvents()
1073
+ socketPair.flushServerSentEvents()
1074
+ }
1075
+ }
1076
+ let onSyncError = vi.fn()
1077
+ const client = await new Promise<TLSyncClient<Book | Presence>>((resolve, reject) => {
1078
+ onSyncError = vi.fn(reject)
1079
+ const client = new TLSyncClient({
1080
+ store: new Store<Book | Presence, unknown>({
1081
+ schema: bookSchemaWithoutValidator,
1082
+ props: {},
1083
+ }),
1084
+ socket: socketPair.clientSocket as any,
1085
+ onLoad: resolve,
1086
+ onSyncError,
1087
+ presence: computed('', () => null),
1088
+ })
1089
+ disposables.push(() => client.close())
1090
+ flush()
1091
+ })
1046
1092
 
1047
- // Check the connect response
1048
- const connectResponse = socket.__lastMessage
1049
- expect(connectResponse?.type).toBe('connect')
1050
- if (connectResponse?.type === 'connect') {
1051
- // Should receive wipe_all to reset client state
1052
- expect(connectResponse.hydrationType).toBe('wipe_all')
1053
- // Should receive all documents
1054
- expect(Object.keys(connectResponse.diff).length).toBe(2)
1093
+ return {
1094
+ server,
1095
+ socketPair,
1096
+ client,
1097
+ flush,
1098
+ onSyncError,
1099
+ }
1055
1100
  }
1056
- })
1057
1101
 
1058
- it('provides all documents when client has future clock', () => {
1059
- const storage = new InMemorySyncStorage<TLRecord>({
1060
- snapshot: makeSnapshot(records, {
1061
- documentClock: 5,
1062
- documents: [
1063
- { state: records[0], lastChangedClock: 3 },
1064
- { state: records[1], lastChangedClock: 5 },
1065
- ],
1066
- }),
1067
- })
1068
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
1102
+ it('[RP2] rejects invalid put operations that create a new document', async () => {
1103
+ const { client, flush, onSyncError, server } = await makeTestInstance()
1069
1104
 
1070
- const socket = makeSocket()
1071
- const sessionId = 'future-client'
1105
+ const prevServerDocs = server.storage.getSnapshot().documents
1072
1106
 
1073
- room.handleNewSession({
1074
- sessionId,
1075
- socket,
1076
- meta: undefined,
1077
- isReadonly: false,
1078
- })
1107
+ client.store.put([
1108
+ {
1109
+ typeName: 'book',
1110
+ id: Book.createId('1'),
1111
+ // @ts-expect-error - deliberate invalid data
1112
+ title: 123 as string,
1113
+ },
1114
+ ])
1115
+ await flush()
1116
+
1117
+ expect(onSyncError).toHaveBeenCalledTimes(1)
1118
+ expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
1119
+ expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
1120
+ })
1121
+
1122
+ it('[RP2] rejects invalid put operations that replace an existing document', async () => {
1123
+ const { client, flush, onSyncError, server } = await makeTestInstance()
1124
+
1125
+ let prevServerDocs = server.storage.getSnapshot().documents
1126
+ const book: Book = { typeName: 'book', id: Book.createId('1'), title: 'Annihilation' }
1127
+ client.store.put([book])
1128
+ await flush()
1129
+
1130
+ expect(onSyncError).toHaveBeenCalledTimes(0)
1131
+ expect(server.storage.getSnapshot().documents).not.toStrictEqual(prevServerDocs)
1132
+ prevServerDocs = server.storage.getSnapshot().documents
1133
+
1134
+ client.socket.sendMessage({
1135
+ type: 'push',
1136
+ // @ts-expect-error clientClock is private
1137
+ clientClock: client.clientClock++,
1138
+ diff: {
1139
+ [book.id]: [
1140
+ RecordOpType.Put,
1141
+ {
1142
+ ...book,
1143
+ // @ts-expect-error - deliberate invalid data
1144
+ title: 123 as string,
1145
+ },
1146
+ ],
1147
+ },
1148
+ })
1149
+ await flush()
1079
1150
 
1080
- room.handleMessage(sessionId, {
1081
- connectRequestId: 'connect-1',
1082
- lastServerClock: 999,
1083
- protocolVersion: getTlsyncProtocolVersion(),
1084
- schema: room.serializedSchema,
1085
- type: 'connect',
1151
+ expect(onSyncError).toHaveBeenCalledTimes(1)
1152
+ expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
1153
+ expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
1086
1154
  })
1087
1155
 
1088
- const connectResponse = socket.__lastMessage
1089
- expect(connectResponse?.type).toBe('connect')
1090
- if (connectResponse?.type === 'connect') {
1091
- // Both documents should be included regardless of their lastChangedClock
1092
- expect(connectResponse.diff[records[0].id]).toBeDefined()
1093
- expect(connectResponse.diff[records[1].id]).toBeDefined()
1094
- }
1095
- })
1096
- })
1097
-
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 })
1102
-
1103
- const socket = makeSocket()
1104
- const sessionId = 'active-session'
1156
+ it('[RP2] rejects invalid patch operations', async () => {
1157
+ const { client, flush, onSyncError, server } = await makeTestInstance()
1105
1158
 
1106
- room.handleNewSession({
1107
- sessionId,
1108
- socket,
1109
- meta: undefined,
1110
- isReadonly: false,
1111
- })
1159
+ let prevServerDocs = server.storage.getSnapshot().documents
1112
1160
 
1113
- room.handleMessage(sessionId, {
1114
- connectRequestId: 'connect-1',
1115
- lastServerClock: 0,
1116
- protocolVersion: getTlsyncProtocolVersion(),
1117
- schema: room.serializedSchema,
1118
- type: 'connect',
1119
- })
1120
- ;(socket.sendMessage as any).mockClear()
1161
+ // create the book
1162
+ client.store.put([
1163
+ {
1164
+ typeName: 'book',
1165
+ id: Book.createId('1'),
1166
+ title: 'The silence of the girls',
1167
+ },
1168
+ ])
1169
+ await flush()
1121
1170
 
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,
1127
- })
1171
+ expect(onSyncError).toHaveBeenCalledTimes(0)
1172
+ expect(server.storage.getSnapshot().documents).not.toStrictEqual(prevServerDocs)
1173
+ prevServerDocs = server.storage.getSnapshot().documents
1128
1174
 
1129
- storage.transaction((txn) => {
1130
- txn.set(newPage.id, newPage)
1175
+ // update the title to be wrong
1176
+ client.store.put([
1177
+ {
1178
+ typeName: 'book',
1179
+ id: Book.createId('1'),
1180
+ // @ts-expect-error - deliberate invalid data
1181
+ title: 123 as string,
1182
+ },
1183
+ ])
1184
+ await flush()
1185
+ expect(onSyncError).toHaveBeenCalledTimes(1)
1186
+ expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
1187
+ expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
1131
1188
  })
1132
-
1133
- // Wait for onChange to fire
1134
- await Promise.resolve()
1135
-
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
- }
1148
- }
1149
- }
1150
- }
1151
-
1152
- const allDiffs = allPatches.reduce((acc, patch) => ({ ...acc, ...patch.diff }), {})
1153
- expect(allDiffs[newPage.id]).toBeDefined()
1154
1189
  })
1155
1190
 
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
- })
1162
-
1163
- const storage = new InMemorySyncStorage<TLRecord>({
1164
- snapshot: makeSnapshot([...records, extraPage]),
1165
- })
1166
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
1191
+ it('[RP3][RP7][RP8] a put of a new id stores the record, broadcasts a put, and commits', () => {
1192
+ const { storage, room, socketA, socketB } = setupTwoSessions()
1193
+ const newPage = makePage('page_3', 'my lovely page 3')
1167
1194
 
1168
- const socket = makeSocket()
1169
- const sessionId = 'active-session'
1195
+ room.handleMessage('a', {
1196
+ type: 'push',
1197
+ clientClock: 7,
1198
+ diff: { [newPage.id]: ['put', newPage] },
1199
+ } as TLPushRequest<TLRecord>)
1170
1200
 
1171
- room.handleNewSession({
1172
- sessionId,
1173
- socket,
1174
- meta: undefined,
1175
- isReadonly: false,
1176
- })
1201
+ expect(storage.documents.get(newPage.id)?.state).toEqual(newPage)
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
+ // The pusher gets a commit and never its own patch back
1204
+ expect(sentDataMessages(socketA)).toEqual([
1205
+ { type: 'push_result', clientClock: 7, serverClock: 1, action: 'commit' },
1206
+ ])
1186
1207
 
1187
- // Delete the extra page via storage
1188
- storage.transaction((txn) => {
1189
- txn.delete(extraPage.id)
1190
- })
1208
+ // The other session gets the put broadcast
1209
+ expect(sentDataMessages(socketB)).toEqual([
1210
+ { type: 'patch', diff: { [newPage.id]: ['put', newPage] }, serverClock: 1 },
1211
+ ])
1212
+ })
1191
1213
 
1192
- await Promise.resolve()
1214
+ it('[RP3][RP7] a put over an existing record broadcasts only the difference and rebases the pusher', () => {
1215
+ const { storage, room, socketA, socketB } = setupTwoSessions()
1216
+ const renamed = { ...pageRecord, name: 'renamed' }
1193
1217
 
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
- }
1218
+ room.handleMessage('a', {
1219
+ type: 'push',
1220
+ clientClock: 1,
1221
+ diff: { [pageRecord.id]: ['put', renamed] },
1222
+ } as TLPushRequest<TLRecord>)
1209
1223
 
1210
- const allDiffs = allPatches.reduce((acc, patch) => ({ ...acc, ...patch.diff }), {})
1211
- expect(allDiffs[extraPage.id]).toEqual(['remove'])
1224
+ expect(storage.documents.get(pageRecord.id)?.state).toEqual(renamed)
1225
+
1226
+ const expectedDiff = { [pageRecord.id]: ['patch', { name: ['put', 'renamed'] }] }
1227
+ expect(sentDataMessages(socketB)).toEqual([
1228
+ { type: 'patch', diff: expectedDiff, serverClock: 1 },
1229
+ ])
1230
+ expect(sentDataMessages(socketA)).toEqual([
1231
+ {
1232
+ type: 'push_result',
1233
+ clientClock: 1,
1234
+ serverClock: 1,
1235
+ action: { rebaseWithDiff: expectedDiff },
1236
+ },
1237
+ ])
1212
1238
  })
1213
- })
1214
1239
 
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 })
1220
-
1221
- const socket = makeSocket()
1222
- const sessionId = 'invalid-record-session'
1240
+ it('[RP3][RP7] a put equal to the stored record changes nothing and is discarded', () => {
1241
+ const { storage, room, socketA, socketB } = setupTwoSessions()
1223
1242
 
1224
- room.handleNewSession({
1225
- sessionId,
1226
- socket,
1227
- meta: undefined,
1228
- isReadonly: false,
1229
- })
1243
+ room.handleMessage('a', {
1244
+ type: 'push',
1245
+ clientClock: 1,
1246
+ diff: { [pageRecord.id]: ['put', { ...pageRecord }] },
1247
+ } as TLPushRequest<TLRecord>)
1230
1248
 
1231
- room.handleMessage(sessionId, {
1232
- connectRequestId: 'connect-1',
1233
- lastServerClock: 0,
1234
- protocolVersion: getTlsyncProtocolVersion(),
1235
- schema: room.serializedSchema,
1236
- type: 'connect',
1237
- })
1249
+ expect(storage.getClock()).toBe(0)
1250
+ expect(sentDataMessages(socketA)).toEqual([
1251
+ { type: 'push_result', clientClock: 1, serverClock: 0, action: 'discard' },
1252
+ ])
1253
+ expect(socketB.sendMessage).not.toHaveBeenCalled()
1254
+ })
1238
1255
 
1239
- expect(room.sessions.get(sessionId)?.state).toBe('connected')
1256
+ it('[RP4][RP7] a patch for a missing record is silently ignored and the push discarded', () => {
1257
+ const { storage, room, socketA, socketB } = setupTwoSessions()
1240
1258
 
1241
- // Try to push a record with an unknown type
1242
- room.handleMessage(sessionId, {
1259
+ room.handleMessage('a', {
1243
1260
  type: 'push',
1244
1261
  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
- },
1262
+ diff: { 'page:does_not_exist': ['patch', { name: ['put', 'whatever'] }] },
1254
1263
  } as TLPushRequest<TLRecord>)
1255
1264
 
1256
- // Session should be rejected/removed
1257
- const session = room.sessions.get(sessionId)
1258
- expect(session?.state === 'connected').toBe(false)
1259
- consoleSpy.mockRestore()
1265
+ expect(storage.getClock()).toBe(0)
1266
+ expect(room.sessions.get('a')?.state).toBe(RoomSessionState.Connected)
1267
+ expect(sentDataMessages(socketA)).toEqual([
1268
+ { type: 'push_result', clientClock: 1, serverClock: 0, action: 'discard' },
1269
+ ])
1270
+ expect(socketB.sendMessage).not.toHaveBeenCalled()
1260
1271
  })
1261
- })
1262
1272
 
1263
- describe('Migration and patch handling', () => {
1264
- it('successfully patches arrow shape with current schema', () => {
1273
+ it('[RP5] successfully patches an arrow shape with the current schema', () => {
1265
1274
  // Create a document in the room first
1266
1275
  const existingArrow: TLArrowShape = {
1267
1276
  typeName: 'shape',
@@ -1295,34 +1304,11 @@ describe('Migration and patch handling', () => {
1295
1304
  meta: {},
1296
1305
  }
1297
1306
 
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')
1307
+ const { room, storage } = makeRoom({ snapshot: makeSnapshot([...records, existingArrow]) })
1308
+ connectSession(room, 'patch-migration-session')
1323
1309
 
1324
1310
  // Patch the arrow - this should work normally
1325
- room.handleMessage(sessionId, {
1311
+ room.handleMessage('patch-migration-session', {
1326
1312
  type: 'push',
1327
1313
  clientClock: 1,
1328
1314
  diff: {
@@ -1331,78 +1317,934 @@ describe('Migration and patch handling', () => {
1331
1317
  } as TLPushRequest<TLRecord>)
1332
1318
 
1333
1319
  // Session should still be connected after valid patch
1334
- expect(room.sessions.get(sessionId)?.state).toBe('connected')
1320
+ expect(room.sessions.get('patch-migration-session')?.state).toBe(RoomSessionState.Connected)
1335
1321
 
1336
1322
  // Verify the patch was applied
1337
1323
  const patchedArrow = storage.documents.get(existingArrow.id)?.state as TLArrowShape
1338
1324
  expect(patchedArrow.props.color).toBe('red')
1339
1325
  })
1340
1326
 
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,
1327
+ it('[RP6][RP7] a remove deletes the record, writes a tombstone, and broadcasts the removal', () => {
1328
+ const { storage, room, socketA, socketB } = setupTwoSessions()
1329
+
1330
+ room.handleMessage('a', {
1331
+ type: 'push',
1332
+ clientClock: 1,
1333
+ diff: { [pageRecord.id]: ['remove'] },
1334
+ } as TLPushRequest<TLRecord>)
1335
+
1336
+ expect(storage.documents.get(pageRecord.id)).toBeUndefined()
1337
+ expect(storage.tombstones.get(pageRecord.id)).toBe(1)
1338
+
1339
+ expect(sentDataMessages(socketA)).toEqual([
1340
+ { type: 'push_result', clientClock: 1, serverClock: 1, action: 'commit' },
1341
+ ])
1342
+ expect(sentDataMessages(socketB)).toEqual([
1343
+ { type: 'patch', diff: { [pageRecord.id]: ['remove'] }, serverClock: 1 },
1344
+ ])
1345
+ })
1346
+
1347
+ it('[RP6][RP7] a remove of a missing id is ignored', () => {
1348
+ const { storage, room, socketA, socketB } = setupTwoSessions()
1349
+
1350
+ room.handleMessage('a', {
1351
+ type: 'push',
1352
+ clientClock: 1,
1353
+ diff: { 'page:does_not_exist': ['remove'] },
1354
+ } as TLPushRequest<TLRecord>)
1355
+
1356
+ expect(storage.getClock()).toBe(0)
1357
+ expect(storage.tombstones.get('page:does_not_exist')).toBeUndefined()
1358
+ expect(sentDataMessages(socketA)).toEqual([
1359
+ { type: 'push_result', clientClock: 1, serverClock: 0, action: 'discard' },
1360
+ ])
1361
+ expect(socketB.sendMessage).not.toHaveBeenCalled()
1362
+ })
1363
+
1364
+ it('[RP7] a patch that only partially applies gets rebaseWithDiff carrying the effective diff', () => {
1365
+ const { room, socketA, socketB } = setupTwoSessions()
1366
+
1367
+ // The name op has an effect, the index op is a no-op
1368
+ room.handleMessage('a', {
1369
+ type: 'push',
1370
+ clientClock: 1,
1371
+ diff: {
1372
+ [pageRecord.id]: ['patch', { name: ['put', 'renamed'], index: ['put', ZERO_INDEX_KEY] }],
1373
+ },
1374
+ } as TLPushRequest<TLRecord>)
1375
+
1376
+ const effectiveDiff = { [pageRecord.id]: ['patch', { name: ['put', 'renamed'] }] }
1377
+ expect(sentDataMessages(socketA)).toEqual([
1378
+ {
1379
+ type: 'push_result',
1380
+ clientClock: 1,
1381
+ serverClock: 1,
1382
+ action: { rebaseWithDiff: effectiveDiff },
1383
+ },
1384
+ ])
1385
+ expect(sentDataMessages(socketB)).toEqual([
1386
+ { type: 'patch', diff: effectiveDiff, serverClock: 1 },
1387
+ ])
1388
+ })
1389
+
1390
+ it('[RP7] a presence-only push gets a commit', () => {
1391
+ const { room, socketA } = setupTwoSessions()
1392
+
1393
+ room.handleMessage('a', {
1394
+ type: 'push',
1395
+ clientClock: 9,
1396
+ presence: presencePut(),
1397
+ } as TLPushRequest<TLRecord>)
1398
+
1399
+ expect(sentDataMessages(socketA)).toEqual([
1400
+ { type: 'push_result', clientClock: 9, serverClock: 0, action: 'commit' },
1401
+ ])
1402
+ })
1403
+
1404
+ it('[RP9] does not allow updates from users who are marked as readonly', () => {
1405
+ const { room, storage } = makeRoom()
1406
+ const socketA = connectSession(room, 'sessionA', { isReadonly: true })
1407
+ const socketB = connectSession(room, 'sessionB', { isReadonly: false })
1408
+ const getDoc = (id: string) => storage.documents.get(id)?.state
1409
+
1410
+ const push: TLPushRequest<any> = {
1411
+ clientClock: 0,
1412
+ diff: {
1413
+ 'page:page_3': [
1414
+ 'put',
1415
+ PageRecordType.create({
1416
+ id: 'page:page_3' as any,
1417
+ name: 'my lovely page 3',
1418
+ index: 'ab34' as IndexKey,
1419
+ }),
1420
+ ],
1350
1421
  },
1422
+ presence: undefined,
1423
+ type: 'push',
1351
1424
  }
1352
1425
 
1353
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
1426
+ // sessionA is readonly
1427
+ room.handleMessage('sessionA', push)
1428
+
1429
+ expect(getDoc('page:page_3')).toBe(undefined)
1430
+ // should tell the session to discard it
1431
+ expect(socketA.__lastMessage).toEqual({
1432
+ type: 'data',
1433
+ data: [{ type: 'push_result', clientClock: 0, serverClock: 0, action: 'discard' }],
1434
+ })
1435
+ // should not have sent anything to sessionB
1436
+ expect(socketB.__lastMessage).toBe(null)
1437
+
1438
+ // sessionB is not readonly
1439
+ room.handleMessage('sessionB', push)
1440
+
1441
+ expect(getDoc('page:page_3')).not.toBe(undefined)
1442
+
1443
+ // should tell the session to commit it
1444
+ expect(socketB.__lastMessage).toEqual({
1445
+ type: 'data',
1446
+ data: [{ type: 'push_result', clientClock: 0, serverClock: 1, action: 'commit' }],
1447
+ })
1448
+ })
1449
+
1450
+ it('[RP9][RP7][RP10] still allows presence updates from readonly users', () => {
1451
+ const { room } = makeRoom()
1452
+ const socketA = connectSession(room, 'sessionA', { isReadonly: true })
1453
+ const socketB = connectSession(room, 'sessionB', { isReadonly: false })
1454
+ const presenceIdA = room.sessions.get('sessionA')!.presenceId!
1455
+
1456
+ const presencePushRequest: TLPushRequest<any> = {
1457
+ clientClock: 0,
1458
+ diff: undefined,
1459
+ presence: [
1460
+ 'put',
1461
+ InstancePresenceRecordType.create({
1462
+ id: InstancePresenceRecordType.createId('foo'),
1463
+ currentPageId: 'page:page_2' as any,
1464
+ userId: createUserId('foo'),
1465
+ userName: 'Jimbo',
1466
+ }),
1467
+ ],
1468
+ type: 'push',
1469
+ }
1470
+
1471
+ // sessionA is readonly
1472
+ room.handleMessage('sessionA', presencePushRequest)
1473
+
1474
+ // commit for sessionA
1475
+ expect(socketA.__lastMessage).toEqual({
1476
+ type: 'data',
1477
+ data: [{ type: 'push_result', clientClock: 0, serverClock: 0, action: 'commit' }],
1478
+ })
1479
+
1480
+ // patch for sessionB, stored under sessionA's presence id
1481
+ expect(socketB.__lastMessage).toEqual({
1482
+ type: 'data',
1483
+ data: [
1484
+ {
1485
+ type: 'patch',
1486
+ serverClock: 0,
1487
+ diff: {
1488
+ [presenceIdA]: [
1489
+ 'put',
1490
+ expect.objectContaining({
1491
+ id: presenceIdA,
1492
+ typeName: 'instance_presence',
1493
+ currentPageId: 'page:page_2',
1494
+ userId: 'user:foo',
1495
+ userName: 'Jimbo',
1496
+ }),
1497
+ ],
1498
+ },
1499
+ },
1500
+ ],
1501
+ })
1502
+ })
1503
+
1504
+ it('[RP10] presence changes do not affect the document clock', () => {
1505
+ const { room, storage } = makeRoom({ snapshot: makeSnapshot(records, { documentClock: 10 }) })
1506
+ connectSession(room, 'presence-test', { lastServerClock: 10 })
1507
+
1508
+ const clockBefore = storage.getClock()
1509
+
1510
+ // Send presence update
1511
+ room.handleMessage('presence-test', {
1512
+ type: 'push',
1513
+ clientClock: 1,
1514
+ diff: undefined,
1515
+ presence: presencePut('Test User'),
1516
+ } as TLPushRequest<TLRecord>)
1517
+
1518
+ // Document clock should not have changed
1519
+ expect(storage.getClock()).toBe(clockBefore)
1520
+ })
1521
+
1522
+ it('[RP10] presence is stored in the presence store with a forced id and typeName, never in document storage', () => {
1523
+ const { room, storage } = makeRoom()
1524
+ connectSession(room, 'presence-test')
1525
+
1526
+ const presenceId = room.sessions.get('presence-test')!.presenceId!
1527
+
1528
+ // Send a presence update whose record has a client-chosen id
1529
+ room.handleMessage('presence-test', {
1530
+ type: 'push',
1531
+ clientClock: 1,
1532
+ diff: undefined,
1533
+ presence: presencePut('Test User'),
1534
+ } as TLPushRequest<TLRecord>)
1535
+
1536
+ // Presence should be in presenceStore under the session's presence id,
1537
+ // with id and typeName forced server-side
1538
+ const stored = room.presenceStore.get(presenceId) as any
1539
+ expect(stored).toBeDefined()
1540
+ expect(stored.id).toBe(presenceId)
1541
+ expect(stored.typeName).toBe('instance_presence')
1542
+ expect(stored.userName).toBe('Test User')
1543
+
1544
+ // Presence should NOT be in document storage
1545
+ storage.transaction((txn) => {
1546
+ expect(txn.get(presenceId)).toBeUndefined()
1547
+ })
1548
+ })
1549
+
1550
+ it('[RP10][SES3] presence is removed from the presence store when the session is removed', () => {
1551
+ const { room } = makeRoom()
1552
+ connectSession(room, 'presence-test')
1553
+
1554
+ const presenceId = room.sessions.get('presence-test')!.presenceId!
1555
+
1556
+ room.handleMessage('presence-test', {
1557
+ type: 'push',
1558
+ clientClock: 1,
1559
+ diff: undefined,
1560
+ presence: presencePut('Test User'),
1561
+ } as TLPushRequest<TLRecord>)
1562
+
1563
+ expect(room.presenceStore.get(presenceId)).toBeDefined()
1564
+
1565
+ // Close the session
1566
+ room.rejectSession('presence-test')
1567
+
1568
+ // Presence should be removed
1569
+ expect(room.presenceStore.get(presenceId)).toBeUndefined()
1570
+ })
1571
+
1572
+ it('[RP10] onPresenceChange fires on a microtask after presence changes', async () => {
1573
+ const onPresenceChange = vi.fn()
1574
+ const { room } = makeRoom({ onPresenceChange })
1575
+ connectSession(room, 'a')
1576
+
1577
+ room.handleMessage('a', {
1578
+ type: 'push',
1579
+ clientClock: 1,
1580
+ presence: presencePut(),
1581
+ } as TLPushRequest<TLRecord>)
1582
+
1583
+ // Not called synchronously
1584
+ expect(onPresenceChange).not.toHaveBeenCalled()
1585
+
1586
+ await Promise.resolve()
1587
+ expect(onPresenceChange).toHaveBeenCalledTimes(1)
1588
+ })
1589
+
1590
+ it('[RP11] pings are answered with pong, and pings and pushes update lastInteractionTime', () => {
1591
+ vi.useFakeTimers()
1592
+ const { room } = makeRoom()
1593
+ const socket = connectSession(room, 'a')
1594
+ const t0 = (room.sessions.get('a') as any).lastInteractionTime
1595
+
1596
+ vi.advanceTimersByTime(1234)
1597
+ room.handleMessage('a', { type: 'ping' })
1598
+ expect(socket.__lastMessage).toEqual({ type: 'pong' })
1599
+ expect((room.sessions.get('a') as any).lastInteractionTime).toBe(t0 + 1234)
1600
+
1601
+ vi.advanceTimersByTime(1000)
1602
+ room.handleMessage('a', {
1603
+ type: 'push',
1604
+ clientClock: 1,
1605
+ diff: { [pageRecord.id]: ['patch', { name: ['put', 'renamed'] }] },
1606
+ } as TLPushRequest<TLRecord>)
1607
+ expect((room.sessions.get('a') as any).lastInteractionTime).toBe(t0 + 2234)
1608
+ })
1609
+
1610
+ it('[RP12][RP2] a TLSyncError mid-push rejects only the offending session', () => {
1611
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1612
+ try {
1613
+ const { room, socketA, socketB } = setupTwoSessions()
1614
+
1615
+ room.handleMessage('a', {
1616
+ type: 'push',
1617
+ clientClock: 1,
1618
+ diff: {
1619
+ 'unknown:record': [
1620
+ 'put',
1621
+ { id: 'unknown:record', typeName: 'unknown_type_that_does_not_exist' } as any,
1622
+ ],
1623
+ },
1624
+ } as TLPushRequest<TLRecord>)
1625
+
1626
+ // Only the offending session is rejected
1627
+ expect(room.sessions.get('a')).toBeUndefined()
1628
+ expect(socketA.close).toHaveBeenCalledWith(
1629
+ TLSyncErrorCloseEventCode,
1630
+ TLSyncErrorCloseEventReason.INVALID_RECORD
1631
+ )
1632
+ expect(room.sessions.get('b')?.state).toBe(RoomSessionState.Connected)
1633
+ expect(socketB.close).not.toHaveBeenCalled()
1634
+ } finally {
1635
+ consoleSpy.mockRestore()
1636
+ }
1637
+ })
1638
+
1639
+ it('[RP13] when storage modifies a pushed change, the pusher is rebased with the storage actual changes and others receive them', () => {
1640
+ // A storage layer that embellishes every written record (e.g. with server
1641
+ // metadata) and reports the actual changes back to the room.
1642
+ class EmbellishingStorage implements TLSyncStorage<TLRecord> {
1643
+ constructor(readonly inner: InMemorySyncStorage<TLRecord>) {}
1644
+ getClock() {
1645
+ return this.inner.getClock()
1646
+ }
1647
+ onChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown) {
1648
+ return this.inner.onChange(callback)
1649
+ }
1650
+ getSnapshot() {
1651
+ return this.inner.getSnapshot()
1652
+ }
1653
+ transaction<T>(
1654
+ callback: TLSyncStorageTransactionCallback<TLRecord, T>,
1655
+ opts?: TLSyncStorageTransactionOptions
1656
+ ): TLSyncStorageTransactionResult<T, TLRecord> {
1657
+ return this.inner.transaction<T>(
1658
+ ((txn: TLSyncStorageTransaction<TLRecord>) => {
1659
+ const wrapped: TLSyncStorageTransaction<TLRecord> = {
1660
+ getClock: () => txn.getClock(),
1661
+ getChangesSince: (since) => txn.getChangesSince(since),
1662
+ get: (id) => txn.get(id),
1663
+ set: (id, record) =>
1664
+ txn.set(id, {
1665
+ ...record,
1666
+ meta: { ...(record as any).meta, embellished: true },
1667
+ } as TLRecord),
1668
+ delete: (id) => txn.delete(id),
1669
+ entries: () => txn.entries(),
1670
+ keys: () => txn.keys(),
1671
+ values: () => txn.values(),
1672
+ getSchema: () => txn.getSchema(),
1673
+ setSchema: (s) => txn.setSchema(s),
1674
+ }
1675
+ return (callback as any)(wrapped)
1676
+ }) as any,
1677
+ // Always emit the actual changes so the room sees that they differ
1678
+ // from what the client requested
1679
+ { ...opts, emitChanges: 'always' }
1680
+ )
1681
+ }
1682
+ }
1683
+
1684
+ const inner = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
1685
+ const storage = new EmbellishingStorage(inner)
1354
1686
  const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
1687
+ disposables.push(() => room.close())
1355
1688
 
1689
+ const socketA = connectSession(room, 'a')
1690
+ const socketB = connectSession(room, 'b')
1691
+
1692
+ const newPage = makePage('page_3', 'embellish me')
1693
+ room.handleMessage('a', {
1694
+ type: 'push',
1695
+ clientClock: 1,
1696
+ diff: { [newPage.id]: ['put', newPage] },
1697
+ } as TLPushRequest<TLRecord>)
1698
+
1699
+ const embellished = { ...newPage, meta: { embellished: true } }
1700
+ expect(inner.documents.get(newPage.id)?.state).toEqual(embellished)
1701
+
1702
+ // The pusher is rebased onto the storage's actual changes
1703
+ expect(sentDataMessages(socketA)).toEqual([
1704
+ {
1705
+ type: 'push_result',
1706
+ clientClock: 1,
1707
+ serverClock: 1,
1708
+ action: { rebaseWithDiff: { [newPage.id]: ['put', embellished] } },
1709
+ },
1710
+ ])
1711
+
1712
+ // Other sessions receive the actual changes too
1713
+ expect(sentDataMessages(socketB)).toEqual([
1714
+ { type: 'patch', diff: { [newPage.id]: ['put', embellished] }, serverClock: 1 },
1715
+ ])
1716
+ })
1717
+ })
1718
+
1719
+ describe('25. Messaging and broadcast (RB)', () => {
1720
+ function setupTwoSessions() {
1721
+ const { room, storage } = makeRoom()
1722
+ const socketA = connectSession(room, 'a')
1723
+ const socketB = connectSession(room, 'b')
1724
+ return { room, storage, socketA, socketB }
1725
+ }
1726
+
1727
+ it('[RB1] data messages are debounced: the first is sent immediately, the rest are buffered until the flush', () => {
1728
+ vi.useFakeTimers()
1729
+ const { room, socketB } = setupTwoSessions()
1730
+ const newPage = makePage('page_3', 'v1')
1731
+
1732
+ // First data message is sent immediately, wrapped as a data event
1733
+ room.handleMessage('a', {
1734
+ type: 'push',
1735
+ clientClock: 1,
1736
+ diff: { [newPage.id]: ['put', newPage] },
1737
+ } as TLPushRequest<TLRecord>)
1738
+
1739
+ expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
1740
+ expect(sentMessages(socketB)[0]).toEqual({
1741
+ type: 'data',
1742
+ data: [{ type: 'patch', diff: { [newPage.id]: ['put', newPage] }, serverClock: 1 }],
1743
+ })
1744
+
1745
+ // Messages within the debounce interval are buffered
1746
+ room.handleMessage('a', {
1747
+ type: 'push',
1748
+ clientClock: 2,
1749
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
1750
+ } as TLPushRequest<TLRecord>)
1751
+ room.handleMessage('a', {
1752
+ type: 'push',
1753
+ clientClock: 3,
1754
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v3'] }] },
1755
+ } as TLPushRequest<TLRecord>)
1756
+
1757
+ expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
1758
+
1759
+ // ...and flushed together as a single data message
1760
+ vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
1761
+
1762
+ expect(socketB.sendMessage).toHaveBeenCalledTimes(2)
1763
+ expect(sentMessages(socketB)[1]).toEqual({
1764
+ type: 'data',
1765
+ data: [
1766
+ {
1767
+ type: 'patch',
1768
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
1769
+ serverClock: 2,
1770
+ },
1771
+ {
1772
+ type: 'patch',
1773
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v3'] }] },
1774
+ serverClock: 3,
1775
+ },
1776
+ ],
1777
+ })
1778
+ })
1779
+
1780
+ it('[RB1] the flushed data array is handed off to the socket, not truncated in place', () => {
1781
+ vi.useFakeTimers()
1782
+ const { room, socketB } = setupTwoSessions()
1783
+ const newPage = makePage('page_3', 'v1')
1784
+
1785
+ room.handleMessage('a', {
1786
+ type: 'push',
1787
+ clientClock: 1,
1788
+ diff: { [newPage.id]: ['put', newPage] },
1789
+ } as TLPushRequest<TLRecord>)
1790
+ room.handleMessage('a', {
1791
+ type: 'push',
1792
+ clientClock: 2,
1793
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
1794
+ } as TLPushRequest<TLRecord>)
1795
+ room.handleMessage('a', {
1796
+ type: 'push',
1797
+ clientClock: 3,
1798
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v3'] }] },
1799
+ } as TLPushRequest<TLRecord>)
1800
+
1801
+ // capture the raw message object without cloning, as a socket that defers
1802
+ // serialization would observe it
1803
+ const rawMessages: any[] = []
1804
+ socketB.sendMessage.mockImplementation((msg: any) => rawMessages.push(msg))
1805
+ vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
1806
+
1807
+ expect(rawMessages).toHaveLength(1)
1808
+ expect(rawMessages[0].data).toHaveLength(2)
1809
+ })
1810
+
1811
+ it('[RB2] a custom message flushes buffered data messages first, preserving order', () => {
1812
+ vi.useFakeTimers()
1813
+ const { room, socketB } = setupTwoSessions()
1814
+ const newPage = makePage('page_3', 'v1')
1815
+
1816
+ room.handleMessage('a', {
1817
+ type: 'push',
1818
+ clientClock: 1,
1819
+ diff: { [newPage.id]: ['put', newPage] },
1820
+ } as TLPushRequest<TLRecord>)
1821
+ room.handleMessage('a', {
1822
+ type: 'push',
1823
+ clientClock: 2,
1824
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
1825
+ } as TLPushRequest<TLRecord>)
1826
+
1827
+ // One immediate data message, one buffered
1828
+ expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
1829
+
1830
+ room.sendCustomMessage('b', 'hello')
1831
+
1832
+ const messages = sentMessages(socketB) as any[]
1833
+ expect(messages.map((m) => m.type)).toEqual(['data', 'data', 'custom'])
1834
+ expect(messages[1].data[0].diff).toEqual({
1835
+ [newPage.id]: ['patch', { name: ['put', 'v2'] }],
1836
+ })
1837
+ expect(messages[2]).toEqual({ type: 'custom', data: 'hello' })
1838
+ })
1839
+
1840
+ it('[RB2] pong does not flush buffered data messages', () => {
1841
+ vi.useFakeTimers()
1842
+ const { room, socketB } = setupTwoSessions()
1843
+ const newPage = makePage('page_3', 'v1')
1844
+
1845
+ room.handleMessage('a', {
1846
+ type: 'push',
1847
+ clientClock: 1,
1848
+ diff: { [newPage.id]: ['put', newPage] },
1849
+ } as TLPushRequest<TLRecord>)
1850
+ room.handleMessage('a', {
1851
+ type: 'push',
1852
+ clientClock: 2,
1853
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
1854
+ } as TLPushRequest<TLRecord>)
1855
+ expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
1856
+
1857
+ // B pings while it has a buffered data message: the pong arrives first
1858
+ room.handleMessage('b', { type: 'ping' })
1859
+
1860
+ let messages = sentMessages(socketB) as any[]
1861
+ expect(messages.map((m) => m.type)).toEqual(['data', 'pong'])
1862
+
1863
+ // The buffered message is delivered by the regular flush afterwards
1864
+ vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
1865
+ messages = sentMessages(socketB) as any[]
1866
+ expect(messages.map((m) => m.type)).toEqual(['data', 'pong', 'data'])
1867
+ })
1868
+
1869
+ it('[RB3] sending to a session whose socket is closed cancels that session', () => {
1870
+ const { room, socketB } = setupTwoSessions()
1871
+
1872
+ socketB.isOpen = false
1873
+
1874
+ const newPage = makePage('page_3', 'hello')
1875
+ room.handleMessage('a', {
1876
+ type: 'push',
1877
+ clientClock: 1,
1878
+ diff: { [newPage.id]: ['put', newPage] },
1879
+ } as TLPushRequest<TLRecord>)
1880
+
1881
+ expect(room.sessions.get('b')?.state).toBe(RoomSessionState.AwaitingRemoval)
1882
+ expect(socketB.sendMessage).not.toHaveBeenCalled()
1883
+ })
1884
+
1885
+ it('[RB4] a per-session migration failure during broadcast rejects only the affected session', () => {
1886
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1887
+ try {
1888
+ interface RBUser extends BaseRecord<'user', RecordId<RBUser>> {
1889
+ name: string
1890
+ }
1891
+ const RBUser = createRecordType<RBUser>('user', {
1892
+ scope: 'document',
1893
+ validator: { validate: (r) => r as RBUser },
1894
+ })
1895
+ const rbUserVersions = createMigrationIds('test.rb.user', { Touch: 1 } as const)
1896
+ const rbServerSchema = StoreSchema.create<RBUser>(
1897
+ { user: RBUser },
1898
+ {
1899
+ migrations: [
1900
+ createRecordMigrationSequence({
1901
+ sequenceId: 'test.rb.user',
1902
+ recordType: 'user',
1903
+ sequence: [
1904
+ {
1905
+ id: rbUserVersions.Touch,
1906
+ up: (r: any) => r,
1907
+ down: (r: any) => {
1908
+ if (r.name === 'explode') throw new Error('cannot down-migrate')
1909
+ return r
1910
+ },
1911
+ },
1912
+ ],
1913
+ }),
1914
+ ],
1915
+ }
1916
+ )
1917
+ const rbOldSchema = StoreSchema.create<RBUser>(
1918
+ { user: RBUser },
1919
+ {
1920
+ migrations: [
1921
+ createMigrationSequence({
1922
+ sequenceId: 'test.rb.user',
1923
+ sequence: [],
1924
+ retroactive: true,
1925
+ }),
1926
+ ],
1927
+ }
1928
+ )
1929
+
1930
+ const storage = new InMemorySyncStorage<RBUser>({
1931
+ snapshot: { documents: [], clock: 0, documentClock: 0, schema: rbServerSchema.serialize() },
1932
+ })
1933
+ const room = new TLSyncRoom<RBUser, undefined>({ schema: rbServerSchema, storage })
1934
+ disposables.push(() => room.close())
1935
+
1936
+ const oldSocket = connectSession(room, 'old', { schema: rbOldSchema.serialize() })
1937
+ const peerSocket = connectSession(room, 'peer')
1938
+ const pusherSocket = connectSession(room, 'pusher')
1939
+
1940
+ const user = RBUser.create({ id: RBUser.createId('boom'), name: 'explode' })
1941
+ room.handleMessage('pusher', {
1942
+ type: 'push',
1943
+ clientClock: 1,
1944
+ diff: { [user.id]: ['put', user] },
1945
+ } as TLPushRequest<RBUser>)
1946
+
1947
+ // The old client could not down-migrate the record and was rejected
1948
+ expect(room.sessions.get('old')).toBeUndefined()
1949
+ expect(oldSocket.close).toHaveBeenCalledWith(
1950
+ TLSyncErrorCloseEventCode,
1951
+ TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
1952
+ )
1953
+
1954
+ // The broadcast proceeded for the same-version session
1955
+ expect(room.sessions.get('peer')?.state).toBe(RoomSessionState.Connected)
1956
+ expect(sentDataMessages(peerSocket)).toEqual([
1957
+ { type: 'patch', diff: { [user.id]: ['put', user] }, serverClock: 1 },
1958
+ ])
1959
+
1960
+ // And the pusher got its commit
1961
+ expect(sentDataMessages(pusherSocket)).toEqual([
1962
+ { type: 'push_result', clientClock: 1, serverClock: 1, action: 'commit' },
1963
+ ])
1964
+ } finally {
1965
+ consoleSpy.mockRestore()
1966
+ }
1967
+ })
1968
+
1969
+ it('[RB5] sends a custom message to a connected client', async () => {
1970
+ type Presence = UnknownRecord & { typeName: 'presence' }
1971
+ const presenceType = createRecordType<Presence>('presence', {
1972
+ scope: 'presence',
1973
+ validator: { validate: (record) => record as Presence },
1974
+ })
1975
+ const customSchema = StoreSchema.create<Presence>({ presence: presenceType })
1976
+
1977
+ const store = new Store<any, any>({ schema: customSchema, props: {} })
1978
+
1979
+ const sessionId = 'test-session-1'
1980
+ const server = new TestServer(customSchema)
1981
+ const socketPair = new TestSocketPair(sessionId, server)
1982
+ socketPair.connect()
1983
+
1984
+ const onCustomMessageReceived = vi.fn()
1985
+ const client = new TLSyncClient({
1986
+ store,
1987
+ socket: socketPair.clientSocket,
1988
+ onCustomMessageReceived,
1989
+ onLoad: vi.fn(),
1990
+ onSyncError: vi.fn(),
1991
+ presence: atom('', null),
1992
+ })
1993
+ disposables.push(() => client.close())
1994
+ await socketPair.flushAllEvents()
1995
+ server.room.sendCustomMessage(sessionId, 'hello world')
1996
+ await socketPair.flushAllEvents()
1997
+ expect(onCustomMessageReceived.mock.lastCall).toEqual(['hello world'])
1998
+ })
1999
+
2000
+ it('[RB5] sending a custom message to an unknown or not-yet-connected session warns and does nothing', () => {
2001
+ const warn = vi.fn()
2002
+ const { room } = makeRoom({ log: { warn } })
2003
+
2004
+ room.sendCustomMessage('nobody', 'hello')
2005
+ expect(warn).toHaveBeenCalledWith('Tried to send message to unknown session', 'custom')
2006
+
2007
+ warn.mockClear()
1356
2008
  const socket = makeSocket()
1357
- const sessionId = 'old-client-session'
2009
+ room.handleNewSession({ sessionId: 'pending', socket, meta: undefined, isReadonly: false })
2010
+ room.sendCustomMessage('pending', 'hello')
2011
+ expect(warn).toHaveBeenCalledWith('Tried to send message to disconnected client', 'custom')
2012
+ expect(socket.sendMessage).not.toHaveBeenCalled()
2013
+ })
2014
+ })
1358
2015
 
2016
+ describe('26. Session lifecycle (SES)', () => {
2017
+ it('[SES1][SES2][SES3] an idle connected session is cancelled, kept for a grace period, then removed', () => {
2018
+ vi.useFakeTimers()
2019
+ const { room } = makeRoom({ clientTimeout: SESSION_IDLE_TIMEOUT })
2020
+ const socket = connectSession(room, 'idle')
2021
+ const presenceId = room.sessions.get('idle')!.presenceId
2022
+ const removed = vi.fn()
2023
+ const becameEmpty = vi.fn()
2024
+ room.events.on('session_removed', removed)
2025
+ room.events.on('room_became_empty', becameEmpty)
2026
+
2027
+ // Shift the clock past the idle timeout without firing the interval so we
2028
+ // can observe the prune pass directly
2029
+ vi.setSystemTime(Date.now() + SESSION_IDLE_TIMEOUT + 1)
2030
+ room.pruneSessions()
2031
+
2032
+ const session = room.sessions.get('idle')!
2033
+ expect(session.state).toBe(RoomSessionState.AwaitingRemoval)
2034
+ // [SES2] presence id is kept for a quick reconnect, and the socket is closed
2035
+ expect(session.presenceId).toBe(presenceId)
2036
+ expect(socket.isOpen).toBe(false)
2037
+ expect(removed).not.toHaveBeenCalled()
2038
+
2039
+ // Not removed before the grace period has elapsed
2040
+ vi.setSystemTime(Date.now() + 1001)
2041
+ room.pruneSessions()
2042
+ expect(room.sessions.get('idle')?.state).toBe(RoomSessionState.AwaitingRemoval)
2043
+
2044
+ // [SES1] removed once it has been awaiting removal for SESSION_REMOVAL_WAIT_TIME
2045
+ vi.setSystemTime(Date.now() + SESSION_REMOVAL_WAIT_TIME + 1)
2046
+ room.pruneSessions()
2047
+ expect(room.sessions.size).toBe(0)
2048
+ // [SES3] events fire on removal
2049
+ expect(removed).toHaveBeenCalledWith({ sessionId: 'idle', meta: undefined })
2050
+ expect(becameEmpty).toHaveBeenCalledTimes(1)
2051
+ })
2052
+
2053
+ it('[SES1] a session that never sends a connect message is removed after SESSION_START_WAIT_TIME', () => {
2054
+ vi.useFakeTimers()
2055
+ const { room } = makeRoom()
1359
2056
  room.handleNewSession({
1360
- sessionId,
1361
- socket,
2057
+ sessionId: 'never-connects',
2058
+ socket: makeSocket(),
1362
2059
  meta: undefined,
1363
2060
  isReadonly: false,
1364
2061
  })
1365
2062
 
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
- })
2063
+ vi.setSystemTime(Date.now() + SESSION_START_WAIT_TIME + 1)
2064
+ room.pruneSessions()
1374
2065
 
1375
- // Session should not be connected - it was rejected due to incompatible schema
1376
- expect(room.sessions.get(sessionId)?.state).not.toBe('connected')
2066
+ expect(room.sessions.size).toBe(0)
2067
+ })
1377
2068
 
1378
- // Socket should be closed with CLIENT_TOO_OLD
1379
- expect(socket.isOpen).toBe(false)
2069
+ it('[SES1] a connected session whose socket has closed is cancelled on prune', () => {
2070
+ const { room } = makeRoom()
2071
+ const socket = connectSession(room, 's1')
2072
+
2073
+ socket.isOpen = false
2074
+ room.pruneSessions()
2075
+
2076
+ expect(room.sessions.get('s1')?.state).toBe(RoomSessionState.AwaitingRemoval)
1380
2077
  })
1381
2078
 
1382
- it('accepts client with same schema version', () => {
2079
+ it('[SES2] handleClose moves the session to AwaitingRemoval keeping its presence id and meta', () => {
1383
2080
  const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
1384
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
2081
+ const room = new TLSyncRoom<TLRecord, { userId: string }>({ schema, storage })
2082
+ disposables.push(() => room.close())
1385
2083
 
1386
2084
  const socket = makeSocket()
1387
- const sessionId = 'current-client-session'
1388
-
1389
2085
  room.handleNewSession({
1390
- sessionId,
2086
+ sessionId: 's1',
1391
2087
  socket,
1392
- meta: undefined,
2088
+ meta: { userId: 'user-1' },
1393
2089
  isReadonly: false,
1394
2090
  })
1395
-
1396
- // Connect with same schema
1397
- room.handleMessage(sessionId, {
1398
- connectRequestId: 'connect-1',
2091
+ room.handleMessage('s1', {
2092
+ type: 'connect',
2093
+ connectRequestId: 'connect-s1',
1399
2094
  lastServerClock: 0,
1400
2095
  protocolVersion: getTlsyncProtocolVersion(),
1401
2096
  schema: room.serializedSchema,
1402
- type: 'connect',
2097
+ } satisfies TLConnectRequest)
2098
+ const presenceId = room.sessions.get('s1')!.presenceId
2099
+
2100
+ room.handleClose('s1')
2101
+
2102
+ const session = room.sessions.get('s1')!
2103
+ expect(session.state).toBe(RoomSessionState.AwaitingRemoval)
2104
+ expect(session.presenceId).toBe(presenceId)
2105
+ expect(session.meta).toEqual({ userId: 'user-1' })
2106
+ expect(socket.close).toHaveBeenCalled()
2107
+ })
2108
+
2109
+ it('[SES3] removing a session deletes its presence record and broadcasts the deletion to everyone', () => {
2110
+ const { room } = makeRoom()
2111
+ connectSession(room, 'a')
2112
+ const socketB = connectSession(room, 'b')
2113
+ const presenceIdA = room.sessions.get('a')!.presenceId!
2114
+
2115
+ room.handleMessage('a', {
2116
+ type: 'push',
2117
+ clientClock: 1,
2118
+ presence: presencePut('Leaving Soon'),
2119
+ } as TLPushRequest<TLRecord>)
2120
+ expect(room.presenceStore.get(presenceIdA)).toBeDefined()
2121
+
2122
+ // Flush B's debounced data messages and start from a clean slate
2123
+ room._flushDataMessages('b')
2124
+ clearSocket(socketB)
2125
+
2126
+ room.rejectSession('a')
2127
+
2128
+ expect(room.presenceStore.get(presenceIdA)).toBeUndefined()
2129
+ expect(sentDataMessages(socketB)).toEqual([
2130
+ { type: 'patch', diff: { [presenceIdA]: ['remove'] }, serverClock: 0 },
2131
+ ])
2132
+ })
2133
+
2134
+ it('[SES4] legacy protocol clients are rejected with a mapped incompatibility_error and no close code', () => {
2135
+ const cases = [
2136
+ [TLSyncErrorCloseEventReason.CLIENT_TOO_OLD, 'clientTooOld'],
2137
+ [TLSyncErrorCloseEventReason.SERVER_TOO_OLD, 'serverTooOld'],
2138
+ [TLSyncErrorCloseEventReason.INVALID_RECORD, 'invalidRecord'],
2139
+ [TLSyncErrorCloseEventReason.FORBIDDEN, 'invalidOperation'],
2140
+ ] as const
2141
+
2142
+ for (const [reason, legacyReason] of cases) {
2143
+ const { room } = makeRoom()
2144
+ // Protocol version 6 clients require legacy rejection
2145
+ const socket = connectSession(room, 'legacy', { protocolVersion: 6 })
2146
+
2147
+ room.rejectSession('legacy', reason)
2148
+
2149
+ expect(sentMessages(socket)).toEqual([
2150
+ { type: 'incompatibility_error', reason: legacyReason },
2151
+ ])
2152
+ expect(socket.close).toHaveBeenCalledWith()
2153
+ expect(room.sessions.size).toBe(0)
2154
+ }
2155
+ })
2156
+
2157
+ it('[SES4] modern clients are rejected by closing the socket with code 4099 and the reason', () => {
2158
+ const { room } = makeRoom()
2159
+ const socket = connectSession(room, 'modern')
2160
+
2161
+ room.rejectSession('modern', TLSyncErrorCloseEventReason.FORBIDDEN)
2162
+
2163
+ expect(sentMessages(socket)).toEqual([])
2164
+ expect(socket.close).toHaveBeenCalledWith(
2165
+ TLSyncErrorCloseEventCode,
2166
+ TLSyncErrorCloseEventReason.FORBIDDEN
2167
+ )
2168
+ expect(room.sessions.size).toBe(0)
2169
+ })
2170
+
2171
+ it('[HS2][SES5] sets supportsStringAppend to false for protocol version 7', () => {
2172
+ const { room } = makeRoom()
2173
+ connectSession(room, 'v7-session', { protocolVersion: 7 })
2174
+
2175
+ const session = room.sessions.get('v7-session')
2176
+ expect(session?.state).toBe(RoomSessionState.Connected)
2177
+ if (session?.state === RoomSessionState.Connected) {
2178
+ expect(session.supportsStringAppend).toBe(false)
2179
+ }
2180
+ })
2181
+
2182
+ it('[HS2][SES5] sets supportsStringAppend to true for protocol version 8', () => {
2183
+ const { room } = makeRoom()
2184
+ connectSession(room, 'v8-session', { protocolVersion: getTlsyncProtocolVersion() })
2185
+
2186
+ const session = room.sessions.get('v8-session')
2187
+ expect(session?.state).toBe(RoomSessionState.Connected)
2188
+ if (session?.state === RoomSessionState.Connected) {
2189
+ expect(session.supportsStringAppend).toBe(true)
2190
+ }
2191
+ })
2192
+
2193
+ it('[SES5] getCanEmitStringAppend returns false when any connected client lacks string append support', () => {
2194
+ const { room } = makeRoom()
2195
+
2196
+ connectSession(room, 'v8-client', { protocolVersion: 8 })
2197
+
2198
+ // With only a v8 client, should be true
2199
+ expect(room.getCanEmitStringAppend()).toBe(true)
2200
+
2201
+ connectSession(room, 'v7-client', { protocolVersion: 7 })
2202
+
2203
+ // With mixed clients, should be false
2204
+ expect(room.getCanEmitStringAppend()).toBe(false)
2205
+ })
2206
+
2207
+ it('[SES6] handleResumedSession registers a session directly in Connected state and restores its presence', () => {
2208
+ const { room } = makeRoom()
2209
+ const socket = makeSocket()
2210
+ const presenceId = InstancePresenceRecordType.createId('resumed')
2211
+ const presenceRecord = InstancePresenceRecordType.create({
2212
+ id: presenceId,
2213
+ currentPageId: pageRecord.id,
2214
+ userId: createUserId('resumed'),
2215
+ userName: 'Resumed',
1403
2216
  })
1404
2217
 
1405
- // Session should be connected
1406
- expect(room.sessions.get(sessionId)?.state).toBe('connected')
2218
+ room.handleResumedSession({
2219
+ sessionId: 'resumed',
2220
+ socket,
2221
+ meta: undefined,
2222
+ isReadonly: false,
2223
+ serializedSchema: room.serializedSchema,
2224
+ presenceId,
2225
+ presenceRecord,
2226
+ requiresLegacyRejection: false,
2227
+ supportsStringAppend: true,
2228
+ })
2229
+
2230
+ const session = room.sessions.get('resumed')! as any
2231
+ expect(session.state).toBe(RoomSessionState.Connected)
2232
+ // requiresDownMigrations is recomputed from the supplied schema
2233
+ expect(session.requiresDownMigrations).toBe(false)
2234
+ // the presence record is restored into the presence store
2235
+ expect(room.presenceStore.get(presenceId)).toEqual(presenceRecord)
2236
+
2237
+ // the resumed session handles messages like any connected session
2238
+ room.handleMessage('resumed', { type: 'ping' })
2239
+ expect(socket.__lastMessage).toEqual({ type: 'pong' })
2240
+ })
2241
+
2242
+ it('[SES7] a message from an unknown session id logs a warning and is ignored', () => {
2243
+ const warn = vi.fn()
2244
+ const { room } = makeRoom({ log: { warn } })
2245
+
2246
+ room.handleMessage('unknown-session', { type: 'ping' })
2247
+
2248
+ expect(warn).toHaveBeenCalledWith('Received message from unknown session')
1407
2249
  })
1408
2250
  })