@tldraw/sync-core 3.16.0-internal.51e99e128bd4 → 3.16.0-internal.71f83a8a571b

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.
@@ -4,6 +4,7 @@ import { vi } from 'vitest'
4
4
  import { WebSocketMinimal } from '../lib/ServerSocketAdapter'
5
5
  import { TLSocketRoom } from '../lib/TLSocketRoom'
6
6
  import { RecordOpType } from '../lib/diff'
7
+ import { getTlsyncProtocolVersion } from '../lib/protocol'
7
8
 
8
9
  function getStore() {
9
10
  const schema = createTLSchema()
@@ -266,4 +267,144 @@ describe(TLSocketRoom, () => {
266
267
  await addPage(room)
267
268
  expect(called).toEqual(2)
268
269
  })
270
+
271
+ it('sends custom messages', async () => {
272
+ const json = JSON.stringify
273
+ const store = getStore()
274
+ const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
275
+
276
+ const sessionId = 'test-session-1'
277
+ const send = vi.fn()
278
+
279
+ // Add session to the room
280
+ const mockSocket: WebSocketMinimal = { send, close: vi.fn(), readyState: WebSocket.OPEN }
281
+ room.handleSocketConnect({ sessionId, socket: mockSocket })
282
+
283
+ // Send connect message to establish the session
284
+ const connect = {
285
+ type: 'connect' as const,
286
+ connectRequestId: 'connect-1',
287
+ lastServerClock: 0,
288
+ protocolVersion: getTlsyncProtocolVersion(),
289
+ schema: store.schema.serialize(),
290
+ }
291
+ room.handleSocketMessage(sessionId, json(connect))
292
+
293
+ room.sendCustomMessage(sessionId, 'hello world')
294
+ expect(send.mock.lastCall).toEqual([json({ type: 'custom', data: 'hello world' })])
295
+ })
296
+
297
+ describe('Room state resetting behavior', () => {
298
+ it('sets documentClock to oldRoom.clock + 1 when resetting room state', () => {
299
+ const store = getStore()
300
+ store.ensureStoreIsUsable()
301
+ const room = new TLSocketRoom({
302
+ initialSnapshot: store.getStoreSnapshot(),
303
+ })
304
+
305
+ // Load a snapshot to increment the clock
306
+ const snapshot = store.getStoreSnapshot()
307
+ room.loadSnapshot(snapshot)
308
+
309
+ const oldClock = room.getCurrentSnapshot().clock
310
+ expect(oldClock).toBe(1)
311
+
312
+ // Reset with a new snapshot
313
+ const newSnapshot = store.getStoreSnapshot()
314
+ room.loadSnapshot(newSnapshot)
315
+
316
+ const newSnapshotResult = room.getCurrentSnapshot()
317
+ expect(newSnapshotResult.documentClock).toBe(oldClock + 1)
318
+ expect(newSnapshotResult.clock).toBe(oldClock + 1)
319
+ })
320
+
321
+ it('updates all documents lastChangedClock when resetting', () => {
322
+ const store = getStore()
323
+ store.ensureStoreIsUsable()
324
+ const room = new TLSocketRoom({
325
+ initialSnapshot: store.getStoreSnapshot(),
326
+ })
327
+
328
+ // Get initial clock
329
+ const initialClock = room.getCurrentSnapshot().clock
330
+
331
+ // Reset with a new snapshot
332
+ const newSnapshot = store.getStoreSnapshot()
333
+ room.loadSnapshot(newSnapshot)
334
+
335
+ const result = room.getCurrentSnapshot()
336
+ expect(result.clock).toBe(initialClock + 1)
337
+
338
+ // All documents should have updated lastChangedClock
339
+ for (const doc of result.documents) {
340
+ expect(doc.lastChangedClock).toBe(initialClock + 1)
341
+ }
342
+ })
343
+
344
+ it('preserves existing tombstones with original clock values', async () => {
345
+ // Create a room with initial state
346
+ const store = getStore()
347
+ store.ensureStoreIsUsable()
348
+ const testPageId = PageRecordType.createId('test_page')
349
+ store.put([
350
+ PageRecordType.create({ id: testPageId, name: 'Test Page', index: ZERO_INDEX_KEY }),
351
+ ])
352
+ const room = new TLSocketRoom({
353
+ initialSnapshot: store.getStoreSnapshot(),
354
+ })
355
+
356
+ await room.updateStore((store) => {
357
+ store.delete(testPageId)
358
+ })
359
+
360
+ const deletionClock = room.getCurrentDocumentClock()
361
+ expect(room.getCurrentSnapshot().tombstones).toEqual({
362
+ [testPageId]: deletionClock,
363
+ })
364
+
365
+ room.loadSnapshot(room.getCurrentSnapshot())
366
+
367
+ expect(room.getCurrentSnapshot().tombstones).toEqual({
368
+ [testPageId]: deletionClock,
369
+ })
370
+
371
+ expect(room.getCurrentSnapshot().documentClock).toBe(deletionClock + 1)
372
+ })
373
+
374
+ it('handles empty snapshot reset correctly', () => {
375
+ const store = getStore()
376
+ // Don't call ensureStoreIsUsable to get an empty snapshot
377
+ const room = new TLSocketRoom({
378
+ initialSnapshot: store.getStoreSnapshot(),
379
+ })
380
+
381
+ const oldClock = room.getCurrentSnapshot().clock
382
+
383
+ // Reset with empty snapshot
384
+ const emptySnapshot = store.getStoreSnapshot()
385
+ room.loadSnapshot(emptySnapshot)
386
+
387
+ const result = room.getCurrentSnapshot()
388
+ expect(result.documentClock).toBe(oldClock + 1)
389
+ expect(result.clock).toBe(oldClock + 1)
390
+ expect(result.documents).toHaveLength(0)
391
+ })
392
+
393
+ it('preserves schema when resetting room state', () => {
394
+ const store = getStore()
395
+ store.ensureStoreIsUsable()
396
+ const room = new TLSocketRoom({
397
+ initialSnapshot: store.getStoreSnapshot(),
398
+ })
399
+
400
+ const originalSchema = room.getCurrentSnapshot().schema
401
+
402
+ // Reset with a new snapshot
403
+ const newSnapshot = store.getStoreSnapshot()
404
+ room.loadSnapshot(newSnapshot)
405
+
406
+ const result = room.getCurrentSnapshot()
407
+ expect(result.schema).toEqual(originalSchema)
408
+ })
409
+ })
269
410
  })
@@ -44,6 +44,17 @@ const records = [
44
44
  ].sort(compareById)
45
45
 
46
46
  const makeSnapshot = (records: TLRecord[], others: Partial<RoomSnapshot> = {}) => ({
47
+ documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
48
+ clock: 0,
49
+ documentClock: 0,
50
+ ...others,
51
+ })
52
+
53
+ // Helper to create legacy snapshots without documentClock field
54
+ const makeLegacySnapshot = (
55
+ records: TLRecord[],
56
+ others: Partial<Omit<RoomSnapshot, 'documentClock'>> = {}
57
+ ) => ({
47
58
  documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
48
59
  clock: 0,
49
60
  ...others,
@@ -158,7 +169,7 @@ describe('TLSyncRoom', () => {
158
169
 
159
170
  const room = new TLSyncRoom({
160
171
  schema,
161
- snapshot: makeSnapshot([...records, oldArrow], {
172
+ snapshot: makeSnapshot([...records, oldArrow as any], {
162
173
  schema: oldSerializedSchema,
163
174
  }),
164
175
  })
@@ -720,4 +731,187 @@ describe('isReadonly', () => {
720
731
  }
721
732
  `)
722
733
  })
734
+
735
+ describe('Backward compatibility with existing snapshots', () => {
736
+ it('can load snapshot without documentClock field', () => {
737
+ const legacySnapshot = makeLegacySnapshot(records)
738
+
739
+ const room = new TLSyncRoom<TLRecord, undefined>({
740
+ schema,
741
+ snapshot: legacySnapshot,
742
+ })
743
+
744
+ // Room should load successfully without errors
745
+ expect(room.getSnapshot().documents.length).toBe(2)
746
+
747
+ // documentClock should be calculated from existing data
748
+ const snapshot = room.getSnapshot()
749
+ expect(snapshot.documentClock).toBe(0) // max lastChangedClock from documents
750
+ })
751
+
752
+ it('calculates documentClock correctly from documents with different lastChangedClock values', () => {
753
+ const legacySnapshot = makeLegacySnapshot(records, {
754
+ documents: [
755
+ { state: records[0], lastChangedClock: 5 },
756
+ { state: records[1], lastChangedClock: 10 },
757
+ ],
758
+ })
759
+
760
+ const room = new TLSyncRoom<TLRecord, undefined>({
761
+ schema,
762
+ snapshot: legacySnapshot,
763
+ })
764
+
765
+ const snapshot = room.getSnapshot()
766
+ expect(snapshot.documentClock).toBe(10) // max lastChangedClock
767
+ })
768
+
769
+ it('calculates documentClock correctly from tombstones', () => {
770
+ const legacySnapshot = makeLegacySnapshot(records, {
771
+ documents: [{ state: records[0], lastChangedClock: 3 }],
772
+ tombstones: {
773
+ 'shape:deleted1': 7,
774
+ 'shape:deleted2': 12,
775
+ },
776
+ })
777
+
778
+ const room = new TLSyncRoom<TLRecord, undefined>({
779
+ schema,
780
+ snapshot: legacySnapshot,
781
+ })
782
+
783
+ const snapshot = room.getSnapshot()
784
+ expect(snapshot.documentClock).toBe(12) // max of document (3) and tombstones (7, 12)
785
+ })
786
+
787
+ it('handles empty snapshot gracefully', () => {
788
+ const emptyLegacySnapshot = makeLegacySnapshot([], {
789
+ documents: [],
790
+ tombstones: {},
791
+ })
792
+
793
+ const room = new TLSyncRoom<TLRecord, undefined>({
794
+ schema,
795
+ snapshot: emptyLegacySnapshot,
796
+ })
797
+
798
+ const snapshot = room.getSnapshot()
799
+ expect(snapshot.documentClock).toBe(0) // no documents or tombstones
800
+ })
801
+
802
+ it('handles snapshot with only tombstones', () => {
803
+ const legacySnapshot = makeLegacySnapshot([], {
804
+ documents: [],
805
+ tombstones: {
806
+ 'shape:deleted1': 5,
807
+ 'shape:deleted2': 8,
808
+ },
809
+ })
810
+
811
+ const room = new TLSyncRoom<TLRecord, undefined>({
812
+ schema,
813
+ snapshot: legacySnapshot,
814
+ })
815
+
816
+ const snapshot = room.getSnapshot()
817
+ expect(snapshot.documentClock).toBe(8) // max tombstone clock
818
+ })
819
+
820
+ it('preserves explicit documentClock when present', () => {
821
+ const snapshotWithDocumentClock = makeSnapshot(records, {
822
+ documentClock: 15,
823
+ })
824
+
825
+ const room = new TLSyncRoom<TLRecord, undefined>({
826
+ schema,
827
+ snapshot: snapshotWithDocumentClock,
828
+ })
829
+
830
+ const snapshot = room.getSnapshot()
831
+ expect(snapshot.documentClock).toBe(15) // should preserve explicit value
832
+ })
833
+
834
+ describe('Document clock initialization logic', () => {
835
+ it('sets documentClock to room clock when migrations run (didIncrementClock = true)', () => {
836
+ // Create a schema with a migration that will update documents
837
+ const schemaWithMigration = createTLSchema({
838
+ migrations: [
839
+ {
840
+ sequenceId: 'test-migration',
841
+ retroactive: false,
842
+ sequence: [
843
+ {
844
+ id: 'test-migration/1',
845
+ scope: 'record',
846
+ filter: (record: any) => record.typeName === 'document',
847
+ up: (record: any) => {
848
+ // Modify the record to trigger clock increment
849
+ return { ...record, meta: { ...record.meta, migrated: true } }
850
+ },
851
+ },
852
+ ],
853
+ },
854
+ ],
855
+ })
856
+
857
+ const snapshotWithDocumentClock = makeSnapshot(records, {
858
+ documentClock: 5,
859
+ clock: 10,
860
+ })
861
+
862
+ const onDataChange = vi.fn()
863
+ const room = new TLSyncRoom<TLRecord, undefined>({
864
+ schema: schemaWithMigration,
865
+ snapshot: snapshotWithDocumentClock,
866
+ onDataChange,
867
+ })
868
+
869
+ // Migration should have run, incrementing the clock
870
+ expect(room.getSnapshot().clock).toBe(11)
871
+ expect(room.getSnapshot().documentClock).toBe(11)
872
+ expect(onDataChange).toHaveBeenCalled()
873
+ })
874
+
875
+ it('preserves documentClock from snapshot when no migrations run (didIncrementClock = false)', () => {
876
+ const snapshotWithDocumentClock = makeSnapshot(records, {
877
+ documentClock: 15,
878
+ clock: 20,
879
+ })
880
+
881
+ const onDataChange = vi.fn()
882
+ const room = new TLSyncRoom<TLRecord, undefined>({
883
+ schema,
884
+ snapshot: snapshotWithDocumentClock,
885
+ onDataChange,
886
+ })
887
+
888
+ // No migrations should have run
889
+ expect(room.getSnapshot().documentClock).toBe(15)
890
+ expect(room.getSnapshot().clock).toBe(20)
891
+ expect(onDataChange).not.toHaveBeenCalled()
892
+ })
893
+
894
+ it('calculates documentClock when snapshot lacks documentClock field (didIncrementClock = false)', () => {
895
+ const legacySnapshot = makeLegacySnapshot(records, {
896
+ documents: [
897
+ { state: records[0], lastChangedClock: 7 },
898
+ { state: records[1], lastChangedClock: 12 },
899
+ ],
900
+ clock: 15,
901
+ })
902
+
903
+ const onDataChange = vi.fn()
904
+ const room = new TLSyncRoom<TLRecord, undefined>({
905
+ schema,
906
+ snapshot: legacySnapshot,
907
+ onDataChange,
908
+ })
909
+
910
+ // Should calculate from existing data
911
+ expect(room.getSnapshot().documentClock).toBe(12) // max lastChangedClock
912
+ expect(room.getSnapshot().clock).toBe(15) // clock from snapshot
913
+ expect(onDataChange).not.toHaveBeenCalled()
914
+ })
915
+ })
916
+ })
723
917
  })
@@ -28,6 +28,14 @@ export class TestSocketPair<R extends UnknownRecord> {
28
28
  })
29
29
  }
30
30
 
31
+ async flushAllEvents() {
32
+ await Promise.resolve()
33
+ while (this.getNeedsFlushing()) {
34
+ this.flushClientSentEvents()
35
+ this.flushServerSentEvents()
36
+ }
37
+ }
38
+
31
39
  getNeedsFlushing() {
32
40
  return this.serverSentEventQueue.length > 0 || this.clientSentEventQueue.length > 0
33
41
  }
@@ -0,0 +1,36 @@
1
+ import { atom, createRecordType, Store, StoreSchema, UnknownRecord } from 'tldraw'
2
+ import { TLSyncClient } from '../lib/TLSyncClient'
3
+ import { TestServer } from './TestServer'
4
+ import { TestSocketPair } from './TestSocketPair'
5
+
6
+ type Presence = UnknownRecord & { typeName: 'presence' }
7
+ const presenceType = createRecordType<Presence>('presence', {
8
+ scope: 'presence',
9
+ validator: { validate: (record) => record as Presence },
10
+ })
11
+ const schema = StoreSchema.create<Presence>({ presence: presenceType })
12
+
13
+ describe('custom messages', () => {
14
+ it('sends a message to a client', async () => {
15
+ const store = new Store({ schema, props: {} })
16
+
17
+ const sessionId = 'test-session-1'
18
+ const server = new TestServer(schema)
19
+ const socketPair = new TestSocketPair(sessionId, server)
20
+ socketPair.connect()
21
+
22
+ const onCustomMessageReceived = vi.fn()
23
+ new TLSyncClient({
24
+ store,
25
+ socket: socketPair.clientSocket,
26
+ onCustomMessageReceived,
27
+ onLoad: vi.fn(),
28
+ onSyncError: vi.fn(),
29
+ presence: atom('', null),
30
+ })
31
+ await socketPair.flushAllEvents()
32
+ server.room.sendCustomMessage(sessionId, 'hello world')
33
+ await socketPair.flushAllEvents()
34
+ expect(onCustomMessageReceived.mock.lastCall).toEqual(['hello world'])
35
+ })
36
+ })