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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist-cjs/index.d.ts +239 -57
  2. package/dist-cjs/index.js +7 -3
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/InMemorySyncStorage.js +289 -0
  5. package/dist-cjs/lib/InMemorySyncStorage.js.map +7 -0
  6. package/dist-cjs/lib/RoomSession.js.map +1 -1
  7. package/dist-cjs/lib/TLSocketRoom.js +117 -69
  8. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  9. package/dist-cjs/lib/TLSyncClient.js +7 -0
  10. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncRoom.js +357 -688
  12. package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
  13. package/dist-cjs/lib/TLSyncStorage.js +76 -0
  14. package/dist-cjs/lib/TLSyncStorage.js.map +7 -0
  15. package/dist-cjs/lib/recordDiff.js +52 -0
  16. package/dist-cjs/lib/recordDiff.js.map +7 -0
  17. package/dist-esm/index.d.mts +239 -57
  18. package/dist-esm/index.mjs +12 -5
  19. package/dist-esm/index.mjs.map +2 -2
  20. package/dist-esm/lib/InMemorySyncStorage.mjs +274 -0
  21. package/dist-esm/lib/InMemorySyncStorage.mjs.map +7 -0
  22. package/dist-esm/lib/RoomSession.mjs.map +1 -1
  23. package/dist-esm/lib/TLSocketRoom.mjs +121 -70
  24. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  25. package/dist-esm/lib/TLSyncClient.mjs +7 -0
  26. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  27. package/dist-esm/lib/TLSyncRoom.mjs +370 -702
  28. package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
  29. package/dist-esm/lib/TLSyncStorage.mjs +56 -0
  30. package/dist-esm/lib/TLSyncStorage.mjs.map +7 -0
  31. package/dist-esm/lib/recordDiff.mjs +32 -0
  32. package/dist-esm/lib/recordDiff.mjs.map +7 -0
  33. package/package.json +6 -6
  34. package/src/index.ts +21 -3
  35. package/src/lib/InMemorySyncStorage.ts +357 -0
  36. package/src/lib/RoomSession.test.ts +1 -0
  37. package/src/lib/RoomSession.ts +2 -0
  38. package/src/lib/TLSocketRoom.ts +228 -114
  39. package/src/lib/TLSyncClient.ts +12 -0
  40. package/src/lib/TLSyncRoom.ts +473 -913
  41. package/src/lib/TLSyncStorage.ts +216 -0
  42. package/src/lib/recordDiff.ts +73 -0
  43. package/src/test/InMemorySyncStorage.test.ts +1674 -0
  44. package/src/test/TLSocketRoom.test.ts +255 -49
  45. package/src/test/TLSyncRoom.test.ts +1021 -533
  46. package/src/test/TestServer.ts +12 -1
  47. package/src/test/customMessages.test.ts +1 -1
  48. package/src/test/presenceMode.test.ts +6 -6
  49. package/src/test/upgradeDowngrade.test.ts +282 -8
  50. package/src/test/validation.test.ts +10 -10
  51. package/src/test/pruneTombstones.test.ts +0 -178
@@ -1,11 +1,22 @@
1
1
  import { StoreSchema, UnknownRecord } from '@tldraw/store'
2
+ import { InMemorySyncStorage } from '../lib/InMemorySyncStorage'
2
3
  import { RoomSnapshot, TLSyncRoom } from '../lib/TLSyncRoom'
3
4
  import { TestSocketPair } from './TestSocketPair'
4
5
 
5
6
  export class TestServer<R extends UnknownRecord, P = unknown> {
6
7
  room: TLSyncRoom<R, undefined>
8
+ storage: InMemorySyncStorage<R>
7
9
  constructor(schema: StoreSchema<R, P>, snapshot?: RoomSnapshot) {
8
- this.room = new TLSyncRoom<R, undefined>({ schema, snapshot })
10
+ // Use provided snapshot or create an empty one with the current schema
11
+ this.storage = new InMemorySyncStorage<R>({
12
+ snapshot: snapshot ?? {
13
+ documents: [],
14
+ clock: 0,
15
+ documentClock: 0,
16
+ schema: schema.serialize(),
17
+ },
18
+ })
19
+ this.room = new TLSyncRoom<R, undefined>({ schema, storage: this.storage })
9
20
  }
10
21
 
11
22
  connect(socketPair: TestSocketPair<R>): void {
@@ -12,7 +12,7 @@ const schema = StoreSchema.create<Presence>({ presence: presenceType })
12
12
 
13
13
  describe('custom messages', () => {
14
14
  it('sends a message to a client', async () => {
15
- const store = new Store({ schema, props: {} })
15
+ const store = new Store<any, any>({ schema, props: {} })
16
16
 
17
17
  const sessionId = 'test-session-1'
18
18
  const server = new TestServer(schema)
@@ -97,21 +97,21 @@ test('presence is pushed on change when mode is full', () => {
97
97
  const session = t.server.room.sessions.values().next().value
98
98
  expect(session).toBeDefined()
99
99
  expect(session?.presenceId).toBeDefined()
100
- expect(t.server.room.documents.get(session!.presenceId!)?.state).toMatchObject({
100
+ expect(t.server.room.presenceStore.get(session!.presenceId!)).toMatchObject({
101
101
  name: 'bob',
102
102
  age: 10,
103
103
  })
104
104
 
105
105
  presenceSignal.set(Presence.create({ name: 'bob', age: 11 }))
106
106
  t.flush()
107
- expect(t.server.room.documents.get(session!.presenceId!)?.state).toMatchObject({
107
+ expect(t.server.room.presenceStore.get(session!.presenceId!)).toMatchObject({
108
108
  name: 'bob',
109
109
  age: 11,
110
110
  })
111
111
 
112
112
  presenceSignal.set(Presence.create({ name: 'bob', age: 12 }))
113
113
  t.flush()
114
- expect(t.server.room.documents.get(session!.presenceId!)?.state).toMatchObject({
114
+ expect(t.server.room.presenceStore.get(session!.presenceId!)).toMatchObject({
115
115
  name: 'bob',
116
116
  age: 12,
117
117
  })
@@ -128,21 +128,21 @@ test('presence is only pushed once on connect when mode is solo', () => {
128
128
  const session = t.server.room.sessions.values().next().value
129
129
  expect(session).toBeDefined()
130
130
  expect(session?.presenceId).toBeDefined()
131
- expect(t.server.room.documents.get(session!.presenceId!)?.state).toMatchObject({
131
+ expect(t.server.room.presenceStore.get(session!.presenceId!)).toMatchObject({
132
132
  name: 'bob',
133
133
  age: 10,
134
134
  })
135
135
 
136
136
  presenceSignal.set(Presence.create({ name: 'bob', age: 11 }))
137
137
  t.flush()
138
- expect(t.server.room.documents.get(session!.presenceId!)?.state).not.toMatchObject({
138
+ expect(t.server.room.presenceStore.get(session!.presenceId!)).not.toMatchObject({
139
139
  name: 'bob',
140
140
  age: 11,
141
141
  })
142
142
 
143
143
  presenceSignal.set(Presence.create({ name: 'bob', age: 12 }))
144
144
  t.flush()
145
- expect(t.server.room.documents.get(session!.presenceId!)?.state).not.toMatchObject({
145
+ expect(t.server.room.presenceStore.get(session!.presenceId!)).not.toMatchObject({
146
146
  name: 'bob',
147
147
  age: 12,
148
148
  })
@@ -228,11 +228,11 @@ test('the server can handle receiving v1 stuff from the client', () => {
228
228
  t.oldClient.store.put([user])
229
229
  t.flush()
230
230
 
231
- expect(t.server.room.documents.get(user.id)?.state).toMatchObject({
231
+ expect(t.server.storage.documents.get(user.id)?.state).toMatchObject({
232
232
  name: 'bob',
233
233
  birthdate: null,
234
234
  })
235
- expect(t.server.room.documents.get(user.id)?.state).not.toMatchObject({
235
+ expect(t.server.storage.documents.get(user.id)?.state).not.toMatchObject({
236
236
  name: 'bob',
237
237
  age: 10,
238
238
  })
@@ -254,7 +254,7 @@ test('the server can send v2 stuff to the v1 client', () => {
254
254
  t.newClient.store.put([user])
255
255
  t.flush()
256
256
 
257
- expect(t.server.room.documents.get(user.id)?.state).toMatchObject({
257
+ expect(t.server.storage.documents.get(user.id)?.state).toMatchObject({
258
258
  name: 'bob',
259
259
  birthdate: '2022-01-09',
260
260
  })
@@ -287,14 +287,14 @@ test('the server will run schema migrations on a snapshot', () => {
287
287
  schemaV3
288
288
  )
289
289
 
290
- expect(t.server.room.documents.get(bob.id)?.state).toMatchObject({
290
+ expect(t.server.storage.documents.get(bob.id)?.state).toMatchObject({
291
291
  name: 'bob',
292
292
  birthdate: null,
293
293
  })
294
- expect(t.server.room.documents.get(joe.id)).toBeUndefined()
294
+ expect(t.server.storage.documents.get(joe.id)).toBeUndefined()
295
295
 
296
296
  // there should be someone named steve
297
- const snapshot = t.server.room.getSnapshot()
297
+ const snapshot = t.server.storage.getSnapshot()
298
298
  expect(snapshot.documents.find((u: any) => u.state.name === 'steve')).toBeDefined()
299
299
  })
300
300
 
@@ -309,7 +309,7 @@ test('clients will receive updates from a snapshot migration upon connection', (
309
309
  t.newClient.store.put([bob, joe])
310
310
  t.flush()
311
311
 
312
- const snapshot = t.server.room.getSnapshot()
312
+ const snapshot = t.server.storage.getSnapshot()
313
313
 
314
314
  t.oldSocketPair.disconnect()
315
315
  t.newSocketPair.disconnect()
@@ -333,7 +333,7 @@ test('clients will receive updates from a snapshot migration upon connection', (
333
333
  newServer.room.handleMessage(id, {
334
334
  type: 'connect',
335
335
  connectRequestId: 'test',
336
- lastServerClock: snapshot.clock,
336
+ lastServerClock: snapshot.documentClock ?? snapshot.clock ?? 0,
337
337
  protocolVersion: getTlsyncProtocolVersion(),
338
338
  schema: schemaV3.serialize(),
339
339
  })
@@ -670,6 +670,280 @@ describe('when the client is too old', () => {
670
670
  })
671
671
  })
672
672
 
673
+ describe('migration failure during push (TLSyncError handling)', () => {
674
+ // Create a schema where migrations will fail during push
675
+ const UserVersionsWithFailure = createMigrationIds('com.tldraw.user.failure', {
676
+ AddNickname: 1,
677
+ } as const)
678
+
679
+ interface UserWithNickname extends BaseRecord<'user_fail', RecordId<UserWithNickname>> {
680
+ name: string
681
+ nickname: string
682
+ }
683
+
684
+ const UserWithNicknameType = createRecordType<UserWithNickname>('user_fail', {
685
+ scope: 'document',
686
+ validator: { validate: (value) => value as UserWithNickname },
687
+ })
688
+
689
+ interface UserWithoutNickname extends BaseRecord<'user_fail', RecordId<UserWithoutNickname>> {
690
+ name: string
691
+ }
692
+
693
+ const UserWithoutNicknameType = createRecordType<UserWithoutNickname>('user_fail', {
694
+ scope: 'document',
695
+ validator: { validate: (value) => value as UserWithoutNickname },
696
+ })
697
+
698
+ // Server schema with migration that throws on specific conditions
699
+ // - up() throws when name === 'FAIL_UP_MIGRATION'
700
+ // - down() throws when nickname === 'FAIL_DOWN_MIGRATION'
701
+ const serverSchemaWithThrowingMigration = StoreSchema.create<UserWithNickname>(
702
+ { user_fail: UserWithNicknameType },
703
+ {
704
+ migrations: [
705
+ createRecordMigrationSequence({
706
+ sequenceId: 'com.tldraw.user.failure',
707
+ recordType: 'user_fail',
708
+ sequence: [
709
+ {
710
+ id: UserVersionsWithFailure.AddNickname,
711
+ up(record: any) {
712
+ // Simulate UP migration failure for specific data
713
+ if (record.name === 'FAIL_UP_MIGRATION') {
714
+ throw new Error('UP migration intentionally failed for testing')
715
+ }
716
+ return {
717
+ ...record,
718
+ nickname: record.name.toUpperCase(),
719
+ }
720
+ },
721
+ down(record: any) {
722
+ // Simulate DOWN migration failure for specific data
723
+ if (record.nickname === 'FAIL_DOWN_MIGRATION') {
724
+ throw new Error('DOWN migration intentionally failed for testing')
725
+ }
726
+ const { nickname: _, ...rest } = record
727
+ return rest
728
+ },
729
+ },
730
+ ],
731
+ }),
732
+ ],
733
+ }
734
+ )
735
+
736
+ // Client schema (old version without nickname)
737
+ const clientSchemaWithoutNickname = StoreSchema.create<UserWithoutNickname>(
738
+ { user_fail: UserWithoutNicknameType },
739
+ {
740
+ migrations: [
741
+ createMigrationSequence({
742
+ sequenceId: 'com.tldraw.user.failure',
743
+ sequence: [],
744
+ retroactive: true,
745
+ }),
746
+ ],
747
+ }
748
+ )
749
+
750
+ it('rejects session when addDocument UP migration throws during PUT', () => {
751
+ const server = new TestServer(serverSchemaWithThrowingMigration)
752
+
753
+ const sessionId = 'failing-migration-session'
754
+ const socket = mockSocket<UserWithNickname>()
755
+
756
+ server.room.handleNewSession({
757
+ sessionId,
758
+ socket: socket as any,
759
+ meta: undefined,
760
+ isReadonly: false,
761
+ })
762
+
763
+ // Connect with old schema - should succeed since migration has down function
764
+ server.room.handleMessage(sessionId, {
765
+ type: 'connect',
766
+ connectRequestId: 'test',
767
+ lastServerClock: 0,
768
+ protocolVersion: getTlsyncProtocolVersion(),
769
+ schema: clientSchemaWithoutNickname.serialize(),
770
+ })
771
+
772
+ expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
773
+ ;(socket.sendMessage as Mock).mockClear()
774
+
775
+ // Push a record that will cause the UP migration to throw
776
+ const failingRecord = UserWithoutNicknameType.create({
777
+ id: UserWithoutNicknameType.createId('fail'),
778
+ name: 'FAIL_UP_MIGRATION', // This name triggers the UP throw
779
+ })
780
+
781
+ server.room.handleMessage(sessionId, {
782
+ type: 'push',
783
+ clientClock: 1,
784
+ diff: {
785
+ [failingRecord.id]: [RecordOpType.Put, failingRecord as any],
786
+ },
787
+ })
788
+
789
+ // Session should be rejected due to migration failure (TLSyncError)
790
+ expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
791
+ })
792
+
793
+ it('successfully migrates records when migration does not throw', () => {
794
+ const server = new TestServer(serverSchemaWithThrowingMigration)
795
+
796
+ const sessionId = 'successful-migration-session'
797
+ const socket = mockSocket<UserWithNickname>()
798
+
799
+ server.room.handleNewSession({
800
+ sessionId,
801
+ socket: socket as any,
802
+ meta: undefined,
803
+ isReadonly: false,
804
+ })
805
+
806
+ // Connect with old schema
807
+ server.room.handleMessage(sessionId, {
808
+ type: 'connect',
809
+ connectRequestId: 'test',
810
+ lastServerClock: 0,
811
+ protocolVersion: getTlsyncProtocolVersion(),
812
+ schema: clientSchemaWithoutNickname.serialize(),
813
+ })
814
+
815
+ expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
816
+ ;(socket.sendMessage as Mock).mockClear()
817
+
818
+ // Push a record that will NOT cause the migration to throw
819
+ const validRecord = UserWithoutNicknameType.create({
820
+ id: UserWithoutNicknameType.createId('valid'),
821
+ name: 'ValidName', // This name does not trigger the throw
822
+ })
823
+
824
+ server.room.handleMessage(sessionId, {
825
+ type: 'push',
826
+ clientClock: 1,
827
+ diff: {
828
+ [validRecord.id]: [RecordOpType.Put, validRecord as any],
829
+ },
830
+ })
831
+
832
+ // Session should still be connected
833
+ expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
834
+
835
+ // Record should be migrated and stored
836
+ const storedRecord = server.storage.documents.get(validRecord.id)?.state as UserWithNickname
837
+ expect(storedRecord).toBeDefined()
838
+ expect(storedRecord.nickname).toBe('VALIDNAME') // Uppercase from migration
839
+ })
840
+
841
+ it('rejects session when patchDocument DOWN migration throws', () => {
842
+ // Create a server with a document that has a nickname that will fail DOWN migration
843
+ const existingUser = UserWithNicknameType.create({
844
+ id: UserWithNicknameType.createId('existing_down_fail'),
845
+ name: 'ExistingUser',
846
+ nickname: 'FAIL_DOWN_MIGRATION', // This nickname triggers the DOWN throw
847
+ })
848
+
849
+ const server = new TestServer(serverSchemaWithThrowingMigration, {
850
+ clock: 10,
851
+ documents: [{ state: existingUser, lastChangedClock: 10 }],
852
+ schema: serverSchemaWithThrowingMigration.serialize(),
853
+ tombstones: {},
854
+ })
855
+
856
+ const sessionId = 'patch-down-migration-session'
857
+ const socket = mockSocket<UserWithNickname>()
858
+
859
+ server.room.handleNewSession({
860
+ sessionId,
861
+ socket: socket as any,
862
+ meta: undefined,
863
+ isReadonly: false,
864
+ })
865
+
866
+ // Connect with old schema
867
+ server.room.handleMessage(sessionId, {
868
+ type: 'connect',
869
+ connectRequestId: 'test',
870
+ lastServerClock: 10,
871
+ protocolVersion: getTlsyncProtocolVersion(),
872
+ schema: clientSchemaWithoutNickname.serialize(),
873
+ })
874
+
875
+ expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
876
+ ;(socket.sendMessage as Mock).mockClear()
877
+
878
+ // Patch the existing record - the DOWN migration will fail because nickname is 'FAIL_DOWN_MIGRATION'
879
+ // This tests line 951 in TLSyncRoom.ts
880
+ server.room.handleMessage(sessionId, {
881
+ type: 'push',
882
+ clientClock: 1,
883
+ diff: {
884
+ [existingUser.id]: [RecordOpType.Patch, { name: [ValueOpType.Put, 'NewName'] }],
885
+ },
886
+ })
887
+
888
+ // Session should be rejected due to DOWN migration failure
889
+ expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
890
+ })
891
+
892
+ it('rejects session when patchDocument UP migration throws (after successful DOWN migration)', () => {
893
+ // Create a server with a document that will pass DOWN migration but fail UP migration
894
+ const existingUser = UserWithNicknameType.create({
895
+ id: UserWithNicknameType.createId('existing_up_fail'),
896
+ name: 'ExistingUser',
897
+ nickname: 'EXISTINGUSER', // Normal nickname, DOWN migration will succeed
898
+ })
899
+
900
+ const server = new TestServer(serverSchemaWithThrowingMigration, {
901
+ clock: 10,
902
+ documents: [{ state: existingUser, lastChangedClock: 10 }],
903
+ schema: serverSchemaWithThrowingMigration.serialize(),
904
+ tombstones: {},
905
+ })
906
+
907
+ const sessionId = 'patch-up-migration-session'
908
+ const socket = mockSocket<UserWithNickname>()
909
+
910
+ server.room.handleNewSession({
911
+ sessionId,
912
+ socket: socket as any,
913
+ meta: undefined,
914
+ isReadonly: false,
915
+ })
916
+
917
+ // Connect with old schema
918
+ server.room.handleMessage(sessionId, {
919
+ type: 'connect',
920
+ connectRequestId: 'test',
921
+ lastServerClock: 10,
922
+ protocolVersion: getTlsyncProtocolVersion(),
923
+ schema: clientSchemaWithoutNickname.serialize(),
924
+ })
925
+
926
+ expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
927
+ ;(socket.sendMessage as Mock).mockClear()
928
+
929
+ // Patch the existing record - changes the name to 'FAIL_UP_MIGRATION'
930
+ // 1. DOWN migration: {name: 'ExistingUser', nickname: 'EXISTINGUSER'} -> {name: 'ExistingUser'} (succeeds)
931
+ // 2. Patch applied: {name: 'ExistingUser'} -> {name: 'FAIL_UP_MIGRATION'}
932
+ // 3. UP migration: {name: 'FAIL_UP_MIGRATION'} -> THROWS
933
+ // This tests line 972 in TLSyncRoom.ts
934
+ server.room.handleMessage(sessionId, {
935
+ type: 'push',
936
+ clientClock: 1,
937
+ diff: {
938
+ [existingUser.id]: [RecordOpType.Patch, { name: [ValueOpType.Put, 'FAIL_UP_MIGRATION'] }],
939
+ },
940
+ })
941
+
942
+ // Session should be rejected due to UP migration failure during patch
943
+ expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
944
+ })
945
+ })
946
+
673
947
  describe('when the client is the same version', () => {
674
948
  function setup() {
675
949
  const steve = UserV2.create({
@@ -95,7 +95,7 @@ async function makeTestInstance() {
95
95
  it('rejects invalid put operations that create a new document', async () => {
96
96
  const { client, flush, onSyncError, server } = await makeTestInstance()
97
97
 
98
- const prevServerDocs = server.room.getSnapshot().documents
98
+ const prevServerDocs = server.storage.getSnapshot().documents
99
99
 
100
100
  client.store.put([
101
101
  {
@@ -109,20 +109,20 @@ it('rejects invalid put operations that create a new document', async () => {
109
109
 
110
110
  expect(onSyncError).toHaveBeenCalledTimes(1)
111
111
  expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
112
- expect(server.room.getSnapshot().documents).toStrictEqual(prevServerDocs)
112
+ expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
113
113
  })
114
114
 
115
115
  it('rejects invalid put operations that replace an existing document', async () => {
116
116
  const { client, flush, onSyncError, server } = await makeTestInstance()
117
117
 
118
- let prevServerDocs = server.room.getSnapshot().documents
118
+ let prevServerDocs = server.storage.getSnapshot().documents
119
119
  const book: Book = { typeName: 'book', id: Book.createId('1'), title: 'Annihilation' }
120
120
  client.store.put([book])
121
121
  await flush()
122
122
 
123
123
  expect(onSyncError).toHaveBeenCalledTimes(0)
124
- expect(server.room.getSnapshot().documents).not.toStrictEqual(prevServerDocs)
125
- prevServerDocs = server.room.getSnapshot().documents
124
+ expect(server.storage.getSnapshot().documents).not.toStrictEqual(prevServerDocs)
125
+ prevServerDocs = server.storage.getSnapshot().documents
126
126
 
127
127
  client.socket.sendMessage({
128
128
  type: 'push',
@@ -143,13 +143,13 @@ it('rejects invalid put operations that replace an existing document', async ()
143
143
 
144
144
  expect(onSyncError).toHaveBeenCalledTimes(1)
145
145
  expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
146
- expect(server.room.getSnapshot().documents).toStrictEqual(prevServerDocs)
146
+ expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
147
147
  })
148
148
 
149
149
  it('rejects invalid update operations', async () => {
150
150
  const { client, flush, onSyncError, server } = await makeTestInstance()
151
151
 
152
- let prevServerDocs = server.room.getSnapshot().documents
152
+ let prevServerDocs = server.storage.getSnapshot().documents
153
153
 
154
154
  // create the book
155
155
  client.store.put([
@@ -162,8 +162,8 @@ it('rejects invalid update operations', async () => {
162
162
  await flush()
163
163
 
164
164
  expect(onSyncError).toHaveBeenCalledTimes(0)
165
- expect(server.room.getSnapshot().documents).not.toStrictEqual(prevServerDocs)
166
- prevServerDocs = server.room.getSnapshot().documents
165
+ expect(server.storage.getSnapshot().documents).not.toStrictEqual(prevServerDocs)
166
+ prevServerDocs = server.storage.getSnapshot().documents
167
167
 
168
168
  // update the title to be wrong
169
169
  client.store.put([
@@ -177,5 +177,5 @@ it('rejects invalid update operations', async () => {
177
177
  await flush()
178
178
  expect(onSyncError).toHaveBeenCalledTimes(1)
179
179
  expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
180
- expect(server.room.getSnapshot().documents).toStrictEqual(prevServerDocs)
180
+ expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
181
181
  })
@@ -1,178 +0,0 @@
1
- import { BaseRecord, createRecordType, RecordId, StoreSchema } from '@tldraw/store'
2
- import { TLSyncRoom } from '../lib/TLSyncRoom'
3
- import { findMin } from '../lib/findMin'
4
-
5
- interface TestRecord extends BaseRecord<'test', RecordId<TestRecord>> {
6
- name: string
7
- }
8
-
9
- describe('TLSyncRoom pruneTombstones', () => {
10
- let room: TLSyncRoom<TestRecord, any>
11
- let TestRecordType: any
12
- let schema: StoreSchema<TestRecord, any>
13
-
14
- beforeEach(() => {
15
- TestRecordType = createRecordType<TestRecord>('test', {
16
- scope: 'document',
17
- })
18
-
19
- schema = StoreSchema.create({
20
- test: TestRecordType,
21
- })
22
-
23
- // Create room with empty snapshot to avoid default documents being converted to tombstones
24
- room = new TLSyncRoom({
25
- schema,
26
- snapshot: {
27
- clock: 0,
28
- documents: [],
29
- },
30
- })
31
- })
32
-
33
- it('should not prune when tombstone count is below threshold', () => {
34
- // Add some tombstones but below MAX_TOMBSTONES (3000)
35
- for (let i = 0; i < 100; i++) {
36
- room.tombstones.set(`doc${i}`, i + 1)
37
- }
38
-
39
- const initialSize = room.tombstones.size
40
- const initialHistoryClock = room.tombstoneHistoryStartsAtClock
41
-
42
- // Reset needsPrune flag and call pruneTombstones
43
- ;(room as any).needsPrune = true
44
- ;(room as any).pruneTombstones()
45
-
46
- // Should not have pruned anything
47
- expect(room.tombstones.size).toBe(initialSize)
48
- expect(room.tombstoneHistoryStartsAtClock).toBe(initialHistoryClock)
49
-
50
- expect(findMin(room.tombstones.values())).toBeGreaterThanOrEqual(
51
- room.tombstoneHistoryStartsAtClock
52
- )
53
- })
54
-
55
- it('should prune tombstones when count exceeds threshold', () => {
56
- // Add more tombstones than MAX_TOMBSTONES
57
- const totalTombstones = 3200 // Above MAX_TOMBSTONES (3000)
58
- for (let i = 0; i < totalTombstones; i++) {
59
- room.tombstones.set(`doc${i}`, i + 1)
60
- }
61
-
62
- const startCock = room.tombstoneHistoryStartsAtClock
63
-
64
- expect(room.tombstones.size).toBe(totalTombstones)
65
-
66
- // Reset needsPrune flag and call pruneTombstones
67
- ;(room as any).needsPrune = true
68
- ;(room as any).pruneTombstones()
69
-
70
- expect(room.tombstones.size).toBeLessThan(totalTombstones)
71
- expect(room.tombstoneHistoryStartsAtClock).toBeGreaterThan(startCock)
72
-
73
- expect(room.tombstones.size).toMatchInlineSnapshot(`2700`) // should be about 1500
74
- expect(room.tombstoneHistoryStartsAtClock).toMatchInlineSnapshot(`501`) // should be about 1700
75
-
76
- expect(findMin(room.tombstones.values())).toBeGreaterThanOrEqual(
77
- room.tombstoneHistoryStartsAtClock
78
- )
79
- })
80
-
81
- it('should handle tombstones with same clock value correctly', () => {
82
- // Add tombstones with some having the same clock values
83
- const totalTombstones = 3200
84
- for (let i = 0; i < totalTombstones; i++) {
85
- // Use clock values that repeat: 1, 1, 1, ..., 2, 2, 2, ..., 320, 320, 320
86
- const clock = Math.floor(i / 10) + 1
87
- room.tombstones.set(`doc${i}`, clock)
88
- }
89
-
90
- const startCock = room.tombstoneHistoryStartsAtClock
91
-
92
- // Reset needsPrune flag and call pruneTombstones
93
- ;(room as any).needsPrune = true
94
- ;(room as any).pruneTombstones()
95
-
96
- // The algorithm keeps the oldest tombstones (preserving history)
97
- // With repeating clock values, we have 10 tombstones for each clock 1-320
98
- // We keep the oldest 200 tombstones (clocks 1-20)
99
- // We delete the newest 3000 tombstones (clocks 21-320)
100
- expect(room.tombstones.size).toBeLessThan(totalTombstones)
101
- expect(room.tombstoneHistoryStartsAtClock).toBeGreaterThan(startCock)
102
-
103
- expect(room.tombstones.size).toMatchInlineSnapshot(`2700`) // should be about 1500
104
- expect(room.tombstoneHistoryStartsAtClock).toMatchInlineSnapshot(`51`) // should be about 150
105
-
106
- expect(findMin(room.tombstones.values())).toBeGreaterThanOrEqual(
107
- room.tombstoneHistoryStartsAtClock
108
- )
109
- })
110
-
111
- it('should handle edge case where all tombstones have same clock value', () => {
112
- // Add tombstones all with the same clock value
113
- const totalTombstones = 3200
114
- const sameClock = 100
115
- for (let i = 0; i < totalTombstones; i++) {
116
- room.tombstones.set(`doc${i}`, sameClock)
117
- }
118
-
119
- const startClock = room.tombstoneHistoryStartsAtClock
120
-
121
- // Reset needsPrune flag and call pruneTombstones
122
- ;(room as any).needsPrune = true
123
- ;(room as any).pruneTombstones()
124
-
125
- // When all tombstones have the same clock value, the algorithm deletes all of them
126
- // because the while loop advances to the end and all tombstones are marked for deletion
127
- expect(room.tombstones.size).toBeLessThan(totalTombstones)
128
- expect(room.tombstoneHistoryStartsAtClock).toBeGreaterThan(startClock)
129
-
130
- expect(room.tombstones.size).toMatchInlineSnapshot(`0`) // all deleted
131
- expect(room.tombstoneHistoryStartsAtClock).toMatchInlineSnapshot(`101`) // next clock after deletion
132
-
133
- expect(findMin(room.tombstones.values())).toBe(null) // findMin returns null for empty collections
134
- })
135
-
136
- it('should handle exact threshold case', () => {
137
- // Add exactly MAX_TOMBSTONES tombstones
138
- for (let i = 0; i < 3000; i++) {
139
- room.tombstones.set(`doc${i}`, i + 1)
140
- }
141
-
142
- const initialSize = room.tombstones.size
143
- expect(initialSize).toBe(3000)
144
-
145
- // Reset needsPrune flag and call pruneTombstones
146
- ;(room as any).needsPrune = true
147
- ;(room as any).pruneTombstones()
148
-
149
- // Should not prune anything since we're exactly at the threshold
150
- expect(room.tombstones.size).toBe(initialSize)
151
- })
152
-
153
- it('should handle very large tombstone counts', () => {
154
- // Test with a much larger number of tombstones
155
- const totalTombstones = 10000
156
- for (let i = 0; i < totalTombstones; i++) {
157
- room.tombstones.set(`doc${i}`, i + 1)
158
- }
159
-
160
- const startClock = room.tombstoneHistoryStartsAtClock
161
-
162
- // Reset needsPrune flag and call pruneTombstones
163
- ;(room as any).needsPrune = true
164
- ;(room as any).pruneTombstones()
165
-
166
- // The algorithm keeps the oldest tombstones (preserving history)
167
- // With 10000 tombstones, we keep about 7000 and delete about 3000
168
- expect(room.tombstones.size).toBeLessThan(totalTombstones)
169
- expect(room.tombstoneHistoryStartsAtClock).toBeGreaterThan(startClock)
170
-
171
- expect(room.tombstones.size).toMatchInlineSnapshot(`2700`) // should be about 1500
172
- expect(room.tombstoneHistoryStartsAtClock).toMatchInlineSnapshot(`7301`) // should be about 1500
173
-
174
- expect(findMin(room.tombstones.values())).toBeGreaterThanOrEqual(
175
- room.tombstoneHistoryStartsAtClock
176
- )
177
- })
178
- })