@tldraw/sync-core 4.3.0-canary.da35795ba8e2 → 4.3.0-canary.e1766dd4eab3

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 (53) 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/FuzzEditor.ts +4 -5
  44. package/src/test/InMemorySyncStorage.test.ts +1674 -0
  45. package/src/test/TLSocketRoom.test.ts +255 -49
  46. package/src/test/TLSyncRoom.test.ts +1022 -534
  47. package/src/test/TestServer.ts +12 -1
  48. package/src/test/customMessages.test.ts +1 -1
  49. package/src/test/presenceMode.test.ts +6 -6
  50. package/src/test/syncFuzz.test.ts +2 -4
  51. package/src/test/upgradeDowngrade.test.ts +282 -8
  52. package/src/test/validation.test.ts +10 -10
  53. package/src/test/pruneTombstones.test.ts +0 -178
@@ -1,11 +1,27 @@
1
- import { InstancePresenceRecordType, PageRecordType } from '@tldraw/tlschema'
2
- import { createTLSchema, createTLStore, ZERO_INDEX_KEY } from 'tldraw'
1
+ /* eslint-disable @typescript-eslint/no-deprecated */
2
+ import {
3
+ InstancePresenceRecordType,
4
+ PageRecordType,
5
+ TLDocument,
6
+ TLPage,
7
+ TLRecord,
8
+ } from '@tldraw/tlschema'
9
+ import {
10
+ createTLSchema,
11
+ createTLStore,
12
+ IndexKey,
13
+ promiseWithResolve,
14
+ sortById,
15
+ ZERO_INDEX_KEY,
16
+ } from 'tldraw'
3
17
  import { beforeEach, describe, expect, it, vi } from 'vitest'
4
18
  import { RecordOpType } from '../lib/diff'
19
+ import { DEFAULT_INITIAL_SNAPSHOT, InMemorySyncStorage } from '../lib/InMemorySyncStorage'
5
20
  import { getTlsyncProtocolVersion } from '../lib/protocol'
6
21
  import { WebSocketMinimal } from '../lib/ServerSocketAdapter'
7
22
  import { TLSocketRoom, TLSyncLog } from '../lib/TLSocketRoom'
8
23
  import { TLSyncErrorCloseEventReason } from '../lib/TLSyncClient'
24
+ import { RoomSnapshot } from '../lib/TLSyncRoom'
9
25
 
10
26
  function getStore() {
11
27
  const schema = createTLSchema()
@@ -41,7 +57,7 @@ describe(TLSocketRoom, () => {
41
57
  initialSnapshot: snapshot,
42
58
  })
43
59
  expect(room.getCurrentSnapshot()).not.toMatchObject({ clock: 0, documents: [] })
44
- expect(room.getCurrentSnapshot().clock).toBe(0)
60
+ expect(room.getCurrentSnapshot().documentClock).toBe(0)
45
61
  expect(room.getCurrentSnapshot().documents.sort((a, b) => a.state.id.localeCompare(b.state.id)))
46
62
  .toMatchInlineSnapshot(`
47
63
  [
@@ -75,7 +91,7 @@ describe(TLSocketRoom, () => {
75
91
  initialSnapshot: store.getStoreSnapshot(),
76
92
  })
77
93
 
78
- expect(room.getCurrentSnapshot()).toMatchObject({ clock: 0, documents: [] })
94
+ expect(room.getCurrentSnapshot()).toMatchObject({ documentClock: 0, documents: [] })
79
95
 
80
96
  // populate with an empty document (document:document and page:page records)
81
97
  store.ensureStoreIsUsable()
@@ -83,7 +99,7 @@ describe(TLSocketRoom, () => {
83
99
  const snapshot = store.getStoreSnapshot()
84
100
  room.loadSnapshot(snapshot)
85
101
 
86
- expect(room.getCurrentSnapshot().clock).toBe(1)
102
+ expect(room.getCurrentSnapshot().documentClock).toBe(1)
87
103
  expect(room.getCurrentSnapshot().documents.sort((a, b) => a.state.id.localeCompare(b.state.id)))
88
104
  .toMatchInlineSnapshot(`
89
105
  [
@@ -287,50 +303,38 @@ describe(TLSocketRoom, () => {
287
303
  })
288
304
 
289
305
  describe('Room state resetting behavior', () => {
290
- it('sets documentClock to oldRoom.clock + 1 when resetting room state', () => {
306
+ it('increments documentClock when loading snapshot with different data', () => {
291
307
  const store = getStore()
292
308
  store.ensureStoreIsUsable()
293
309
  const room = new TLSocketRoom({
294
310
  initialSnapshot: store.getStoreSnapshot(),
295
311
  })
296
312
 
297
- // Load a snapshot to increment the clock
298
- const snapshot = store.getStoreSnapshot()
299
- room.loadSnapshot(snapshot)
313
+ const oldClock = room.getCurrentDocumentClock()
314
+ expect(oldClock).toBe(0)
300
315
 
301
- const oldClock = room.getCurrentSnapshot().clock
302
- expect(oldClock).toBe(1)
303
-
304
- // Reset with a new snapshot
316
+ // Add a new page to make the snapshot different
317
+ store.put([PageRecordType.create({ name: 'New Page', index: 'a2' as IndexKey })])
305
318
  const newSnapshot = store.getStoreSnapshot()
306
319
  room.loadSnapshot(newSnapshot)
307
320
 
308
- const newSnapshotResult = room.getCurrentSnapshot()
309
- expect(newSnapshotResult.documentClock).toBe(oldClock + 1)
310
- expect(newSnapshotResult.clock).toBe(oldClock + 1)
321
+ expect(room.getCurrentDocumentClock()).toBe(oldClock + 1)
311
322
  })
312
323
 
313
- it('updates all documents lastChangedClock when resetting', () => {
324
+ it('does not increment documentClock when loading identical snapshot', () => {
314
325
  const store = getStore()
315
326
  store.ensureStoreIsUsable()
316
327
  const room = new TLSocketRoom({
317
328
  initialSnapshot: store.getStoreSnapshot(),
318
329
  })
319
330
 
320
- // Get initial clock
321
- const initialClock = room.getCurrentSnapshot().clock
322
-
323
- // Reset with a new snapshot
324
- const newSnapshot = store.getStoreSnapshot()
325
- room.loadSnapshot(newSnapshot)
331
+ const oldClock = room.getCurrentDocumentClock()
326
332
 
327
- const result = room.getCurrentSnapshot()
328
- expect(result.clock).toBe(initialClock + 1)
333
+ // Load the same snapshot again
334
+ room.loadSnapshot(store.getStoreSnapshot())
329
335
 
330
- // All documents should have updated lastChangedClock
331
- for (const doc of result.documents) {
332
- expect(doc.lastChangedClock).toBe(initialClock + 1)
333
- }
336
+ // Clock should not change since data is identical
337
+ expect(room.getCurrentDocumentClock()).toBe(oldClock)
334
338
  })
335
339
 
336
340
  it('preserves existing tombstones with original clock values', async () => {
@@ -356,30 +360,13 @@ describe(TLSocketRoom, () => {
356
360
 
357
361
  room.loadSnapshot(room.getCurrentSnapshot())
358
362
 
363
+ // Tombstones should be preserved
359
364
  expect(room.getCurrentSnapshot().tombstones).toEqual({
360
365
  [testPageId]: deletionClock,
361
366
  })
362
367
 
363
- expect(room.getCurrentSnapshot().documentClock).toBe(deletionClock + 1)
364
- })
365
-
366
- it('handles empty snapshot reset correctly', () => {
367
- const store = getStore()
368
- // Don't call ensureStoreIsUsable to get an empty snapshot
369
- const room = new TLSocketRoom({
370
- initialSnapshot: store.getStoreSnapshot(),
371
- })
372
-
373
- const oldClock = room.getCurrentSnapshot().clock
374
-
375
- // Reset with empty snapshot
376
- const emptySnapshot = store.getStoreSnapshot()
377
- room.loadSnapshot(emptySnapshot)
378
-
379
- const result = room.getCurrentSnapshot()
380
- expect(result.documentClock).toBe(oldClock + 1)
381
- expect(result.clock).toBe(oldClock + 1)
382
- expect(result.documents).toHaveLength(0)
368
+ // Clock should not change since we loaded the same snapshot
369
+ expect(room.getCurrentSnapshot().documentClock).toBe(deletionClock)
383
370
  })
384
371
 
385
372
  it('preserves schema when resetting room state', () => {
@@ -817,3 +804,222 @@ describe(TLSocketRoom, () => {
817
804
  })
818
805
  })
819
806
  })
807
+
808
+ describe('TLSocketRoom.updateStore', () => {
809
+ let storage = new InMemorySyncStorage<TLRecord>({ snapshot: DEFAULT_INITIAL_SNAPSHOT })
810
+
811
+ let room = new TLSocketRoom<TLRecord, undefined>({ storage })
812
+ function init(snapshot?: RoomSnapshot) {
813
+ storage = new InMemorySyncStorage<TLRecord>({ snapshot: snapshot ?? DEFAULT_INITIAL_SNAPSHOT })
814
+ room = new TLSocketRoom<TLRecord, undefined>({
815
+ storage,
816
+ })
817
+ }
818
+ beforeEach(() => {
819
+ init()
820
+ })
821
+
822
+ test('it allows updating records', async () => {
823
+ const clock = storage.getClock()
824
+ await room.updateStore((store) => {
825
+ const document = store.get('document:document') as TLDocument
826
+ document.name = 'My lovely document'
827
+ store.put(document)
828
+ })
829
+ expect(
830
+ (
831
+ storage.getSnapshot().documents.find((r) => r.state.id === 'document:document')
832
+ ?.state as any
833
+ ).name
834
+ ).toBe('My lovely document')
835
+ expect(clock).toBeLessThan(storage.getClock())
836
+ })
837
+
838
+ test('it does not update unless you call .set', () => {
839
+ const clock = storage.getClock()
840
+ room.updateStore((store) => {
841
+ const document = store.get('document:document') as TLDocument
842
+ document.name = 'My lovely document'
843
+ })
844
+ expect(
845
+ (
846
+ storage.getSnapshot().documents.find((r) => r.state.id === 'document:document')
847
+ ?.state as any
848
+ ).name
849
+ ).toBe('')
850
+ expect(clock).toBe(storage.getClock())
851
+ })
852
+
853
+ test('triggers onChange events on the storage if something changed', async () => {
854
+ const onChange = vi.fn()
855
+ storage.onChange(onChange)
856
+ await room.updateStore((store) => {
857
+ const document = store.get('document:document') as TLDocument
858
+ document.name = 'My lovely document'
859
+ store.put(document)
860
+ })
861
+ expect(onChange).toHaveBeenCalled()
862
+ })
863
+
864
+ test('does not trigger onChange events if the change is not committed', async () => {
865
+ const onChange = vi.fn()
866
+ storage.onChange(onChange)
867
+ room.updateStore((store) => {
868
+ const document = store.get('document:document') as TLDocument
869
+ document.name = 'My lovely document'
870
+ })
871
+ expect(onChange).not.toHaveBeenCalled()
872
+ })
873
+
874
+ test('it allows adding new records', async () => {
875
+ const id = PageRecordType.createId('page_3')
876
+ await room.updateStore((store) => {
877
+ const page = PageRecordType.create({ id, name: 'page 3', index: 'a0' as IndexKey })
878
+ store.put(page)
879
+ })
880
+
881
+ expect(storage.getSnapshot().documents.find((r) => r.state.id === id)?.state).toBeTruthy()
882
+ })
883
+
884
+ test('it allows deleting records', async () => {
885
+ await room.updateStore((store) => {
886
+ store.delete('page:page_2')
887
+ })
888
+
889
+ expect(storage.getSnapshot().documents.find((r) => r.state.id === 'page:page_2')).toBeFalsy()
890
+ })
891
+
892
+ test('it returns all records if you ask for them', async () => {
893
+ let allRecords
894
+ await room.updateStore((store) => {
895
+ allRecords = store.getAll()
896
+ })
897
+ expect(allRecords!.sort(sortById)).toEqual(
898
+ storage
899
+ .getSnapshot()
900
+ .documents.map((r) => r.state)
901
+ .sort(sortById)
902
+ )
903
+ await room.updateStore((store) => {
904
+ const page3 = PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey })
905
+ store.put(page3)
906
+ allRecords = store.getAll()
907
+ expect(allRecords.sort(sortById)).toEqual(
908
+ [...storage.getSnapshot().documents.map((r) => r.state), page3].sort(sortById)
909
+ )
910
+ store.delete(page3)
911
+ allRecords = store.getAll()
912
+ })
913
+ expect(allRecords!.sort(sortById)).toEqual(
914
+ storage
915
+ .getSnapshot()
916
+ .documents.map((r) => r.state)
917
+ .sort(sortById)
918
+ )
919
+ })
920
+
921
+ test('all operations fail after the store is closed', async () => {
922
+ let store
923
+ await room.updateStore((s) => {
924
+ store = s
925
+ })
926
+ expect(() => {
927
+ store!.put(PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey }))
928
+ }).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
929
+ expect(() => {
930
+ store!.delete('page:page_2')
931
+ }).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
932
+ expect(() => {
933
+ store!.getAll()
934
+ }).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
935
+ expect(() => {
936
+ store!.get('page:page_2')
937
+ }).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
938
+ })
939
+
940
+ test('it fails if the room is closed', async () => {
941
+ room.close()
942
+ await expect(
943
+ room.updateStore(() => {
944
+ // noop
945
+ })
946
+ ).rejects.toMatchInlineSnapshot(`[Error: Cannot update store on a closed room]`)
947
+ })
948
+
949
+ test('it fails if you try to add bad data', async () => {
950
+ await expect(
951
+ room.updateStore((store) => {
952
+ const page = store.get('page:page') as TLPage
953
+ page.index = 34 as any
954
+ store.put(page)
955
+ })
956
+ ).rejects.toMatchInlineSnapshot(
957
+ `[ValidationError: At page.index: Expected string, got a number]`
958
+ )
959
+ })
960
+
961
+ test('changes in multiple transaction are isolated from one another', async () => {
962
+ const page3 = PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey })
963
+ const didDelete = promiseWithResolve()
964
+ const didPut = promiseWithResolve()
965
+ const doneA = room.updateStore(async (store) => {
966
+ store.put(page3)
967
+ didPut.resolve(null)
968
+ await didDelete
969
+ expect(store.get(page3.id)).toBeTruthy()
970
+ })
971
+ const doneB = room.updateStore(async (store) => {
972
+ await didPut
973
+ expect(store.get(page3.id)).toBeFalsy()
974
+ store.delete(page3.id)
975
+ didDelete.resolve(null)
976
+ })
977
+ await Promise.all([doneA, doneB])
978
+ })
979
+
980
+ test('getting something that was deleted in the same transaction returns null', async () => {
981
+ await room.updateStore((store) => {
982
+ expect(store.get('page:page')).toBeTruthy()
983
+ store.delete('page:page')
984
+ expect(store.get('page:page')).toBe(null)
985
+ })
986
+ })
987
+
988
+ test('getting something that never existed in the first place returns null', async () => {
989
+ await room.updateStore((store) => {
990
+ expect(store.get('page:page_3')).toBe(null)
991
+ })
992
+ })
993
+
994
+ test('mutations to shapes gotten via .get are not committed unless you .put', async () => {
995
+ const page3 = PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey })
996
+ let page4 = PageRecordType.create({ name: 'page 4', index: 'a1' as IndexKey })
997
+ let page1
998
+ await room.updateStore((store) => {
999
+ page1 = store.get('page:page') as TLPage
1000
+ page1.name = 'my lovely page 1'
1001
+ store.put(page3)
1002
+ page3.name = 'my lovely page 3'
1003
+ store.put(page4)
1004
+ page4 = store.get(page4.id) as TLPage
1005
+ page4.name = 'my lovely page 4'
1006
+ })
1007
+
1008
+ const getPageNames = () =>
1009
+ room
1010
+ .getCurrentSnapshot()
1011
+ .documents.filter((r) => r.state.typeName === 'page')
1012
+ .map((r) => (r.state as any).name)
1013
+ .sort()
1014
+
1015
+ expect(getPageNames()).toEqual(['Page 1', 'page 3', 'page 4'])
1016
+
1017
+ await room.updateStore((store) => {
1018
+ store.put(page1!)
1019
+ store.put(page3)
1020
+ store.put(page4)
1021
+ })
1022
+
1023
+ expect(getPageNames()).toEqual(['my lovely page 1', 'my lovely page 3', 'my lovely page 4'])
1024
+ })
1025
+ })