@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
@@ -1,22 +1,11 @@
1
1
  import { StoreSchema, UnknownRecord } from '@tldraw/store'
2
- import { InMemorySyncStorage } from '../lib/InMemorySyncStorage'
3
2
  import { RoomSnapshot, TLSyncRoom } from '../lib/TLSyncRoom'
4
3
  import { TestSocketPair } from './TestSocketPair'
5
4
 
6
5
  export class TestServer<R extends UnknownRecord, P = unknown> {
7
6
  room: TLSyncRoom<R, undefined>
8
- storage: InMemorySyncStorage<R>
9
7
  constructor(schema: StoreSchema<R, P>, snapshot?: RoomSnapshot) {
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 })
8
+ this.room = new TLSyncRoom<R, undefined>({ schema, snapshot })
20
9
  }
21
10
 
22
11
  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<any, any>({ schema, props: {} })
15
+ const store = new Store({ 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.presenceStore.get(session!.presenceId!)).toMatchObject({
100
+ expect(t.server.room.documents.get(session!.presenceId!)?.state).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.presenceStore.get(session!.presenceId!)).toMatchObject({
107
+ expect(t.server.room.documents.get(session!.presenceId!)?.state).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.presenceStore.get(session!.presenceId!)).toMatchObject({
114
+ expect(t.server.room.documents.get(session!.presenceId!)?.state).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.presenceStore.get(session!.presenceId!)).toMatchObject({
131
+ expect(t.server.room.documents.get(session!.presenceId!)?.state).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.presenceStore.get(session!.presenceId!)).not.toMatchObject({
138
+ expect(t.server.room.documents.get(session!.presenceId!)?.state).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.presenceStore.get(session!.presenceId!)).not.toMatchObject({
145
+ expect(t.server.room.documents.get(session!.presenceId!)?.state).not.toMatchObject({
146
146
  name: 'bob',
147
147
  age: 12,
148
148
  })
@@ -0,0 +1,178 @@
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
+ })
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  Editor,
3
+ TLArrowBinding,
4
+ TLArrowShape,
3
5
  TLRecord,
4
6
  TLStore,
5
7
  computed,
@@ -109,9 +111,9 @@ let totalNumShapes = 0
109
111
  let totalNumPages = 0
110
112
 
111
113
  function arrowsAreSound(editor: Editor) {
112
- const arrows = editor.getCurrentPageShapes().filter((s) => s.type === 'arrow')
114
+ const arrows = editor.getCurrentPageShapes().filter((s): s is TLArrowShape => s.type === 'arrow')
113
115
  for (const arrow of arrows) {
114
- const bindings = editor.getBindingsFromShape(arrow, 'arrow')
116
+ const bindings = editor.getBindingsFromShape<TLArrowBinding>(arrow, 'arrow')
115
117
  const terminalsSeen = new Set()
116
118
  for (const binding of bindings) {
117
119
  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.storage.documents.get(user.id)?.state).toMatchObject({
231
+ expect(t.server.room.documents.get(user.id)?.state).toMatchObject({
232
232
  name: 'bob',
233
233
  birthdate: null,
234
234
  })
235
- expect(t.server.storage.documents.get(user.id)?.state).not.toMatchObject({
235
+ expect(t.server.room.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.storage.documents.get(user.id)?.state).toMatchObject({
257
+ expect(t.server.room.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.storage.documents.get(bob.id)?.state).toMatchObject({
290
+ expect(t.server.room.documents.get(bob.id)?.state).toMatchObject({
291
291
  name: 'bob',
292
292
  birthdate: null,
293
293
  })
294
- expect(t.server.storage.documents.get(joe.id)).toBeUndefined()
294
+ expect(t.server.room.documents.get(joe.id)).toBeUndefined()
295
295
 
296
296
  // there should be someone named steve
297
- const snapshot = t.server.storage.getSnapshot()
297
+ const snapshot = t.server.room.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.storage.getSnapshot()
312
+ const snapshot = t.server.room.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.documentClock ?? snapshot.clock ?? 0,
336
+ lastServerClock: snapshot.clock,
337
337
  protocolVersion: getTlsyncProtocolVersion(),
338
338
  schema: schemaV3.serialize(),
339
339
  })
@@ -670,288 +670,6 @@ 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
-
955
673
  describe('when the client is the same version', () => {
956
674
  function setup() {
957
675
  const steve = UserV2.create({