@tldraw/sync-core 4.2.2 → 4.2.3

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 (84) hide show
  1. package/dist-cjs/index.d.ts +58 -483
  2. package/dist-cjs/index.js +3 -13
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/RoomSession.js.map +1 -1
  5. package/dist-cjs/lib/TLSocketRoom.js +69 -117
  6. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  7. package/dist-cjs/lib/TLSyncClient.js +0 -7
  8. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  9. package/dist-cjs/lib/TLSyncRoom.js +688 -357
  10. package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
  11. package/dist-cjs/lib/chunk.js +2 -2
  12. package/dist-cjs/lib/chunk.js.map +1 -1
  13. package/dist-esm/index.d.mts +58 -483
  14. package/dist-esm/index.mjs +5 -20
  15. package/dist-esm/index.mjs.map +2 -2
  16. package/dist-esm/lib/RoomSession.mjs.map +1 -1
  17. package/dist-esm/lib/TLSocketRoom.mjs +70 -121
  18. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  19. package/dist-esm/lib/TLSyncClient.mjs +0 -7
  20. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  21. package/dist-esm/lib/TLSyncRoom.mjs +702 -370
  22. package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
  23. package/dist-esm/lib/chunk.mjs +2 -2
  24. package/dist-esm/lib/chunk.mjs.map +1 -1
  25. package/package.json +11 -12
  26. package/src/index.ts +3 -32
  27. package/src/lib/ClientWebSocketAdapter.test.ts +0 -3
  28. package/src/lib/RoomSession.test.ts +0 -1
  29. package/src/lib/RoomSession.ts +0 -2
  30. package/src/lib/TLSocketRoom.ts +114 -228
  31. package/src/lib/TLSyncClient.ts +0 -12
  32. package/src/lib/TLSyncRoom.ts +913 -473
  33. package/src/lib/chunk.ts +2 -2
  34. package/src/test/FuzzEditor.ts +5 -4
  35. package/src/test/TLSocketRoom.test.ts +49 -255
  36. package/src/test/TLSyncRoom.test.ts +534 -1024
  37. package/src/test/TestServer.ts +1 -12
  38. package/src/test/customMessages.test.ts +1 -1
  39. package/src/test/presenceMode.test.ts +6 -6
  40. package/src/test/pruneTombstones.test.ts +178 -0
  41. package/src/test/syncFuzz.test.ts +4 -2
  42. package/src/test/upgradeDowngrade.test.ts +8 -290
  43. package/src/test/validation.test.ts +10 -15
  44. package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js +0 -55
  45. package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js.map +0 -7
  46. package/dist-cjs/lib/InMemorySyncStorage.js +0 -287
  47. package/dist-cjs/lib/InMemorySyncStorage.js.map +0 -7
  48. package/dist-cjs/lib/MicrotaskNotifier.js +0 -50
  49. package/dist-cjs/lib/MicrotaskNotifier.js.map +0 -7
  50. package/dist-cjs/lib/NodeSqliteWrapper.js +0 -48
  51. package/dist-cjs/lib/NodeSqliteWrapper.js.map +0 -7
  52. package/dist-cjs/lib/SQLiteSyncStorage.js +0 -428
  53. package/dist-cjs/lib/SQLiteSyncStorage.js.map +0 -7
  54. package/dist-cjs/lib/TLSyncStorage.js +0 -76
  55. package/dist-cjs/lib/TLSyncStorage.js.map +0 -7
  56. package/dist-cjs/lib/recordDiff.js +0 -52
  57. package/dist-cjs/lib/recordDiff.js.map +0 -7
  58. package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs +0 -35
  59. package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs.map +0 -7
  60. package/dist-esm/lib/InMemorySyncStorage.mjs +0 -272
  61. package/dist-esm/lib/InMemorySyncStorage.mjs.map +0 -7
  62. package/dist-esm/lib/MicrotaskNotifier.mjs +0 -30
  63. package/dist-esm/lib/MicrotaskNotifier.mjs.map +0 -7
  64. package/dist-esm/lib/NodeSqliteWrapper.mjs +0 -28
  65. package/dist-esm/lib/NodeSqliteWrapper.mjs.map +0 -7
  66. package/dist-esm/lib/SQLiteSyncStorage.mjs +0 -414
  67. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +0 -7
  68. package/dist-esm/lib/TLSyncStorage.mjs +0 -56
  69. package/dist-esm/lib/TLSyncStorage.mjs.map +0 -7
  70. package/dist-esm/lib/recordDiff.mjs +0 -32
  71. package/dist-esm/lib/recordDiff.mjs.map +0 -7
  72. package/src/lib/DurableObjectSqliteSyncWrapper.ts +0 -95
  73. package/src/lib/InMemorySyncStorage.ts +0 -387
  74. package/src/lib/MicrotaskNotifier.test.ts +0 -429
  75. package/src/lib/MicrotaskNotifier.ts +0 -38
  76. package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
  77. package/src/lib/NodeSqliteSyncWrapper.test.ts +0 -272
  78. package/src/lib/NodeSqliteWrapper.ts +0 -99
  79. package/src/lib/SQLiteSyncStorage.ts +0 -627
  80. package/src/lib/TLSyncStorage.ts +0 -216
  81. package/src/lib/computeTombstonePruning.test.ts +0 -352
  82. package/src/lib/recordDiff.ts +0 -73
  83. package/src/test/InMemorySyncStorage.test.ts +0 -1684
  84. package/src/test/SQLiteSyncStorage.test.ts +0 -1378
@@ -9,14 +9,21 @@ import {
9
9
  TLArrowShapeProps,
10
10
  TLBaseShape,
11
11
  TLDOCUMENT_ID,
12
+ TLDocument,
13
+ TLPage,
12
14
  TLRecord,
13
15
  TLShapeId,
14
16
  createTLSchema,
15
17
  } from '@tldraw/tlschema'
16
- import { IndexKey, ZERO_INDEX_KEY, mockUniqueId, sortById } from '@tldraw/utils'
18
+ import { IndexKey, ZERO_INDEX_KEY, mockUniqueId, promiseWithResolve, sortById } from '@tldraw/utils'
17
19
  import { vi } from 'vitest'
18
- import { InMemorySyncStorage } from '../lib/InMemorySyncStorage'
19
- import { RoomSnapshot, TLRoomSocket, TLSyncRoom } from '../lib/TLSyncRoom'
20
+ import {
21
+ MAX_TOMBSTONES,
22
+ RoomSnapshot,
23
+ TLRoomSocket,
24
+ TLSyncRoom,
25
+ TOMBSTONE_PRUNE_BUFFER_SIZE,
26
+ } from '../lib/TLSyncRoom'
20
27
  import {
21
28
  TLConnectRequest,
22
29
  TLPushRequest,
@@ -40,7 +47,6 @@ const makeSnapshot = (records: TLRecord[], others: Partial<RoomSnapshot> = {}) =
40
47
  documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
41
48
  clock: 0,
42
49
  documentClock: 0,
43
- schema: schema.serialize(),
44
50
  ...others,
45
51
  })
46
52
 
@@ -92,21 +98,63 @@ const oldArrow: TLBaseShape<'arrow', Omit<TLArrowShapeProps, 'labelColor'>> = {
92
98
  }
93
99
 
94
100
  describe('TLSyncRoom', () => {
95
- it('can be constructed with a storage', () => {
96
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
97
- const _room = new TLSyncRoom<TLRecord, undefined>({
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>({
98
110
  schema,
99
- storage,
111
+ snapshot: makeSnapshot(records),
100
112
  })
101
113
 
102
114
  expect(
103
- storage
115
+ room
104
116
  .getSnapshot()
105
117
  .documents.map((r) => r.state)
106
118
  .sort(sortById)
107
119
  ).toEqual(records)
108
120
 
109
- expect(storage.getSnapshot().documents.map((r) => r.lastChangedClock)).toEqual([0, 0])
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)
110
158
  })
111
159
 
112
160
  it('migrates the snapshot if it is dealing with old data', () => {
@@ -119,50 +167,40 @@ describe('TLSyncRoom', () => {
119
167
  },
120
168
  }
121
169
 
122
- const storage = new InMemorySyncStorage<TLRecord>({
123
- snapshot: makeSnapshot([...records, oldArrow as any], {
170
+ const room = new TLSyncRoom({
171
+ schema,
172
+ snapshot: makeSnapshot([...records, oldArrow], {
124
173
  schema: oldSerializedSchema,
125
174
  }),
126
175
  })
127
- const _room = new TLSyncRoom<TLRecord, undefined>({
128
- schema,
129
- storage,
130
- })
131
176
 
132
- const arrow = storage.getSnapshot().documents.find((r) => r.state.id === oldArrow.id)
177
+ const arrow = room.getSnapshot().documents.find((r) => r.state.id === oldArrow.id)
133
178
  ?.state as TLArrowShape
134
179
  expect(arrow.props.labelColor).toBe('black')
135
180
  })
136
181
 
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
- ),
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
+ ]),
161
200
  })
162
- const _room = new TLSyncRoom({ schema, storage })
163
201
 
164
202
  expect(
165
- storage
203
+ room
166
204
  .getSnapshot()
167
205
  .documents.map((r) => r.state)
168
206
  .sort(sortById)
@@ -185,17 +223,349 @@ function makeSocket(): MockSocket {
185
223
  return socket
186
224
  }
187
225
 
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
+
188
558
  describe('isReadonly', () => {
189
559
  const sessionAId = 'sessionA'
190
560
  const sessionBId = 'sessionB'
191
- let storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
192
- let room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
561
+ let room = new TLSyncRoom<TLRecord, undefined>({ schema, snapshot: makeSnapshot(records) })
193
562
  let socketA = makeSocket()
194
563
  let socketB = makeSocket()
195
- const getDoc = (id: string) => storage.documents.get(id)?.state
196
564
  function init(snapshot?: RoomSnapshot) {
197
- storage = new InMemorySyncStorage<TLRecord>({ snapshot: snapshot ?? makeSnapshot(records) })
198
- room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
565
+ room = new TLSyncRoom<TLRecord, undefined>({
566
+ schema,
567
+ snapshot: snapshot ?? makeSnapshot(records),
568
+ })
199
569
  socketA = makeSocket()
200
570
  socketB = makeSocket()
201
571
  room.handleNewSession({
@@ -253,7 +623,7 @@ describe('isReadonly', () => {
253
623
  // sessionA is readonly
254
624
  room.handleMessage(sessionAId, push)
255
625
 
256
- expect(getDoc('page:page_3')).toBe(undefined)
626
+ expect(room.documents.get('page:page_3')?.state).toBe(undefined)
257
627
  // should tell the session to discard it
258
628
  expect(socketA.__lastMessage).toMatchInlineSnapshot(`
259
629
  {
@@ -261,7 +631,7 @@ describe('isReadonly', () => {
261
631
  {
262
632
  "action": "discard",
263
633
  "clientClock": 0,
264
- "serverClock": 0,
634
+ "serverClock": 1,
265
635
  "type": "push_result",
266
636
  },
267
637
  ],
@@ -274,7 +644,7 @@ describe('isReadonly', () => {
274
644
  // sessionB is not readonly
275
645
  room.handleMessage(sessionBId, push)
276
646
 
277
- expect(getDoc('page:page_3')).not.toBe(undefined)
647
+ expect(room.documents.get('page:page_3')?.state).not.toBe(undefined)
278
648
 
279
649
  // should tell the session to commit it
280
650
  expect(socketB.__lastMessage).toMatchInlineSnapshot(`
@@ -283,7 +653,7 @@ describe('isReadonly', () => {
283
653
  {
284
654
  "action": "commit",
285
655
  "clientClock": 0,
286
- "serverClock": 1,
656
+ "serverClock": 2,
287
657
  "type": "push_result",
288
658
  },
289
659
  ],
@@ -318,7 +688,7 @@ describe('isReadonly', () => {
318
688
  {
319
689
  "action": "commit",
320
690
  "clientClock": 0,
321
- "serverClock": 0,
691
+ "serverClock": 1,
322
692
  "type": "push_result",
323
693
  },
324
694
  ],
@@ -354,7 +724,7 @@ describe('isReadonly', () => {
354
724
  },
355
725
  ],
356
726
  },
357
- "serverClock": 0,
727
+ "serverClock": 1,
358
728
  "type": "patch",
359
729
  },
360
730
  ],
@@ -367,14 +737,16 @@ describe('isReadonly', () => {
367
737
  it('can load snapshot without documentClock field', () => {
368
738
  const legacySnapshot = makeLegacySnapshot(records)
369
739
 
370
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: legacySnapshot })
371
- const _room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
740
+ const room = new TLSyncRoom<TLRecord, undefined>({
741
+ schema,
742
+ snapshot: legacySnapshot,
743
+ })
372
744
 
373
745
  // Room should load successfully without errors
374
- expect(storage.getSnapshot().documents.length).toBe(2)
746
+ expect(room.getSnapshot().documents.length).toBe(2)
375
747
 
376
748
  // documentClock should be calculated from existing data
377
- const snapshot = storage.getSnapshot()
749
+ const snapshot = room.getSnapshot()
378
750
  expect(snapshot.documentClock).toBe(0) // max lastChangedClock from documents
379
751
  })
380
752
 
@@ -386,10 +758,12 @@ describe('isReadonly', () => {
386
758
  ],
387
759
  })
388
760
 
389
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: legacySnapshot })
390
- const _room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
761
+ const room = new TLSyncRoom<TLRecord, undefined>({
762
+ schema,
763
+ snapshot: legacySnapshot,
764
+ })
391
765
 
392
- const snapshot = storage.getSnapshot()
766
+ const snapshot = room.getSnapshot()
393
767
  expect(snapshot.documentClock).toBe(10) // max lastChangedClock
394
768
  })
395
769
 
@@ -402,10 +776,12 @@ describe('isReadonly', () => {
402
776
  },
403
777
  })
404
778
 
405
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: legacySnapshot })
406
- const _room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
779
+ const room = new TLSyncRoom<TLRecord, undefined>({
780
+ schema,
781
+ snapshot: legacySnapshot,
782
+ })
407
783
 
408
- const snapshot = storage.getSnapshot()
784
+ const snapshot = room.getSnapshot()
409
785
  expect(snapshot.documentClock).toBe(12) // max of document (3) and tombstones (7, 12)
410
786
  })
411
787
 
@@ -415,10 +791,12 @@ describe('isReadonly', () => {
415
791
  tombstones: {},
416
792
  })
417
793
 
418
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: emptyLegacySnapshot })
419
- const _room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
794
+ const room = new TLSyncRoom<TLRecord, undefined>({
795
+ schema,
796
+ snapshot: emptyLegacySnapshot,
797
+ })
420
798
 
421
- const snapshot = storage.getSnapshot()
799
+ const snapshot = room.getSnapshot()
422
800
  expect(snapshot.documentClock).toBe(0) // no documents or tombstones
423
801
  })
424
802
 
@@ -431,10 +809,12 @@ describe('isReadonly', () => {
431
809
  },
432
810
  })
433
811
 
434
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: legacySnapshot })
435
- const _room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
812
+ const room = new TLSyncRoom<TLRecord, undefined>({
813
+ schema,
814
+ snapshot: legacySnapshot,
815
+ })
436
816
 
437
- const snapshot = storage.getSnapshot()
817
+ const snapshot = room.getSnapshot()
438
818
  expect(snapshot.documentClock).toBe(8) // max tombstone clock
439
819
  })
440
820
 
@@ -443,966 +823,96 @@ describe('isReadonly', () => {
443
823
  documentClock: 15,
444
824
  })
445
825
 
446
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: snapshotWithDocumentClock })
447
- const _room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
826
+ const room = new TLSyncRoom<TLRecord, undefined>({
827
+ schema,
828
+ snapshot: snapshotWithDocumentClock,
829
+ })
448
830
 
449
- const snapshot = storage.getSnapshot()
831
+ const snapshot = room.getSnapshot()
450
832
  expect(snapshot.documentClock).toBe(15) // should preserve explicit value
451
833
  })
452
- })
453
- })
454
-
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 })
459
-
460
- const socket = makeSocket()
461
- const sessionId = 'test-session'
462
-
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
834
 
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()
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
+ })
612
875
 
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
- })
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
+ })
624
894
 
625
- storage.transaction((txn) => {
626
- txn.set(page1.id, page1)
627
- })
628
- storage.transaction((txn) => {
629
- txn.set(page2.id, page2)
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
+ })
630
916
  })
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 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'
1223
-
1224
- room.handleNewSession({
1225
- sessionId,
1226
- socket,
1227
- meta: undefined,
1228
- isReadonly: false,
1229
- })
1230
-
1231
- room.handleMessage(sessionId, {
1232
- connectRequestId: 'connect-1',
1233
- lastServerClock: 0,
1234
- protocolVersion: getTlsyncProtocolVersion(),
1235
- schema: room.serializedSchema,
1236
- type: 'connect',
1237
- })
1238
-
1239
- expect(room.sessions.get(sessionId)?.state).toBe('connected')
1240
-
1241
- // Try to push a record with an unknown type
1242
- room.handleMessage(sessionId, {
1243
- type: 'push',
1244
- 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
- },
1254
- } as TLPushRequest<TLRecord>)
1255
-
1256
- // Session should be rejected/removed
1257
- const session = room.sessions.get(sessionId)
1258
- expect(session?.state === 'connected').toBe(false)
1259
- consoleSpy.mockRestore()
1260
- })
1261
- })
1262
-
1263
- describe('Migration and patch handling', () => {
1264
- it('successfully patches arrow shape with current schema', () => {
1265
- // Create a document in the room first
1266
- const existingArrow: TLArrowShape = {
1267
- typeName: 'shape',
1268
- type: 'arrow',
1269
- id: 'shape:existing_arrow' as TLShapeId,
1270
- index: ZERO_INDEX_KEY,
1271
- isLocked: false,
1272
- parentId: PageRecordType.createId(),
1273
- rotation: 0,
1274
- x: 0,
1275
- y: 0,
1276
- opacity: 1,
1277
- props: {
1278
- kind: 'arc',
1279
- elbowMidPoint: 0.5,
1280
- dash: 'draw',
1281
- size: 'm',
1282
- fill: 'none',
1283
- color: 'black',
1284
- labelColor: 'black',
1285
- bend: 0,
1286
- start: { x: 0, y: 0 },
1287
- end: { x: 0, y: 0 },
1288
- arrowheadStart: 'none',
1289
- arrowheadEnd: 'arrow',
1290
- richText: { type: 'doc', content: [] },
1291
- font: 'draw',
1292
- labelPosition: 0.5,
1293
- scale: 1,
1294
- },
1295
- meta: {},
1296
- }
1297
-
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')
1323
-
1324
- // Patch the arrow - this should work normally
1325
- room.handleMessage(sessionId, {
1326
- type: 'push',
1327
- clientClock: 1,
1328
- diff: {
1329
- [existingArrow.id]: ['patch', { props: ['patch', { color: ['put', 'red'] }] }],
1330
- },
1331
- } as TLPushRequest<TLRecord>)
1332
-
1333
- // Session should still be connected after valid patch
1334
- expect(room.sessions.get(sessionId)?.state).toBe('connected')
1335
-
1336
- // Verify the patch was applied
1337
- const patchedArrow = storage.documents.get(existingArrow.id)?.state as TLArrowShape
1338
- expect(patchedArrow.props.color).toBe('red')
1339
- })
1340
-
1341
- it('rejects client with incompatible schema version that lacks down migrations', () => {
1342
- // Use an old schema version that can't connect (arrow v0 has no down migration)
1343
- const serializedSchema = schema.serialize()
1344
- const oldSerializedSchema: SerializedSchemaV2 = {
1345
- schemaVersion: 2,
1346
- sequences: {
1347
- ...serializedSchema.sequences,
1348
- // Set arrow shape to version 0, which has retired down migrations
1349
- 'com.tldraw.shape.arrow': 0,
1350
- },
1351
- }
1352
-
1353
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
1354
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
1355
-
1356
- const socket = makeSocket()
1357
- const sessionId = 'old-client-session'
1358
-
1359
- room.handleNewSession({
1360
- sessionId,
1361
- socket,
1362
- meta: undefined,
1363
- isReadonly: false,
1364
- })
1365
-
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
- })
1374
-
1375
- // Session should not be connected - it was rejected due to incompatible schema
1376
- expect(room.sessions.get(sessionId)?.state).not.toBe('connected')
1377
-
1378
- // Socket should be closed with CLIENT_TOO_OLD
1379
- expect(socket.isOpen).toBe(false)
1380
- })
1381
-
1382
- it('accepts client with same schema version', () => {
1383
- const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
1384
- const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
1385
-
1386
- const socket = makeSocket()
1387
- const sessionId = 'current-client-session'
1388
-
1389
- room.handleNewSession({
1390
- sessionId,
1391
- socket,
1392
- meta: undefined,
1393
- isReadonly: false,
1394
- })
1395
-
1396
- // Connect with same schema
1397
- room.handleMessage(sessionId, {
1398
- connectRequestId: 'connect-1',
1399
- lastServerClock: 0,
1400
- protocolVersion: getTlsyncProtocolVersion(),
1401
- schema: room.serializedSchema,
1402
- type: 'connect',
1403
- })
1404
-
1405
- // Session should be connected
1406
- expect(room.sessions.get(sessionId)?.state).toBe('connected')
1407
917
  })
1408
918
  })