@tldraw/sync-core 4.2.0 → 4.2.2

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 +483 -58
  2. package/dist-cjs/index.js +13 -3
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js +55 -0
  5. package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js.map +7 -0
  6. package/dist-cjs/lib/InMemorySyncStorage.js +287 -0
  7. package/dist-cjs/lib/InMemorySyncStorage.js.map +7 -0
  8. package/dist-cjs/lib/MicrotaskNotifier.js +50 -0
  9. package/dist-cjs/lib/MicrotaskNotifier.js.map +7 -0
  10. package/dist-cjs/lib/NodeSqliteWrapper.js +48 -0
  11. package/dist-cjs/lib/NodeSqliteWrapper.js.map +7 -0
  12. package/dist-cjs/lib/RoomSession.js.map +1 -1
  13. package/dist-cjs/lib/SQLiteSyncStorage.js +428 -0
  14. package/dist-cjs/lib/SQLiteSyncStorage.js.map +7 -0
  15. package/dist-cjs/lib/TLSocketRoom.js +117 -69
  16. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  17. package/dist-cjs/lib/TLSyncClient.js +7 -0
  18. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  19. package/dist-cjs/lib/TLSyncRoom.js +357 -688
  20. package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
  21. package/dist-cjs/lib/TLSyncStorage.js +76 -0
  22. package/dist-cjs/lib/TLSyncStorage.js.map +7 -0
  23. package/dist-cjs/lib/chunk.js +2 -2
  24. package/dist-cjs/lib/chunk.js.map +1 -1
  25. package/dist-cjs/lib/recordDiff.js +52 -0
  26. package/dist-cjs/lib/recordDiff.js.map +7 -0
  27. package/dist-esm/index.d.mts +483 -58
  28. package/dist-esm/index.mjs +20 -5
  29. package/dist-esm/index.mjs.map +2 -2
  30. package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs +35 -0
  31. package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs.map +7 -0
  32. package/dist-esm/lib/InMemorySyncStorage.mjs +272 -0
  33. package/dist-esm/lib/InMemorySyncStorage.mjs.map +7 -0
  34. package/dist-esm/lib/MicrotaskNotifier.mjs +30 -0
  35. package/dist-esm/lib/MicrotaskNotifier.mjs.map +7 -0
  36. package/dist-esm/lib/NodeSqliteWrapper.mjs +28 -0
  37. package/dist-esm/lib/NodeSqliteWrapper.mjs.map +7 -0
  38. package/dist-esm/lib/RoomSession.mjs.map +1 -1
  39. package/dist-esm/lib/SQLiteSyncStorage.mjs +414 -0
  40. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +7 -0
  41. package/dist-esm/lib/TLSocketRoom.mjs +121 -70
  42. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  43. package/dist-esm/lib/TLSyncClient.mjs +7 -0
  44. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  45. package/dist-esm/lib/TLSyncRoom.mjs +370 -702
  46. package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
  47. package/dist-esm/lib/TLSyncStorage.mjs +56 -0
  48. package/dist-esm/lib/TLSyncStorage.mjs.map +7 -0
  49. package/dist-esm/lib/chunk.mjs +2 -2
  50. package/dist-esm/lib/chunk.mjs.map +1 -1
  51. package/dist-esm/lib/recordDiff.mjs +32 -0
  52. package/dist-esm/lib/recordDiff.mjs.map +7 -0
  53. package/package.json +12 -11
  54. package/src/index.ts +32 -3
  55. package/src/lib/ClientWebSocketAdapter.test.ts +3 -0
  56. package/src/lib/DurableObjectSqliteSyncWrapper.ts +95 -0
  57. package/src/lib/InMemorySyncStorage.ts +387 -0
  58. package/src/lib/MicrotaskNotifier.test.ts +429 -0
  59. package/src/lib/MicrotaskNotifier.ts +38 -0
  60. package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +270 -0
  61. package/src/lib/NodeSqliteSyncWrapper.test.ts +272 -0
  62. package/src/lib/NodeSqliteWrapper.ts +99 -0
  63. package/src/lib/RoomSession.test.ts +1 -0
  64. package/src/lib/RoomSession.ts +2 -0
  65. package/src/lib/SQLiteSyncStorage.ts +627 -0
  66. package/src/lib/TLSocketRoom.ts +228 -114
  67. package/src/lib/TLSyncClient.ts +12 -0
  68. package/src/lib/TLSyncRoom.ts +473 -913
  69. package/src/lib/TLSyncStorage.ts +216 -0
  70. package/src/lib/chunk.ts +2 -2
  71. package/src/lib/computeTombstonePruning.test.ts +352 -0
  72. package/src/lib/recordDiff.ts +73 -0
  73. package/src/test/FuzzEditor.ts +4 -5
  74. package/src/test/InMemorySyncStorage.test.ts +1684 -0
  75. package/src/test/SQLiteSyncStorage.test.ts +1378 -0
  76. package/src/test/TLSocketRoom.test.ts +255 -49
  77. package/src/test/TLSyncRoom.test.ts +1024 -534
  78. package/src/test/TestServer.ts +12 -1
  79. package/src/test/customMessages.test.ts +1 -1
  80. package/src/test/presenceMode.test.ts +6 -6
  81. package/src/test/syncFuzz.test.ts +2 -4
  82. package/src/test/upgradeDowngrade.test.ts +290 -8
  83. package/src/test/validation.test.ts +15 -10
  84. 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
  })
@@ -1,7 +1,5 @@
1
1
  import {
2
2
  Editor,
3
- TLArrowBinding,
4
- TLArrowShape,
5
3
  TLRecord,
6
4
  TLStore,
7
5
  computed,
@@ -111,9 +109,9 @@ let totalNumShapes = 0
111
109
  let totalNumPages = 0
112
110
 
113
111
  function arrowsAreSound(editor: Editor) {
114
- const arrows = editor.getCurrentPageShapes().filter((s): s is TLArrowShape => s.type === 'arrow')
112
+ const arrows = editor.getCurrentPageShapes().filter((s) => s.type === 'arrow')
115
113
  for (const arrow of arrows) {
116
- const bindings = editor.getBindingsFromShape<TLArrowBinding>(arrow, 'arrow')
114
+ const bindings = editor.getBindingsFromShape(arrow, 'arrow')
117
115
  const terminalsSeen = new Set()
118
116
  for (const binding of bindings) {
119
117
  if (terminalsSeen.has(binding.props.terminal)) {
@@ -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,288 @@ describe('when the client is too old', () => {
670
670
  })
671
671
  })
672
672
 
673
+ describe('migration failure during push (TLSyncError handling)', () => {
674
+ let consoleSpy: ReturnType<typeof vi.spyOn>
675
+ beforeEach(() => {
676
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
677
+ })
678
+ afterEach(() => {
679
+ consoleSpy.mockRestore()
680
+ })
681
+
682
+ // Create a schema where migrations will fail during push
683
+ const UserVersionsWithFailure = createMigrationIds('com.tldraw.user.failure', {
684
+ AddNickname: 1,
685
+ } as const)
686
+
687
+ interface UserWithNickname extends BaseRecord<'user_fail', RecordId<UserWithNickname>> {
688
+ name: string
689
+ nickname: string
690
+ }
691
+
692
+ const UserWithNicknameType = createRecordType<UserWithNickname>('user_fail', {
693
+ scope: 'document',
694
+ validator: { validate: (value) => value as UserWithNickname },
695
+ })
696
+
697
+ interface UserWithoutNickname extends BaseRecord<'user_fail', RecordId<UserWithoutNickname>> {
698
+ name: string
699
+ }
700
+
701
+ const UserWithoutNicknameType = createRecordType<UserWithoutNickname>('user_fail', {
702
+ scope: 'document',
703
+ validator: { validate: (value) => value as UserWithoutNickname },
704
+ })
705
+
706
+ // Server schema with migration that throws on specific conditions
707
+ // - up() throws when name === 'FAIL_UP_MIGRATION'
708
+ // - down() throws when nickname === 'FAIL_DOWN_MIGRATION'
709
+ const serverSchemaWithThrowingMigration = StoreSchema.create<UserWithNickname>(
710
+ { user_fail: UserWithNicknameType },
711
+ {
712
+ migrations: [
713
+ createRecordMigrationSequence({
714
+ sequenceId: 'com.tldraw.user.failure',
715
+ recordType: 'user_fail',
716
+ sequence: [
717
+ {
718
+ id: UserVersionsWithFailure.AddNickname,
719
+ up(record: any) {
720
+ // Simulate UP migration failure for specific data
721
+ if (record.name === 'FAIL_UP_MIGRATION') {
722
+ throw new Error('UP migration intentionally failed for testing')
723
+ }
724
+ return {
725
+ ...record,
726
+ nickname: record.name.toUpperCase(),
727
+ }
728
+ },
729
+ down(record: any) {
730
+ // Simulate DOWN migration failure for specific data
731
+ if (record.nickname === 'FAIL_DOWN_MIGRATION') {
732
+ throw new Error('DOWN migration intentionally failed for testing')
733
+ }
734
+ const { nickname: _, ...rest } = record
735
+ return rest
736
+ },
737
+ },
738
+ ],
739
+ }),
740
+ ],
741
+ }
742
+ )
743
+
744
+ // Client schema (old version without nickname)
745
+ const clientSchemaWithoutNickname = StoreSchema.create<UserWithoutNickname>(
746
+ { user_fail: UserWithoutNicknameType },
747
+ {
748
+ migrations: [
749
+ createMigrationSequence({
750
+ sequenceId: 'com.tldraw.user.failure',
751
+ sequence: [],
752
+ retroactive: true,
753
+ }),
754
+ ],
755
+ }
756
+ )
757
+
758
+ it('rejects session when addDocument UP migration throws during PUT', () => {
759
+ const server = new TestServer(serverSchemaWithThrowingMigration)
760
+
761
+ const sessionId = 'failing-migration-session'
762
+ const socket = mockSocket<UserWithNickname>()
763
+
764
+ server.room.handleNewSession({
765
+ sessionId,
766
+ socket: socket as any,
767
+ meta: undefined,
768
+ isReadonly: false,
769
+ })
770
+
771
+ // Connect with old schema - should succeed since migration has down function
772
+ server.room.handleMessage(sessionId, {
773
+ type: 'connect',
774
+ connectRequestId: 'test',
775
+ lastServerClock: 0,
776
+ protocolVersion: getTlsyncProtocolVersion(),
777
+ schema: clientSchemaWithoutNickname.serialize(),
778
+ })
779
+
780
+ expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
781
+ ;(socket.sendMessage as Mock).mockClear()
782
+
783
+ // Push a record that will cause the UP migration to throw
784
+ const failingRecord = UserWithoutNicknameType.create({
785
+ id: UserWithoutNicknameType.createId('fail'),
786
+ name: 'FAIL_UP_MIGRATION', // This name triggers the UP throw
787
+ })
788
+
789
+ server.room.handleMessage(sessionId, {
790
+ type: 'push',
791
+ clientClock: 1,
792
+ diff: {
793
+ [failingRecord.id]: [RecordOpType.Put, failingRecord as any],
794
+ },
795
+ })
796
+
797
+ // Session should be rejected due to migration failure (TLSyncError)
798
+ expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
799
+ })
800
+
801
+ it('successfully migrates records when migration does not throw', () => {
802
+ const server = new TestServer(serverSchemaWithThrowingMigration)
803
+
804
+ const sessionId = 'successful-migration-session'
805
+ const socket = mockSocket<UserWithNickname>()
806
+
807
+ server.room.handleNewSession({
808
+ sessionId,
809
+ socket: socket as any,
810
+ meta: undefined,
811
+ isReadonly: false,
812
+ })
813
+
814
+ // Connect with old schema
815
+ server.room.handleMessage(sessionId, {
816
+ type: 'connect',
817
+ connectRequestId: 'test',
818
+ lastServerClock: 0,
819
+ protocolVersion: getTlsyncProtocolVersion(),
820
+ schema: clientSchemaWithoutNickname.serialize(),
821
+ })
822
+
823
+ expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
824
+ ;(socket.sendMessage as Mock).mockClear()
825
+
826
+ // Push a record that will NOT cause the migration to throw
827
+ const validRecord = UserWithoutNicknameType.create({
828
+ id: UserWithoutNicknameType.createId('valid'),
829
+ name: 'ValidName', // This name does not trigger the throw
830
+ })
831
+
832
+ server.room.handleMessage(sessionId, {
833
+ type: 'push',
834
+ clientClock: 1,
835
+ diff: {
836
+ [validRecord.id]: [RecordOpType.Put, validRecord as any],
837
+ },
838
+ })
839
+
840
+ // Session should still be connected
841
+ expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
842
+
843
+ // Record should be migrated and stored
844
+ const storedRecord = server.storage.documents.get(validRecord.id)?.state as UserWithNickname
845
+ expect(storedRecord).toBeDefined()
846
+ expect(storedRecord.nickname).toBe('VALIDNAME') // Uppercase from migration
847
+ })
848
+
849
+ it('rejects session when patchDocument DOWN migration throws', () => {
850
+ // Create a server with a document that has a nickname that will fail DOWN migration
851
+ const existingUser = UserWithNicknameType.create({
852
+ id: UserWithNicknameType.createId('existing_down_fail'),
853
+ name: 'ExistingUser',
854
+ nickname: 'FAIL_DOWN_MIGRATION', // This nickname triggers the DOWN throw
855
+ })
856
+
857
+ const server = new TestServer(serverSchemaWithThrowingMigration, {
858
+ clock: 10,
859
+ documents: [{ state: existingUser, lastChangedClock: 10 }],
860
+ schema: serverSchemaWithThrowingMigration.serialize(),
861
+ tombstones: {},
862
+ })
863
+
864
+ const sessionId = 'patch-down-migration-session'
865
+ const socket = mockSocket<UserWithNickname>()
866
+
867
+ server.room.handleNewSession({
868
+ sessionId,
869
+ socket: socket as any,
870
+ meta: undefined,
871
+ isReadonly: false,
872
+ })
873
+
874
+ // Connect with old schema
875
+ server.room.handleMessage(sessionId, {
876
+ type: 'connect',
877
+ connectRequestId: 'test',
878
+ lastServerClock: 10,
879
+ protocolVersion: getTlsyncProtocolVersion(),
880
+ schema: clientSchemaWithoutNickname.serialize(),
881
+ })
882
+
883
+ expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
884
+ ;(socket.sendMessage as Mock).mockClear()
885
+
886
+ // Patch the existing record - the DOWN migration will fail because nickname is 'FAIL_DOWN_MIGRATION'
887
+ // This tests line 951 in TLSyncRoom.ts
888
+ server.room.handleMessage(sessionId, {
889
+ type: 'push',
890
+ clientClock: 1,
891
+ diff: {
892
+ [existingUser.id]: [RecordOpType.Patch, { name: [ValueOpType.Put, 'NewName'] }],
893
+ },
894
+ })
895
+
896
+ // Session should be rejected due to DOWN migration failure
897
+ expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
898
+ })
899
+
900
+ it('rejects session when patchDocument UP migration throws (after successful DOWN migration)', () => {
901
+ // Create a server with a document that will pass DOWN migration but fail UP migration
902
+ const existingUser = UserWithNicknameType.create({
903
+ id: UserWithNicknameType.createId('existing_up_fail'),
904
+ name: 'ExistingUser',
905
+ nickname: 'EXISTINGUSER', // Normal nickname, DOWN migration will succeed
906
+ })
907
+
908
+ const server = new TestServer(serverSchemaWithThrowingMigration, {
909
+ clock: 10,
910
+ documents: [{ state: existingUser, lastChangedClock: 10 }],
911
+ schema: serverSchemaWithThrowingMigration.serialize(),
912
+ tombstones: {},
913
+ })
914
+
915
+ const sessionId = 'patch-up-migration-session'
916
+ const socket = mockSocket<UserWithNickname>()
917
+
918
+ server.room.handleNewSession({
919
+ sessionId,
920
+ socket: socket as any,
921
+ meta: undefined,
922
+ isReadonly: false,
923
+ })
924
+
925
+ // Connect with old schema
926
+ server.room.handleMessage(sessionId, {
927
+ type: 'connect',
928
+ connectRequestId: 'test',
929
+ lastServerClock: 10,
930
+ protocolVersion: getTlsyncProtocolVersion(),
931
+ schema: clientSchemaWithoutNickname.serialize(),
932
+ })
933
+
934
+ expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
935
+ ;(socket.sendMessage as Mock).mockClear()
936
+
937
+ // Patch the existing record - changes the name to 'FAIL_UP_MIGRATION'
938
+ // 1. DOWN migration: {name: 'ExistingUser', nickname: 'EXISTINGUSER'} -> {name: 'ExistingUser'} (succeeds)
939
+ // 2. Patch applied: {name: 'ExistingUser'} -> {name: 'FAIL_UP_MIGRATION'}
940
+ // 3. UP migration: {name: 'FAIL_UP_MIGRATION'} -> THROWS
941
+ // This tests line 972 in TLSyncRoom.ts
942
+ server.room.handleMessage(sessionId, {
943
+ type: 'push',
944
+ clientClock: 1,
945
+ diff: {
946
+ [existingUser.id]: [RecordOpType.Patch, { name: [ValueOpType.Put, 'FAIL_UP_MIGRATION'] }],
947
+ },
948
+ })
949
+
950
+ // Session should be rejected due to UP migration failure during patch
951
+ expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
952
+ })
953
+ })
954
+
673
955
  describe('when the client is the same version', () => {
674
956
  function setup() {
675
957
  const steve = UserV2.create({
@@ -50,7 +50,12 @@ const schemaWithoutValidator = StoreSchema.create<Book | Presence>({
50
50
  })
51
51
 
52
52
  const disposables: Array<() => void> = []
53
+ let consoleSpy: ReturnType<typeof vi.spyOn>
54
+ beforeEach(() => {
55
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
56
+ })
53
57
  afterEach(() => {
58
+ consoleSpy.mockRestore()
54
59
  for (const dispose of disposables) {
55
60
  dispose()
56
61
  }
@@ -95,7 +100,7 @@ async function makeTestInstance() {
95
100
  it('rejects invalid put operations that create a new document', async () => {
96
101
  const { client, flush, onSyncError, server } = await makeTestInstance()
97
102
 
98
- const prevServerDocs = server.room.getSnapshot().documents
103
+ const prevServerDocs = server.storage.getSnapshot().documents
99
104
 
100
105
  client.store.put([
101
106
  {
@@ -109,20 +114,20 @@ it('rejects invalid put operations that create a new document', async () => {
109
114
 
110
115
  expect(onSyncError).toHaveBeenCalledTimes(1)
111
116
  expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
112
- expect(server.room.getSnapshot().documents).toStrictEqual(prevServerDocs)
117
+ expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
113
118
  })
114
119
 
115
120
  it('rejects invalid put operations that replace an existing document', async () => {
116
121
  const { client, flush, onSyncError, server } = await makeTestInstance()
117
122
 
118
- let prevServerDocs = server.room.getSnapshot().documents
123
+ let prevServerDocs = server.storage.getSnapshot().documents
119
124
  const book: Book = { typeName: 'book', id: Book.createId('1'), title: 'Annihilation' }
120
125
  client.store.put([book])
121
126
  await flush()
122
127
 
123
128
  expect(onSyncError).toHaveBeenCalledTimes(0)
124
- expect(server.room.getSnapshot().documents).not.toStrictEqual(prevServerDocs)
125
- prevServerDocs = server.room.getSnapshot().documents
129
+ expect(server.storage.getSnapshot().documents).not.toStrictEqual(prevServerDocs)
130
+ prevServerDocs = server.storage.getSnapshot().documents
126
131
 
127
132
  client.socket.sendMessage({
128
133
  type: 'push',
@@ -143,13 +148,13 @@ it('rejects invalid put operations that replace an existing document', async ()
143
148
 
144
149
  expect(onSyncError).toHaveBeenCalledTimes(1)
145
150
  expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
146
- expect(server.room.getSnapshot().documents).toStrictEqual(prevServerDocs)
151
+ expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
147
152
  })
148
153
 
149
154
  it('rejects invalid update operations', async () => {
150
155
  const { client, flush, onSyncError, server } = await makeTestInstance()
151
156
 
152
- let prevServerDocs = server.room.getSnapshot().documents
157
+ let prevServerDocs = server.storage.getSnapshot().documents
153
158
 
154
159
  // create the book
155
160
  client.store.put([
@@ -162,8 +167,8 @@ it('rejects invalid update operations', async () => {
162
167
  await flush()
163
168
 
164
169
  expect(onSyncError).toHaveBeenCalledTimes(0)
165
- expect(server.room.getSnapshot().documents).not.toStrictEqual(prevServerDocs)
166
- prevServerDocs = server.room.getSnapshot().documents
170
+ expect(server.storage.getSnapshot().documents).not.toStrictEqual(prevServerDocs)
171
+ prevServerDocs = server.storage.getSnapshot().documents
167
172
 
168
173
  // update the title to be wrong
169
174
  client.store.put([
@@ -177,5 +182,5 @@ it('rejects invalid update operations', async () => {
177
182
  await flush()
178
183
  expect(onSyncError).toHaveBeenCalledTimes(1)
179
184
  expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
180
- expect(server.room.getSnapshot().documents).toStrictEqual(prevServerDocs)
185
+ expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
181
186
  })