@tldraw/sync-core 4.3.0-canary.cf5673a789a1 → 4.3.0-canary.d039f3a1ab8f

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 (51) hide show
  1. package/dist-cjs/index.d.ts +239 -57
  2. package/dist-cjs/index.js +7 -3
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/InMemorySyncStorage.js +289 -0
  5. package/dist-cjs/lib/InMemorySyncStorage.js.map +7 -0
  6. package/dist-cjs/lib/RoomSession.js.map +1 -1
  7. package/dist-cjs/lib/TLSocketRoom.js +117 -69
  8. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  9. package/dist-cjs/lib/TLSyncClient.js +7 -0
  10. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncRoom.js +357 -688
  12. package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
  13. package/dist-cjs/lib/TLSyncStorage.js +76 -0
  14. package/dist-cjs/lib/TLSyncStorage.js.map +7 -0
  15. package/dist-cjs/lib/recordDiff.js +52 -0
  16. package/dist-cjs/lib/recordDiff.js.map +7 -0
  17. package/dist-esm/index.d.mts +239 -57
  18. package/dist-esm/index.mjs +12 -5
  19. package/dist-esm/index.mjs.map +2 -2
  20. package/dist-esm/lib/InMemorySyncStorage.mjs +274 -0
  21. package/dist-esm/lib/InMemorySyncStorage.mjs.map +7 -0
  22. package/dist-esm/lib/RoomSession.mjs.map +1 -1
  23. package/dist-esm/lib/TLSocketRoom.mjs +121 -70
  24. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  25. package/dist-esm/lib/TLSyncClient.mjs +7 -0
  26. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  27. package/dist-esm/lib/TLSyncRoom.mjs +370 -702
  28. package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
  29. package/dist-esm/lib/TLSyncStorage.mjs +56 -0
  30. package/dist-esm/lib/TLSyncStorage.mjs.map +7 -0
  31. package/dist-esm/lib/recordDiff.mjs +32 -0
  32. package/dist-esm/lib/recordDiff.mjs.map +7 -0
  33. package/package.json +6 -6
  34. package/src/index.ts +21 -3
  35. package/src/lib/InMemorySyncStorage.ts +357 -0
  36. package/src/lib/RoomSession.test.ts +1 -0
  37. package/src/lib/RoomSession.ts +2 -0
  38. package/src/lib/TLSocketRoom.ts +228 -114
  39. package/src/lib/TLSyncClient.ts +12 -0
  40. package/src/lib/TLSyncRoom.ts +473 -913
  41. package/src/lib/TLSyncStorage.ts +216 -0
  42. package/src/lib/recordDiff.ts +73 -0
  43. package/src/test/InMemorySyncStorage.test.ts +1674 -0
  44. package/src/test/TLSocketRoom.test.ts +255 -49
  45. package/src/test/TLSyncRoom.test.ts +1021 -533
  46. package/src/test/TestServer.ts +12 -1
  47. package/src/test/customMessages.test.ts +1 -1
  48. package/src/test/presenceMode.test.ts +6 -6
  49. package/src/test/upgradeDowngrade.test.ts +282 -8
  50. package/src/test/validation.test.ts +10 -10
  51. package/src/test/pruneTombstones.test.ts +0 -178
@@ -9,21 +9,14 @@ import {
9
9
  TLArrowShapeProps,
10
10
  TLBaseShape,
11
11
  TLDOCUMENT_ID,
12
- TLDocument,
13
- TLPage,
14
12
  TLRecord,
15
13
  TLShapeId,
16
14
  createTLSchema,
17
15
  } from '@tldraw/tlschema'
18
- import { IndexKey, ZERO_INDEX_KEY, mockUniqueId, promiseWithResolve, sortById } from '@tldraw/utils'
16
+ import { IndexKey, ZERO_INDEX_KEY, mockUniqueId, sortById } from '@tldraw/utils'
19
17
  import { vi } from 'vitest'
20
- import {
21
- MAX_TOMBSTONES,
22
- RoomSnapshot,
23
- TLRoomSocket,
24
- TLSyncRoom,
25
- TOMBSTONE_PRUNE_BUFFER_SIZE,
26
- } from '../lib/TLSyncRoom'
18
+ import { InMemorySyncStorage } from '../lib/InMemorySyncStorage'
19
+ import { RoomSnapshot, TLRoomSocket, TLSyncRoom } from '../lib/TLSyncRoom'
27
20
  import {
28
21
  TLConnectRequest,
29
22
  TLPushRequest,
@@ -47,6 +40,7 @@ const makeSnapshot = (records: TLRecord[], others: Partial<RoomSnapshot> = {}) =
47
40
  documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
48
41
  clock: 0,
49
42
  documentClock: 0,
43
+ schema: schema.serialize(),
50
44
  ...others,
51
45
  })
52
46
 
@@ -98,63 +92,21 @@ const oldArrow: TLBaseShape<'arrow', Omit<TLArrowShapeProps, 'labelColor'>> = {
98
92
  }
99
93
 
100
94
  describe('TLSyncRoom', () => {
101
- it('can be constructed with a schema alone', () => {
102
- const room = new TLSyncRoom<any, undefined>({ schema })
103
-
104
- // we populate the store with a default document if none is given
105
- expect(room.getSnapshot().documents.length).toBeGreaterThan(0)
106
- })
107
-
108
- it('can be constructed with a snapshot', () => {
109
- const room = new TLSyncRoom<TLRecord, undefined>({
95
+ it('can be constructed with a storage', () => {
96
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
97
+ const _room = new TLSyncRoom<TLRecord, undefined>({
110
98
  schema,
111
- snapshot: makeSnapshot(records),
99
+ storage,
112
100
  })
113
101
 
114
102
  expect(
115
- room
103
+ storage
116
104
  .getSnapshot()
117
105
  .documents.map((r) => r.state)
118
106
  .sort(sortById)
119
107
  ).toEqual(records)
120
108
 
121
- expect(room.getSnapshot().documents.map((r) => r.lastChangedClock)).toEqual([0, 0])
122
- })
123
-
124
- it('trims tombstones down if you pass too many in the snapshot', () => {
125
- const room = new TLSyncRoom({
126
- schema,
127
- snapshot: {
128
- documents: [],
129
- clock: MAX_TOMBSTONES + 100,
130
- tombstones: Object.fromEntries(
131
- Array.from({ length: MAX_TOMBSTONES + 100 }, (_, i) => [PageRecordType.createId(), i])
132
- ),
133
- },
134
- })
135
-
136
- expect(Object.keys(room.getSnapshot().tombstones ?? {})).toHaveLength(
137
- MAX_TOMBSTONES - TOMBSTONE_PRUNE_BUFFER_SIZE
138
- )
139
- })
140
-
141
- it('updates tombstoneHistoryStartsAtClock when pruning tombstones', () => {
142
- const room = new TLSyncRoom({
143
- schema,
144
- snapshot: {
145
- documents: [],
146
- clock: MAX_TOMBSTONES + 100,
147
- tombstones: Object.fromEntries(
148
- Array.from({ length: MAX_TOMBSTONES + 100 }, (_, i) => [PageRecordType.createId(), i])
149
- ),
150
- },
151
- })
152
-
153
- // After pruning, tombstoneHistoryStartsAtClock should be updated to the clock value
154
- // of the oldest remaining tombstone
155
- const remainingTombstones = Object.values(room.getSnapshot().tombstones ?? {})
156
- const oldestRemainingClock = Math.min(...remainingTombstones)
157
- expect(room.tombstoneHistoryStartsAtClock).toBe(oldestRemainingClock)
109
+ expect(storage.getSnapshot().documents.map((r) => r.lastChangedClock)).toEqual([0, 0])
158
110
  })
159
111
 
160
112
  it('migrates the snapshot if it is dealing with old data', () => {
@@ -167,40 +119,50 @@ describe('TLSyncRoom', () => {
167
119
  },
168
120
  }
169
121
 
170
- const room = new TLSyncRoom({
171
- schema,
122
+ const storage = new InMemorySyncStorage<TLRecord>({
172
123
  snapshot: makeSnapshot([...records, oldArrow as any], {
173
124
  schema: oldSerializedSchema,
174
125
  }),
175
126
  })
127
+ const _room = new TLSyncRoom<TLRecord, undefined>({
128
+ schema,
129
+ storage,
130
+ })
176
131
 
177
- const arrow = room.getSnapshot().documents.find((r) => r.state.id === oldArrow.id)
132
+ const arrow = storage.getSnapshot().documents.find((r) => r.state.id === oldArrow.id)
178
133
  ?.state as TLArrowShape
179
134
  expect(arrow.props.labelColor).toBe('black')
180
135
  })
181
136
 
182
- it('filters out instance state records', () => {
183
- const schema = createTLSchema({ shapes: {}, bindings: {} })
184
- const room = new TLSyncRoom({
185
- schema,
186
- snapshot: makeSnapshot([
187
- ...records,
188
- schema.types.instance.create({
189
- currentPageId: PageRecordType.createId('page_1'),
190
- id: schema.types.instance.createId('instance_1'),
191
- }),
192
- InstancePageStateRecordType.create({
193
- id: InstancePageStateRecordType.createId(PageRecordType.createId('page_1')),
194
- pageId: PageRecordType.createId('page_1'),
195
- }),
196
- CameraRecordType.create({
197
- id: CameraRecordType.createId('camera_1'),
198
- }),
199
- ]),
137
+ it('filters out instance state records if a migration occurs, for legacy reasons', () => {
138
+ const schema = createTLSchema()
139
+ const oldSchema = structuredClone(schema.serialize())
140
+ oldSchema.sequences['com.tldraw.shape.arrow'] = 0
141
+ const storage = new InMemorySyncStorage<TLRecord>({
142
+ snapshot: makeSnapshot(
143
+ [
144
+ ...records,
145
+ schema.types.instance.create({
146
+ currentPageId: PageRecordType.createId('page_1'),
147
+ id: schema.types.instance.createId('instance_1'),
148
+ }),
149
+ InstancePageStateRecordType.create({
150
+ id: InstancePageStateRecordType.createId(PageRecordType.createId('page_1')),
151
+ pageId: PageRecordType.createId('page_1'),
152
+ }),
153
+ CameraRecordType.create({
154
+ id: CameraRecordType.createId('camera_1'),
155
+ }),
156
+ ],
157
+ {
158
+ schema: oldSchema,
159
+ }
160
+ ),
200
161
  })
162
+ const _room = new TLSyncRoom({ schema, storage })
201
163
 
202
164
  expect(
203
- room
165
+ storage
204
166
  .getSnapshot()
205
167
  .documents.map((r) => r.state)
206
168
  .sort(sortById)
@@ -223,349 +185,17 @@ function makeSocket(): MockSocket {
223
185
  return socket
224
186
  }
225
187
 
226
- describe('TLSyncRoom.updateStore', () => {
227
- const sessionAId = 'sessionA'
228
- const sessionBId = 'sessionB'
229
- let room = new TLSyncRoom<TLRecord, undefined>({ schema, snapshot: makeSnapshot(records) })
230
- let socketA = makeSocket()
231
- let socketB = makeSocket()
232
- function init(snapshot?: RoomSnapshot) {
233
- room = new TLSyncRoom<TLRecord, undefined>({
234
- schema,
235
- snapshot: snapshot ?? makeSnapshot(records),
236
- })
237
- socketA = makeSocket()
238
- socketB = makeSocket()
239
- room.handleNewSession({
240
- sessionId: sessionAId,
241
- socket: socketA,
242
- meta: null as any,
243
- isReadonly: false,
244
- })
245
- room.handleNewSession({
246
- sessionId: sessionBId,
247
- socket: socketB,
248
- meta: null as any,
249
- isReadonly: false,
250
- })
251
- room.handleMessage(sessionAId, {
252
- connectRequestId: 'connectRequestId' + sessionAId,
253
- lastServerClock: 0,
254
- protocolVersion: getTlsyncProtocolVersion(),
255
- schema: room.serializedSchema,
256
- type: 'connect',
257
- } satisfies TLConnectRequest)
258
- room.handleMessage(sessionBId, {
259
- connectRequestId: 'connectRequestId' + sessionBId,
260
- lastServerClock: 0,
261
- protocolVersion: getTlsyncProtocolVersion(),
262
- schema: room.serializedSchema,
263
- type: 'connect',
264
- } satisfies TLConnectRequest)
265
- expect(room.sessions.get(sessionAId)?.state).toBe('connected')
266
- expect(room.sessions.get(sessionBId)?.state).toBe('connected')
267
- socketA.__lastMessage = null
268
- socketB.__lastMessage = null
269
- }
270
- beforeEach(() => {
271
- init()
272
- })
273
-
274
- test('it allows updating records', async () => {
275
- const clock = room.clock
276
- const documentClock = room.documentClock
277
- await room.updateStore((store) => {
278
- const document = store.get('document:document') as TLDocument
279
- document.name = 'My lovely document'
280
- store.put(document)
281
- })
282
- expect(
283
- (room.getSnapshot().documents.find((r) => r.state.id === 'document:document')?.state as any)
284
- .name
285
- ).toBe('My lovely document')
286
- expect(clock).toBeLessThan(room.clock)
287
- expect(documentClock).toBeLessThan(room.documentClock)
288
- })
289
-
290
- test('it does not update unless you call .set', () => {
291
- const documentClock = room.documentClock
292
- room.updateStore((store) => {
293
- const document = store.get('document:document') as TLDocument
294
- document.name = 'My lovely document'
295
- })
296
- expect(
297
- (room.getSnapshot().documents.find((r) => r.state.id === 'document:document')?.state as any)
298
- .name
299
- ).toBe('')
300
- expect(documentClock).toBe(room.documentClock)
301
- })
302
-
303
- test('after the change it sends a patch to all clients', async () => {
304
- const clock = room.clock
305
- const documentClock = room.documentClock
306
- await room.updateStore((store) => {
307
- const document = store.get('document:document') as TLDocument
308
- document.name = 'My lovely document'
309
- store.put(document)
310
- })
311
-
312
- expect(clock).toBeLessThan(room.clock)
313
- expect(documentClock).toBeLessThan(room.documentClock)
314
-
315
- expect(socketA.__lastMessage).toMatchInlineSnapshot(`
316
- {
317
- "data": [
318
- {
319
- "diff": {
320
- "document:document": [
321
- "patch",
322
- {
323
- "name": [
324
- "append",
325
- "My lovely document",
326
- 0,
327
- ],
328
- },
329
- ],
330
- },
331
- "serverClock": 1,
332
- "type": "patch",
333
- },
334
- ],
335
- "type": "data",
336
- }
337
- `)
338
- expect(socketB.__lastMessage).toEqual(socketA.__lastMessage)
339
- })
340
-
341
- test('it allows adding new records', async () => {
342
- const id = PageRecordType.createId('page_3')
343
- await room.updateStore((store) => {
344
- const page = PageRecordType.create({ id, name: 'page 3', index: 'a0' as IndexKey })
345
- store.put(page)
346
- })
347
-
348
- expect(socketA.__lastMessage).toMatchInlineSnapshot(`
349
- {
350
- "data": [
351
- {
352
- "diff": {
353
- "page:page_3": [
354
- "put",
355
- {
356
- "id": "page:page_3",
357
- "index": "a0",
358
- "meta": {},
359
- "name": "page 3",
360
- "typeName": "page",
361
- },
362
- ],
363
- },
364
- "serverClock": 1,
365
- "type": "patch",
366
- },
367
- ],
368
- "type": "data",
369
- }
370
- `)
371
- expect(socketB.__lastMessage).toEqual(socketA.__lastMessage)
372
- expect(room.getSnapshot().documents.find((r) => r.state.id === id)?.state).toBeTruthy()
373
- })
374
-
375
- test('it allows deleting records', async () => {
376
- await room.updateStore((store) => {
377
- store.delete('page:page_2')
378
- })
379
-
380
- expect(socketA.__lastMessage).toMatchInlineSnapshot(`
381
- {
382
- "data": [
383
- {
384
- "diff": {
385
- "page:page_2": [
386
- "remove",
387
- ],
388
- },
389
- "serverClock": 1,
390
- "type": "patch",
391
- },
392
- ],
393
- "type": "data",
394
- }
395
- `)
396
- expect(socketB.__lastMessage).toEqual(socketA.__lastMessage)
397
- expect(room.getSnapshot().documents.find((r) => r.state.id === 'page:page_2')).toBeFalsy()
398
- })
399
-
400
- test('it wont do anything if your changes are no-ops', async () => {
401
- const documentClock = room.documentClock
402
- await room.updateStore((store) => {
403
- const newPage = PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey })
404
- store.put(newPage)
405
- store.delete(newPage.id)
406
- })
407
-
408
- expect(room.documentClock).toBe(documentClock)
409
- expect(socketA.__lastMessage).toBeNull()
410
- expect(socketB.__lastMessage).toBeNull()
411
-
412
- await room.updateStore((store) => {
413
- const page = store.get('page:page_2')!
414
- store.delete(page)
415
- store.put(page)
416
- })
417
-
418
- expect(room.documentClock).toBe(documentClock)
419
- expect(socketA.__lastMessage).toBeNull()
420
- expect(socketB.__lastMessage).toBeNull()
421
-
422
- await room.updateStore((store) => {
423
- let page = store.get('page:page_2') as TLPage
424
- page.name = 'my lovely page'
425
- store.put(page)
426
- page = store.get('page:page_2') as TLPage
427
- store.delete(page)
428
- page.name = 'page 2'
429
- store.put(page)
430
- })
431
-
432
- expect(room.documentClock).toBe(documentClock)
433
- expect(socketA.__lastMessage).toBeNull()
434
- expect(socketB.__lastMessage).toBeNull()
435
- })
436
-
437
- test('it returns all records if you ask for them', async () => {
438
- let allRecords
439
- await room.updateStore((store) => {
440
- allRecords = store.getAll()
441
- })
442
- expect(allRecords!.sort(compareById)).toEqual(records)
443
- await room.updateStore((store) => {
444
- const page3 = PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey })
445
- store.put(page3)
446
- allRecords = store.getAll()
447
- expect(allRecords.sort(compareById)).toEqual([...records, page3].sort(compareById))
448
- store.delete(page3)
449
- allRecords = store.getAll()
450
- })
451
- expect(allRecords!.sort(compareById)).toEqual(records)
452
- })
453
-
454
- test('all operations fail after the store is closed', async () => {
455
- let store
456
- await room.updateStore((s) => {
457
- store = s
458
- })
459
- expect(() => {
460
- store!.put(PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey }))
461
- }).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
462
- expect(() => {
463
- store!.delete('page:page_2')
464
- }).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
465
- expect(() => {
466
- store!.getAll()
467
- }).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
468
- expect(() => {
469
- store!.get('page:page_2')
470
- }).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
471
- })
472
-
473
- test('it fails if the room is closed', async () => {
474
- room.close()
475
- await expect(
476
- room.updateStore(() => {
477
- // noop
478
- })
479
- ).rejects.toMatchInlineSnapshot(`[Error: Cannot update store on a closed room]`)
480
- })
481
-
482
- test('it fails if you try to add bad data', async () => {
483
- await expect(
484
- room.updateStore((store) => {
485
- const page = store.get('page:page_2') as TLPage
486
- page.index = 34 as any
487
- store.put(page)
488
- })
489
- ).rejects.toMatchInlineSnapshot(`[Error: failed to apply changes: INVALID_RECORD]`)
490
- })
491
-
492
- test('changes in multiple transaction are isolated from one another', async () => {
493
- const page3 = PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey })
494
- const didDelete = promiseWithResolve()
495
- const didPut = promiseWithResolve()
496
- const doneA = room.updateStore(async (store) => {
497
- store.put(page3)
498
- didPut.resolve(null)
499
- await didDelete
500
- expect(store.get(page3.id)).toBeTruthy()
501
- })
502
- const doneB = room.updateStore(async (store) => {
503
- await didPut
504
- expect(store.get(page3.id)).toBeFalsy()
505
- store.delete(page3.id)
506
- didDelete.resolve(null)
507
- })
508
- await Promise.all([doneA, doneB])
509
- })
510
-
511
- test('getting something that was deleted in the same transaction returns null', async () => {
512
- await room.updateStore((store) => {
513
- expect(store.get('page:page_2')).toBeTruthy()
514
- store.delete('page:page_2')
515
- expect(store.get('page:page_2')).toBe(null)
516
- })
517
- })
518
-
519
- test('getting something that never existed in the first place returns null', async () => {
520
- await room.updateStore((store) => {
521
- expect(store.get('page:page_3')).toBe(null)
522
- })
523
- })
524
-
525
- test('mutations to shapes gotten via .get are not committed unless you .put', async () => {
526
- const page3 = PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey })
527
- let page4 = PageRecordType.create({ name: 'page 4', index: 'a1' as IndexKey })
528
- let page2
529
- await room.updateStore((store) => {
530
- page2 = store.get('page:page_2') as TLPage
531
- page2.name = 'my lovely page 2'
532
- store.put(page3)
533
- page3.name = 'my lovely page 3'
534
- store.put(page4)
535
- page4 = store.get(page4.id) as TLPage
536
- page4.name = 'my lovely page 4'
537
- })
538
-
539
- const getPageNames = () =>
540
- room
541
- .getSnapshot()
542
- .documents.filter((r) => r.state.typeName === 'page')
543
- .map((r) => (r.state as any).name)
544
- .sort()
545
-
546
- expect(getPageNames()).toEqual(['page 2', 'page 3', 'page 4'])
547
-
548
- await room.updateStore((store) => {
549
- store.put(page2!)
550
- store.put(page3)
551
- store.put(page4)
552
- })
553
-
554
- expect(getPageNames()).toEqual(['my lovely page 2', 'my lovely page 3', 'my lovely page 4'])
555
- })
556
- })
557
-
558
188
  describe('isReadonly', () => {
559
189
  const sessionAId = 'sessionA'
560
190
  const sessionBId = 'sessionB'
561
- let room = new TLSyncRoom<TLRecord, undefined>({ schema, snapshot: makeSnapshot(records) })
191
+ let storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
192
+ let room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
562
193
  let socketA = makeSocket()
563
194
  let socketB = makeSocket()
195
+ const getDoc = (id: string) => storage.documents.get(id)?.state
564
196
  function init(snapshot?: RoomSnapshot) {
565
- room = new TLSyncRoom<TLRecord, undefined>({
566
- schema,
567
- snapshot: snapshot ?? makeSnapshot(records),
568
- })
197
+ storage = new InMemorySyncStorage<TLRecord>({ snapshot: snapshot ?? makeSnapshot(records) })
198
+ room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
569
199
  socketA = makeSocket()
570
200
  socketB = makeSocket()
571
201
  room.handleNewSession({
@@ -623,7 +253,7 @@ describe('isReadonly', () => {
623
253
  // sessionA is readonly
624
254
  room.handleMessage(sessionAId, push)
625
255
 
626
- expect(room.documents.get('page:page_3')?.state).toBe(undefined)
256
+ expect(getDoc('page:page_3')).toBe(undefined)
627
257
  // should tell the session to discard it
628
258
  expect(socketA.__lastMessage).toMatchInlineSnapshot(`
629
259
  {
@@ -631,7 +261,7 @@ describe('isReadonly', () => {
631
261
  {
632
262
  "action": "discard",
633
263
  "clientClock": 0,
634
- "serverClock": 1,
264
+ "serverClock": 0,
635
265
  "type": "push_result",
636
266
  },
637
267
  ],
@@ -644,7 +274,7 @@ describe('isReadonly', () => {
644
274
  // sessionB is not readonly
645
275
  room.handleMessage(sessionBId, push)
646
276
 
647
- expect(room.documents.get('page:page_3')?.state).not.toBe(undefined)
277
+ expect(getDoc('page:page_3')).not.toBe(undefined)
648
278
 
649
279
  // should tell the session to commit it
650
280
  expect(socketB.__lastMessage).toMatchInlineSnapshot(`
@@ -653,7 +283,7 @@ describe('isReadonly', () => {
653
283
  {
654
284
  "action": "commit",
655
285
  "clientClock": 0,
656
- "serverClock": 2,
286
+ "serverClock": 1,
657
287
  "type": "push_result",
658
288
  },
659
289
  ],
@@ -688,7 +318,7 @@ describe('isReadonly', () => {
688
318
  {
689
319
  "action": "commit",
690
320
  "clientClock": 0,
691
- "serverClock": 1,
321
+ "serverClock": 0,
692
322
  "type": "push_result",
693
323
  },
694
324
  ],
@@ -724,7 +354,7 @@ describe('isReadonly', () => {
724
354
  },
725
355
  ],
726
356
  },
727
- "serverClock": 1,
357
+ "serverClock": 0,
728
358
  "type": "patch",
729
359
  },
730
360
  ],
@@ -737,16 +367,14 @@ describe('isReadonly', () => {
737
367
  it('can load snapshot without documentClock field', () => {
738
368
  const legacySnapshot = makeLegacySnapshot(records)
739
369
 
740
- const room = new TLSyncRoom<TLRecord, undefined>({
741
- schema,
742
- snapshot: legacySnapshot,
743
- })
370
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot: legacySnapshot })
371
+ const _room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
744
372
 
745
373
  // Room should load successfully without errors
746
- expect(room.getSnapshot().documents.length).toBe(2)
374
+ expect(storage.getSnapshot().documents.length).toBe(2)
747
375
 
748
376
  // documentClock should be calculated from existing data
749
- const snapshot = room.getSnapshot()
377
+ const snapshot = storage.getSnapshot()
750
378
  expect(snapshot.documentClock).toBe(0) // max lastChangedClock from documents
751
379
  })
752
380
 
@@ -758,12 +386,10 @@ describe('isReadonly', () => {
758
386
  ],
759
387
  })
760
388
 
761
- const room = new TLSyncRoom<TLRecord, undefined>({
762
- schema,
763
- snapshot: legacySnapshot,
764
- })
389
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot: legacySnapshot })
390
+ const _room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
765
391
 
766
- const snapshot = room.getSnapshot()
392
+ const snapshot = storage.getSnapshot()
767
393
  expect(snapshot.documentClock).toBe(10) // max lastChangedClock
768
394
  })
769
395
 
@@ -776,12 +402,10 @@ describe('isReadonly', () => {
776
402
  },
777
403
  })
778
404
 
779
- const room = new TLSyncRoom<TLRecord, undefined>({
780
- schema,
781
- snapshot: legacySnapshot,
782
- })
405
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot: legacySnapshot })
406
+ const _room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
783
407
 
784
- const snapshot = room.getSnapshot()
408
+ const snapshot = storage.getSnapshot()
785
409
  expect(snapshot.documentClock).toBe(12) // max of document (3) and tombstones (7, 12)
786
410
  })
787
411
 
@@ -791,12 +415,10 @@ describe('isReadonly', () => {
791
415
  tombstones: {},
792
416
  })
793
417
 
794
- const room = new TLSyncRoom<TLRecord, undefined>({
795
- schema,
796
- snapshot: emptyLegacySnapshot,
797
- })
418
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot: emptyLegacySnapshot })
419
+ const _room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
798
420
 
799
- const snapshot = room.getSnapshot()
421
+ const snapshot = storage.getSnapshot()
800
422
  expect(snapshot.documentClock).toBe(0) // no documents or tombstones
801
423
  })
802
424
 
@@ -809,12 +431,10 @@ describe('isReadonly', () => {
809
431
  },
810
432
  })
811
433
 
812
- const room = new TLSyncRoom<TLRecord, undefined>({
813
- schema,
814
- snapshot: legacySnapshot,
815
- })
434
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot: legacySnapshot })
435
+ const _room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
816
436
 
817
- const snapshot = room.getSnapshot()
437
+ const snapshot = storage.getSnapshot()
818
438
  expect(snapshot.documentClock).toBe(8) // max tombstone clock
819
439
  })
820
440
 
@@ -823,96 +443,964 @@ describe('isReadonly', () => {
823
443
  documentClock: 15,
824
444
  })
825
445
 
826
- const room = new TLSyncRoom<TLRecord, undefined>({
827
- schema,
828
- snapshot: snapshotWithDocumentClock,
829
- })
446
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot: snapshotWithDocumentClock })
447
+ const _room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
830
448
 
831
- const snapshot = room.getSnapshot()
449
+ const snapshot = storage.getSnapshot()
832
450
  expect(snapshot.documentClock).toBe(15) // should preserve explicit value
833
451
  })
452
+ })
453
+ })
834
454
 
835
- describe('Document clock initialization logic', () => {
836
- it('sets documentClock to room clock when migrations run (didIncrementClock = true)', () => {
837
- // Create a schema with a migration that will update documents
838
- const schemaWithMigration = createTLSchema({
839
- migrations: [
840
- {
841
- sequenceId: 'test-migration',
842
- retroactive: false,
843
- sequence: [
844
- {
845
- id: 'test-migration/1',
846
- scope: 'record',
847
- filter: (record: any) => record.typeName === 'document',
848
- up: (record: any) => {
849
- // Modify the record to trigger clock increment
850
- return { ...record, meta: { ...record.meta, migrated: true } }
851
- },
852
- },
853
- ],
854
- },
855
- ],
856
- })
857
-
858
- const snapshotWithDocumentClock = makeSnapshot(records, {
859
- documentClock: 5,
860
- clock: 10,
861
- })
862
-
863
- const onDataChange = vi.fn()
864
- const room = new TLSyncRoom<TLRecord, undefined>({
865
- schema: schemaWithMigration,
866
- snapshot: snapshotWithDocumentClock,
867
- onDataChange,
868
- })
869
-
870
- // Migration should have run, incrementing the clock
871
- expect(room.getSnapshot().clock).toBe(11)
872
- expect(room.getSnapshot().documentClock).toBe(11)
873
- expect(onDataChange).toHaveBeenCalled()
874
- })
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 })
875
459
 
876
- it('preserves documentClock from snapshot when no migrations run (didIncrementClock = false)', () => {
877
- const snapshotWithDocumentClock = makeSnapshot(records, {
878
- documentClock: 15,
879
- clock: 20,
880
- })
881
-
882
- const onDataChange = vi.fn()
883
- const room = new TLSyncRoom<TLRecord, undefined>({
884
- schema,
885
- snapshot: snapshotWithDocumentClock,
886
- onDataChange,
887
- })
888
-
889
- // No migrations should have run
890
- expect(room.getSnapshot().documentClock).toBe(15)
891
- expect(room.getSnapshot().clock).toBe(20)
892
- expect(onDataChange).not.toHaveBeenCalled()
893
- })
460
+ const socket = makeSocket()
461
+ const sessionId = 'test-session'
894
462
 
895
- it('calculates documentClock when snapshot lacks documentClock field (didIncrementClock = false)', () => {
896
- const legacySnapshot = makeLegacySnapshot(records, {
897
- documents: [
898
- { state: records[0], lastChangedClock: 7 },
899
- { state: records[1], lastChangedClock: 12 },
900
- ],
901
- clock: 15,
902
- })
903
-
904
- const onDataChange = vi.fn()
905
- const room = new TLSyncRoom<TLRecord, undefined>({
906
- schema,
907
- snapshot: legacySnapshot,
908
- onDataChange,
909
- })
910
-
911
- // Should calculate from existing data
912
- expect(room.getSnapshot().documentClock).toBe(12) // max lastChangedClock
913
- expect(room.getSnapshot().clock).toBe(15) // clock from snapshot
914
- expect(onDataChange).not.toHaveBeenCalled()
915
- })
463
+ room.handleNewSession({
464
+ sessionId,
465
+ socket,
466
+ meta: undefined,
467
+ isReadonly: false,
468
+ })
469
+
470
+ room.handleMessage(sessionId, {
471
+ connectRequestId: 'connect-1',
472
+ lastServerClock: 0,
473
+ protocolVersion: getTlsyncProtocolVersion(),
474
+ schema: room.serializedSchema,
475
+ type: 'connect',
476
+ })
477
+
478
+ expect(room.sessions.get(sessionId)?.state).toBe('connected')
479
+ socket.__lastMessage = null
480
+
481
+ // 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
+ })
487
+
488
+ storage.transaction((txn) => {
489
+ txn.set(newPage.id, newPage)
490
+ })
491
+
492
+ // Wait for onChange microtask to fire
493
+ await Promise.resolve()
494
+ // Wait for the room to process the change
495
+ await Promise.resolve()
496
+
497
+ // The client should have received a patch with the new page
498
+ expect(socket.sendMessage).toHaveBeenCalled()
499
+ const calls = (socket.sendMessage as any).mock.calls
500
+ const lastCall = calls[calls.length - 1][0]
501
+
502
+ // Should be a data message containing a patch
503
+ expect(lastCall.type).toBe('data')
504
+ expect(lastCall.data).toEqual(
505
+ expect.arrayContaining([
506
+ expect.objectContaining({
507
+ type: 'patch',
508
+ diff: expect.objectContaining({
509
+ [newPage.id]: ['put', expect.objectContaining({ id: newPage.id })],
510
+ }),
511
+ }),
512
+ ])
513
+ )
514
+ })
515
+
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()
557
+
558
+ // 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
+ })
564
+
565
+ room.handleMessage(sessionAId, {
566
+ type: 'push',
567
+ clientClock: 1,
568
+ diff: {
569
+ [newPage.id]: ['put', newPage],
570
+ },
571
+ } as TLPushRequest<TLRecord>)
572
+
573
+ // Wait for any microtasks
574
+ await Promise.resolve()
575
+
576
+ // 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]
580
+ expect(lastAMessage.type).toBe('data')
581
+ expect(lastAMessage.data[0].type).toBe('push_result')
582
+
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')
588
+ })
589
+
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()
612
+
613
+ // 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
+ })
624
+
625
+ storage.transaction((txn) => {
626
+ txn.set(page1.id, page1)
916
627
  })
628
+ storage.transaction((txn) => {
629
+ txn.set(page2.id, page2)
630
+ })
631
+
632
+ // Wait for all microtasks to settle
633
+ await Promise.resolve()
634
+
635
+ // 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 }), {})
654
+ expect(allDiffs[page1.id]).toBeDefined()
655
+ expect(allDiffs[page2.id]).toBeDefined()
656
+ })
657
+ })
658
+
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
+ }
669
+
670
+ const storage = new InMemorySyncStorage<TLRecord>({
671
+ snapshot: makeSnapshot([...records, oldArrow as any], {
672
+ schema: oldSerializedSchema,
673
+ }),
674
+ })
675
+
676
+ // First migration (simulating what TLSyncRoom constructor does)
677
+ const _room1 = new TLSyncRoom<TLRecord, undefined>({
678
+ schema,
679
+ storage,
680
+ })
681
+
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')
689
+
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
+ })
696
+
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
701
+
702
+ // Should still have the migrated value
703
+ expect(arrowAfterSecond.props.labelColor).toBe('black')
704
+
705
+ // Document count should be the same
706
+ expect(snapshotAfterSecond.documents.length).toBe(snapshotAfterFirst.documents.length)
707
+
708
+ // Schema should be up to date
709
+ expect(snapshotAfterSecond.schema).toEqual(schema.serialize())
710
+ })
711
+
712
+ it('already migrated data is not modified again', () => {
713
+ // Start with current schema (no migration needed)
714
+ const storage = new InMemorySyncStorage<TLRecord>({
715
+ snapshot: makeSnapshot(records, {
716
+ documentClock: 10,
717
+ }),
718
+ })
719
+
720
+ const clockBefore = storage.getClock()
721
+
722
+ // Create room - should not modify anything since schema is current
723
+ const _room = new TLSyncRoom<TLRecord, undefined>({
724
+ schema,
725
+ storage,
726
+ })
727
+
728
+ // Clock should not have changed since no migration was needed
729
+ expect(storage.getClock()).toBe(clockBefore)
730
+
731
+ // Documents should be unchanged
732
+ expect(storage.getSnapshot().documents.length).toBe(2)
733
+ })
734
+
735
+ it('migration updates schema version in storage', () => {
736
+ const serializedSchema = schema.serialize()
737
+ const oldSerializedSchema: SerializedSchemaV2 = {
738
+ schemaVersion: 2,
739
+ sequences: {
740
+ ...serializedSchema.sequences,
741
+ 'com.tldraw.shape.arrow': 0,
742
+ },
743
+ }
744
+
745
+ const storage = new InMemorySyncStorage<TLRecord>({
746
+ snapshot: makeSnapshot([...records, oldArrow as any], {
747
+ schema: oldSerializedSchema,
748
+ }),
749
+ })
750
+
751
+ // Verify old schema before room creation
752
+ expect(storage.schema.get()).toEqual(oldSerializedSchema)
753
+
754
+ // Create room which triggers migration
755
+ const _room = new TLSyncRoom<TLRecord, undefined>({
756
+ schema,
757
+ storage,
758
+ })
759
+
760
+ // Schema should now be updated to current version
761
+ expect(storage.schema.get()).toEqual(schema.serialize())
762
+ })
763
+ })
764
+
765
+ describe('Protocol version handling', () => {
766
+ it('sets supportsStringAppend to false for protocol version 7', () => {
767
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
768
+ const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
769
+
770
+ const socket = makeSocket()
771
+ const sessionId = 'v7-session'
772
+
773
+ room.handleNewSession({
774
+ sessionId,
775
+ socket,
776
+ meta: undefined,
777
+ isReadonly: false,
778
+ })
779
+
780
+ // Connect with protocol version 7 (which gets upgraded to 8 but with supportsStringAppend = false)
781
+ room.handleMessage(sessionId, {
782
+ connectRequestId: 'connect-1',
783
+ lastServerClock: 0,
784
+ protocolVersion: 7,
785
+ schema: room.serializedSchema,
786
+ type: 'connect',
787
+ })
788
+
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
+ }
794
+ })
795
+
796
+ it('sets supportsStringAppend to true for protocol version 8', () => {
797
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
798
+ const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
799
+
800
+ const socket = makeSocket()
801
+ const sessionId = 'v8-session'
802
+
803
+ room.handleNewSession({
804
+ sessionId,
805
+ socket,
806
+ meta: undefined,
807
+ isReadonly: false,
808
+ })
809
+
810
+ room.handleMessage(sessionId, {
811
+ connectRequestId: 'connect-1',
812
+ lastServerClock: 0,
813
+ protocolVersion: getTlsyncProtocolVersion(), // version 8
814
+ schema: room.serializedSchema,
815
+ type: 'connect',
816
+ })
817
+
818
+ const session = room.sessions.get(sessionId)
819
+ expect(session?.state).toBe('connected')
820
+ if (session?.state === 'connected') {
821
+ expect(session.supportsStringAppend).toBe(true)
822
+ }
823
+ })
824
+
825
+ it('getCanEmitStringAppend returns false when any client lacks string append support', () => {
826
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
827
+ const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
828
+
829
+ const socketV7 = makeSocket()
830
+ const socketV8 = makeSocket()
831
+
832
+ // Connect v8 client first
833
+ room.handleNewSession({
834
+ sessionId: 'v8-client',
835
+ socket: socketV8,
836
+ meta: undefined,
837
+ isReadonly: false,
838
+ })
839
+ room.handleMessage('v8-client', {
840
+ connectRequestId: 'connect-v8',
841
+ lastServerClock: 0,
842
+ protocolVersion: 8,
843
+ schema: room.serializedSchema,
844
+ type: 'connect',
845
+ })
846
+
847
+ // With only v8 client, should be true
848
+ expect(room.getCanEmitStringAppend()).toBe(true)
849
+
850
+ // Connect v7 client
851
+ room.handleNewSession({
852
+ sessionId: 'v7-client',
853
+ socket: socketV7,
854
+ meta: undefined,
855
+ isReadonly: false,
856
+ })
857
+ room.handleMessage('v7-client', {
858
+ connectRequestId: 'connect-v7',
859
+ lastServerClock: 0,
860
+ protocolVersion: 7,
861
+ schema: room.serializedSchema,
862
+ type: 'connect',
863
+ })
864
+
865
+ // With mixed clients, should be false
866
+ expect(room.getCanEmitStringAppend()).toBe(false)
867
+ })
868
+ })
869
+
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 })
876
+
877
+ const socket = makeSocket()
878
+ const sessionId = 'presence-test'
879
+
880
+ room.handleNewSession({
881
+ sessionId,
882
+ socket,
883
+ meta: undefined,
884
+ isReadonly: false,
885
+ })
886
+
887
+ room.handleMessage(sessionId, {
888
+ connectRequestId: 'connect-1',
889
+ lastServerClock: 10,
890
+ protocolVersion: getTlsyncProtocolVersion(),
891
+ schema: room.serializedSchema,
892
+ type: 'connect',
893
+ })
894
+
895
+ const clockBefore = storage.getClock()
896
+
897
+ // Send presence update
898
+ room.handleMessage(sessionId, {
899
+ type: 'push',
900
+ clientClock: 1,
901
+ diff: undefined,
902
+ presence: [
903
+ 'put',
904
+ InstancePresenceRecordType.create({
905
+ id: InstancePresenceRecordType.createId('presence-1'),
906
+ currentPageId: PageRecordType.createId('page_2'),
907
+ userId: 'user-1',
908
+ userName: 'Test User',
909
+ }),
910
+ ],
911
+ } as TLPushRequest<TLRecord>)
912
+
913
+ // Document clock should not have changed
914
+ expect(storage.getClock()).toBe(clockBefore)
915
+ })
916
+
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 })
920
+
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',
937
+ })
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()
964
+ })
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,
979
+ })
980
+
981
+ room.handleMessage(sessionId, {
982
+ connectRequestId: 'connect-1',
983
+ lastServerClock: 0,
984
+ protocolVersion: getTlsyncProtocolVersion(),
985
+ schema: room.serializedSchema,
986
+ type: 'connect',
987
+ })
988
+
989
+ const session = room.sessions.get(sessionId)!
990
+ const presenceId = session.presenceId!
991
+
992
+ // Send presence
993
+ room.handleMessage(sessionId, {
994
+ type: 'push',
995
+ clientClock: 1,
996
+ diff: undefined,
997
+ presence: [
998
+ 'put',
999
+ InstancePresenceRecordType.create({
1000
+ id: InstancePresenceRecordType.createId('any'),
1001
+ currentPageId: PageRecordType.createId('page_2'),
1002
+ userId: 'user-1',
1003
+ userName: 'Test User',
1004
+ }),
1005
+ ],
1006
+ } as TLPushRequest<TLRecord>)
1007
+
1008
+ expect(room.presenceStore.get(presenceId)).toBeDefined()
1009
+
1010
+ // Close the session
1011
+ room.rejectSession(sessionId)
1012
+
1013
+ // Presence should be removed
1014
+ expect(room.presenceStore.get(presenceId)).toBeUndefined()
1015
+ })
1016
+ })
1017
+
1018
+ describe('Client with future clock', () => {
1019
+ it('handles client with lastServerClock greater than current documentClock', () => {
1020
+ const storage = new InMemorySyncStorage<TLRecord>({
1021
+ snapshot: makeSnapshot(records, { documentClock: 10 }),
1022
+ })
1023
+ const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
1024
+
1025
+ const socket = makeSocket()
1026
+ const sessionId = 'future-clock-client'
1027
+
1028
+ room.handleNewSession({
1029
+ sessionId,
1030
+ socket,
1031
+ meta: undefined,
1032
+ isReadonly: false,
1033
+ })
1034
+
1035
+ // Client claims to have clock 100, but server is only at 10
1036
+ room.handleMessage(sessionId, {
1037
+ connectRequestId: 'connect-1',
1038
+ lastServerClock: 100, // Future clock!
1039
+ protocolVersion: getTlsyncProtocolVersion(),
1040
+ schema: room.serializedSchema,
1041
+ type: 'connect',
1042
+ })
1043
+
1044
+ // Session should still connect successfully
1045
+ expect(room.sessions.get(sessionId)?.state).toBe('connected')
1046
+
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)
1055
+ }
1056
+ })
1057
+
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 })
1069
+
1070
+ const socket = makeSocket()
1071
+ const sessionId = 'future-client'
1072
+
1073
+ room.handleNewSession({
1074
+ sessionId,
1075
+ socket,
1076
+ meta: undefined,
1077
+ isReadonly: false,
1078
+ })
1079
+
1080
+ room.handleMessage(sessionId, {
1081
+ connectRequestId: 'connect-1',
1082
+ lastServerClock: 999,
1083
+ protocolVersion: getTlsyncProtocolVersion(),
1084
+ schema: room.serializedSchema,
1085
+ type: 'connect',
1086
+ })
1087
+
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'
1105
+
1106
+ room.handleNewSession({
1107
+ sessionId,
1108
+ socket,
1109
+ meta: undefined,
1110
+ isReadonly: false,
1111
+ })
1112
+
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()
1121
+
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
+ })
1128
+
1129
+ storage.transaction((txn) => {
1130
+ txn.set(newPage.id, newPage)
1131
+ })
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
+ })
1155
+
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 })
1167
+
1168
+ const socket = makeSocket()
1169
+ const sessionId = 'active-session'
1170
+
1171
+ room.handleNewSession({
1172
+ sessionId,
1173
+ socket,
1174
+ meta: undefined,
1175
+ isReadonly: false,
1176
+ })
1177
+
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()
1186
+
1187
+ // Delete the extra page via storage
1188
+ storage.transaction((txn) => {
1189
+ txn.delete(extraPage.id)
1190
+ })
1191
+
1192
+ await Promise.resolve()
1193
+
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
+ }
1209
+
1210
+ const allDiffs = allPatches.reduce((acc, patch) => ({ ...acc, ...patch.diff }), {})
1211
+ expect(allDiffs[extraPage.id]).toEqual(['remove'])
1212
+ })
1213
+ })
1214
+
1215
+ describe('Invalid record handling', () => {
1216
+ it('rejects session when push contains record with unknown type', () => {
1217
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
1218
+ const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
1219
+
1220
+ const socket = makeSocket()
1221
+ const sessionId = 'invalid-record-session'
1222
+
1223
+ room.handleNewSession({
1224
+ sessionId,
1225
+ socket,
1226
+ meta: undefined,
1227
+ isReadonly: false,
1228
+ })
1229
+
1230
+ room.handleMessage(sessionId, {
1231
+ connectRequestId: 'connect-1',
1232
+ lastServerClock: 0,
1233
+ protocolVersion: getTlsyncProtocolVersion(),
1234
+ schema: room.serializedSchema,
1235
+ type: 'connect',
1236
+ })
1237
+
1238
+ expect(room.sessions.get(sessionId)?.state).toBe('connected')
1239
+
1240
+ // Try to push a record with an unknown type
1241
+ room.handleMessage(sessionId, {
1242
+ type: 'push',
1243
+ clientClock: 1,
1244
+ diff: {
1245
+ 'unknown:record': [
1246
+ 'put',
1247
+ {
1248
+ id: 'unknown:record',
1249
+ typeName: 'unknown_type_that_does_not_exist',
1250
+ } as any,
1251
+ ],
1252
+ },
1253
+ } as TLPushRequest<TLRecord>)
1254
+
1255
+ // Session should be rejected/removed
1256
+ const session = room.sessions.get(sessionId)
1257
+ expect(session?.state === 'connected').toBe(false)
1258
+ })
1259
+ })
1260
+
1261
+ describe('Migration and patch handling', () => {
1262
+ it('successfully patches arrow shape with current schema', () => {
1263
+ // Create a document in the room first
1264
+ const existingArrow: TLArrowShape = {
1265
+ typeName: 'shape',
1266
+ type: 'arrow',
1267
+ id: 'shape:existing_arrow' as TLShapeId,
1268
+ index: ZERO_INDEX_KEY,
1269
+ isLocked: false,
1270
+ parentId: PageRecordType.createId(),
1271
+ rotation: 0,
1272
+ x: 0,
1273
+ y: 0,
1274
+ opacity: 1,
1275
+ props: {
1276
+ kind: 'arc',
1277
+ elbowMidPoint: 0.5,
1278
+ dash: 'draw',
1279
+ size: 'm',
1280
+ fill: 'none',
1281
+ color: 'black',
1282
+ labelColor: 'black',
1283
+ bend: 0,
1284
+ start: { x: 0, y: 0 },
1285
+ end: { x: 0, y: 0 },
1286
+ arrowheadStart: 'none',
1287
+ arrowheadEnd: 'arrow',
1288
+ richText: { type: 'doc', content: [] },
1289
+ font: 'draw',
1290
+ labelPosition: 0.5,
1291
+ scale: 1,
1292
+ },
1293
+ meta: {},
1294
+ }
1295
+
1296
+ const storage = new InMemorySyncStorage<TLRecord>({
1297
+ snapshot: makeSnapshot([...records, existingArrow]),
1298
+ })
1299
+ const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
1300
+
1301
+ const socket = makeSocket()
1302
+ const sessionId = 'patch-migration-session'
1303
+
1304
+ room.handleNewSession({
1305
+ sessionId,
1306
+ socket,
1307
+ meta: undefined,
1308
+ isReadonly: false,
1309
+ })
1310
+
1311
+ // Connect with current schema
1312
+ room.handleMessage(sessionId, {
1313
+ connectRequestId: 'connect-1',
1314
+ lastServerClock: 0,
1315
+ protocolVersion: getTlsyncProtocolVersion(),
1316
+ schema: room.serializedSchema,
1317
+ type: 'connect',
1318
+ })
1319
+
1320
+ expect(room.sessions.get(sessionId)?.state).toBe('connected')
1321
+
1322
+ // Patch the arrow - this should work normally
1323
+ room.handleMessage(sessionId, {
1324
+ type: 'push',
1325
+ clientClock: 1,
1326
+ diff: {
1327
+ [existingArrow.id]: ['patch', { props: ['patch', { color: ['put', 'red'] }] }],
1328
+ },
1329
+ } as TLPushRequest<TLRecord>)
1330
+
1331
+ // Session should still be connected after valid patch
1332
+ expect(room.sessions.get(sessionId)?.state).toBe('connected')
1333
+
1334
+ // Verify the patch was applied
1335
+ const patchedArrow = storage.documents.get(existingArrow.id)?.state as TLArrowShape
1336
+ expect(patchedArrow.props.color).toBe('red')
1337
+ })
1338
+
1339
+ it('rejects client with incompatible schema version that lacks down migrations', () => {
1340
+ // Use an old schema version that can't connect (arrow v0 has no down migration)
1341
+ const serializedSchema = schema.serialize()
1342
+ const oldSerializedSchema: SerializedSchemaV2 = {
1343
+ schemaVersion: 2,
1344
+ sequences: {
1345
+ ...serializedSchema.sequences,
1346
+ // Set arrow shape to version 0, which has retired down migrations
1347
+ 'com.tldraw.shape.arrow': 0,
1348
+ },
1349
+ }
1350
+
1351
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
1352
+ const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
1353
+
1354
+ const socket = makeSocket()
1355
+ const sessionId = 'old-client-session'
1356
+
1357
+ room.handleNewSession({
1358
+ sessionId,
1359
+ socket,
1360
+ meta: undefined,
1361
+ isReadonly: false,
1362
+ })
1363
+
1364
+ // Connect with old schema - this should fail because arrow v1 migration has no down function
1365
+ room.handleMessage(sessionId, {
1366
+ connectRequestId: 'connect-1',
1367
+ lastServerClock: 0,
1368
+ protocolVersion: getTlsyncProtocolVersion(),
1369
+ schema: oldSerializedSchema,
1370
+ type: 'connect',
1371
+ })
1372
+
1373
+ // Session should not be connected - it was rejected due to incompatible schema
1374
+ expect(room.sessions.get(sessionId)?.state).not.toBe('connected')
1375
+
1376
+ // Socket should be closed with CLIENT_TOO_OLD
1377
+ expect(socket.isOpen).toBe(false)
1378
+ })
1379
+
1380
+ it('accepts client with same schema version', () => {
1381
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
1382
+ const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
1383
+
1384
+ const socket = makeSocket()
1385
+ const sessionId = 'current-client-session'
1386
+
1387
+ room.handleNewSession({
1388
+ sessionId,
1389
+ socket,
1390
+ meta: undefined,
1391
+ isReadonly: false,
1392
+ })
1393
+
1394
+ // Connect with same schema
1395
+ room.handleMessage(sessionId, {
1396
+ connectRequestId: 'connect-1',
1397
+ lastServerClock: 0,
1398
+ protocolVersion: getTlsyncProtocolVersion(),
1399
+ schema: room.serializedSchema,
1400
+ type: 'connect',
1401
+ })
1402
+
1403
+ // Session should be connected
1404
+ expect(room.sessions.get(sessionId)?.state).toBe('connected')
917
1405
  })
918
1406
  })