@tldraw/sync-core 4.1.0-next.b6dfe9bccde9 → 4.1.0-next.b9999db71010

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 (67) hide show
  1. package/dist-cjs/index.d.ts +605 -75
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/ClientWebSocketAdapter.js +144 -0
  4. package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
  5. package/dist-cjs/lib/RoomSession.js +3 -0
  6. package/dist-cjs/lib/RoomSession.js.map +2 -2
  7. package/dist-cjs/lib/ServerSocketAdapter.js +23 -0
  8. package/dist-cjs/lib/ServerSocketAdapter.js.map +2 -2
  9. package/dist-cjs/lib/TLRemoteSyncError.js +8 -0
  10. package/dist-cjs/lib/TLRemoteSyncError.js.map +2 -2
  11. package/dist-cjs/lib/TLSocketRoom.js +280 -56
  12. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  13. package/dist-cjs/lib/TLSyncClient.js +45 -2
  14. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  15. package/dist-cjs/lib/TLSyncRoom.js +161 -16
  16. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  17. package/dist-cjs/lib/chunk.js +30 -0
  18. package/dist-cjs/lib/chunk.js.map +2 -2
  19. package/dist-cjs/lib/diff.js.map +2 -2
  20. package/dist-cjs/lib/findMin.js.map +2 -2
  21. package/dist-cjs/lib/interval.js.map +2 -2
  22. package/dist-cjs/lib/protocol.js.map +2 -2
  23. package/dist-cjs/lib/server-types.js.map +1 -1
  24. package/dist-esm/index.d.mts +605 -75
  25. package/dist-esm/index.mjs +1 -1
  26. package/dist-esm/lib/ClientWebSocketAdapter.mjs +144 -0
  27. package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
  28. package/dist-esm/lib/RoomSession.mjs +3 -0
  29. package/dist-esm/lib/RoomSession.mjs.map +2 -2
  30. package/dist-esm/lib/ServerSocketAdapter.mjs +23 -0
  31. package/dist-esm/lib/ServerSocketAdapter.mjs.map +2 -2
  32. package/dist-esm/lib/TLRemoteSyncError.mjs +8 -0
  33. package/dist-esm/lib/TLRemoteSyncError.mjs.map +2 -2
  34. package/dist-esm/lib/TLSocketRoom.mjs +280 -56
  35. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  36. package/dist-esm/lib/TLSyncClient.mjs +45 -2
  37. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  38. package/dist-esm/lib/TLSyncRoom.mjs +161 -16
  39. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  40. package/dist-esm/lib/chunk.mjs +30 -0
  41. package/dist-esm/lib/chunk.mjs.map +2 -2
  42. package/dist-esm/lib/diff.mjs.map +2 -2
  43. package/dist-esm/lib/findMin.mjs.map +2 -2
  44. package/dist-esm/lib/interval.mjs.map +2 -2
  45. package/dist-esm/lib/protocol.mjs.map +2 -2
  46. package/package.json +6 -6
  47. package/src/lib/ClientWebSocketAdapter.test.ts +712 -129
  48. package/src/lib/ClientWebSocketAdapter.ts +240 -9
  49. package/src/lib/RoomSession.test.ts +97 -0
  50. package/src/lib/RoomSession.ts +105 -3
  51. package/src/lib/ServerSocketAdapter.test.ts +228 -0
  52. package/src/lib/ServerSocketAdapter.ts +124 -5
  53. package/src/lib/TLRemoteSyncError.ts +50 -1
  54. package/src/lib/TLSocketRoom.ts +377 -60
  55. package/src/lib/TLSyncClient.test.ts +828 -0
  56. package/src/lib/TLSyncClient.ts +251 -26
  57. package/src/lib/TLSyncRoom.ts +284 -24
  58. package/src/lib/chunk.ts +72 -1
  59. package/src/lib/diff.ts +128 -14
  60. package/src/lib/findMin.ts +6 -0
  61. package/src/lib/interval.ts +40 -0
  62. package/src/lib/protocol.ts +185 -7
  63. package/src/lib/server-types.test.ts +44 -0
  64. package/src/lib/server-types.ts +45 -1
  65. package/src/test/TLSocketRoom.test.ts +438 -29
  66. package/src/test/chunk.test.ts +200 -3
  67. package/src/test/diff.test.ts +396 -1
@@ -0,0 +1,828 @@
1
+ import { Atom, atom } from '@tldraw/state'
2
+ import { Store } from '@tldraw/store'
3
+ import {
4
+ DocumentRecordType,
5
+ PageRecordType,
6
+ TLDOCUMENT_ID,
7
+ TLRecord,
8
+ createTLSchema,
9
+ } from '@tldraw/tlschema'
10
+ /// <reference types="vitest" />
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
12
+ import { RecordOpType } from './diff'
13
+ import {
14
+ TLPushRequest,
15
+ TLSocketClientSentEvent,
16
+ TLSocketServerSentDataEvent,
17
+ TLSocketServerSentEvent,
18
+ getTlsyncProtocolVersion,
19
+ } from './protocol'
20
+ import {
21
+ TLPersistentClientSocket,
22
+ TLPresenceMode,
23
+ TLSyncClient,
24
+ TlSocketStatusChangeEvent,
25
+ } from './TLSyncClient'
26
+
27
+ // Mock store and schema setup
28
+ const schema = createTLSchema()
29
+ const protocolVersion = getTlsyncProtocolVersion()
30
+ type TestRecord = TLRecord
31
+
32
+ // Mock socket implementation for testing
33
+ class MockSocket implements TLPersistentClientSocket<TestRecord> {
34
+ connectionStatus: 'online' | 'offline' | 'error' = 'offline'
35
+ private messageListeners: Array<(msg: TLSocketServerSentEvent<TestRecord>) => void> = []
36
+ private statusListeners: Array<(event: TlSocketStatusChangeEvent) => void> = []
37
+ private sentMessages: TLSocketClientSentEvent<TestRecord>[] = []
38
+
39
+ sendMessage(msg: TLSocketClientSentEvent<TestRecord>): void {
40
+ if (this.connectionStatus !== 'online') {
41
+ throw new Error('Cannot send message when not online')
42
+ }
43
+ this.sentMessages.push(msg)
44
+ }
45
+
46
+ onReceiveMessage(callback: (val: TLSocketServerSentEvent<TestRecord>) => void) {
47
+ this.messageListeners.push(callback)
48
+ return () => {
49
+ const index = this.messageListeners.indexOf(callback)
50
+ if (index >= 0) this.messageListeners.splice(index, 1)
51
+ }
52
+ }
53
+
54
+ onStatusChange(callback: (params: TlSocketStatusChangeEvent) => void) {
55
+ this.statusListeners.push(callback)
56
+ return () => {
57
+ const index = this.statusListeners.indexOf(callback)
58
+ if (index >= 0) this.statusListeners.splice(index, 1)
59
+ }
60
+ }
61
+
62
+ restart(): void {
63
+ this.connectionStatus = 'offline'
64
+ this._notifyStatus({ status: 'offline' })
65
+ // Simulate reconnection
66
+ setTimeout(() => {
67
+ this.connectionStatus = 'online'
68
+ this._notifyStatus({ status: 'online' })
69
+ }, 0)
70
+ }
71
+
72
+ // Test helpers
73
+ mockServerMessage(message: TLSocketServerSentEvent<TestRecord>) {
74
+ this.messageListeners.forEach((listener) => listener(message))
75
+ }
76
+
77
+ mockConnectionStatus(status: 'online' | 'offline' | 'error', reason?: string) {
78
+ this.connectionStatus = status
79
+ if (status === 'error') {
80
+ this._notifyStatus({ status: 'error', reason: reason || 'Unknown error' })
81
+ } else {
82
+ this._notifyStatus({ status: status as 'online' | 'offline' })
83
+ }
84
+ }
85
+
86
+ getSentMessages() {
87
+ return [...this.sentMessages]
88
+ }
89
+
90
+ getLastSentMessage() {
91
+ return this.sentMessages[this.sentMessages.length - 1]
92
+ }
93
+
94
+ clearSentMessages() {
95
+ this.sentMessages = []
96
+ }
97
+
98
+ private _notifyStatus(event: TlSocketStatusChangeEvent) {
99
+ this.statusListeners.forEach((listener) => listener(event))
100
+ }
101
+ }
102
+
103
+ describe('TLSyncClient', () => {
104
+ let store: Store<TestRecord, any>
105
+ let socket: MockSocket
106
+ let presence: Atom<TestRecord | null>
107
+ let presenceMode: Atom<TLPresenceMode>
108
+ let onLoad: (client: TLSyncClient<TestRecord, Store<TestRecord, any>>) => void
109
+ let onSyncError: (reason: string) => void
110
+ let onCustomMessageReceived: (data: any) => void
111
+ let onAfterConnect: (
112
+ client: TLSyncClient<TestRecord, Store<TestRecord, any>>,
113
+ details: { isReadonly: boolean }
114
+ ) => void
115
+ let client: TLSyncClient<TestRecord, Store<TestRecord, any>>
116
+
117
+ beforeEach(() => {
118
+ vi.useFakeTimers()
119
+
120
+ // Create fresh store for each test
121
+ store = new Store<TestRecord, any>({
122
+ schema,
123
+ props: {
124
+ defaultName: 'test',
125
+ assets: {
126
+ upload: async () => ({ src: 'mock://test' }),
127
+ resolve: (asset: any) => asset.src || 'mock://resolved',
128
+ remove: async () => {},
129
+ },
130
+ onMount: () => {},
131
+ },
132
+ })
133
+
134
+ // Add basic document record
135
+ store.put([
136
+ DocumentRecordType.create({
137
+ id: TLDOCUMENT_ID,
138
+ gridSize: 10,
139
+ }),
140
+ ])
141
+
142
+ socket = new MockSocket() as MockSocket & TLPersistentClientSocket<TestRecord>
143
+ presence = atom<TestRecord | null>('presence', null)
144
+ presenceMode = atom<TLPresenceMode>('presenceMode', 'full')
145
+
146
+ onLoad = vi.fn()
147
+ onSyncError = vi.fn()
148
+ onCustomMessageReceived = vi.fn()
149
+ onAfterConnect = vi.fn()
150
+
151
+ // Start socket as online by default
152
+ socket.connectionStatus = 'online'
153
+ })
154
+
155
+ afterEach(() => {
156
+ client?.close()
157
+ vi.useRealTimers()
158
+ vi.clearAllMocks()
159
+ })
160
+
161
+ function createConnectMessage(
162
+ overrides: Partial<Extract<TLSocketServerSentEvent<TestRecord>, { type: 'connect' }>> = {}
163
+ ): Extract<TLSocketServerSentEvent<TestRecord>, { type: 'connect' }> {
164
+ return {
165
+ type: 'connect',
166
+ connectRequestId: client.latestConnectRequestId!,
167
+ hydrationType: 'wipe_all',
168
+ protocolVersion,
169
+ schema: schema.serialize(),
170
+ isReadonly: false,
171
+ serverClock: 1,
172
+ diff: {},
173
+ ...overrides,
174
+ }
175
+ }
176
+
177
+ function createClient(
178
+ overrides: Partial<{
179
+ store: Store<TestRecord, any>
180
+ socket: TLPersistentClientSocket<TestRecord>
181
+ presence: Atom<TestRecord | null>
182
+ presenceMode?: Atom<TLPresenceMode>
183
+ onLoad(self: TLSyncClient<TestRecord, Store<TestRecord, any>>): void
184
+ onSyncError(reason: string): void
185
+ onCustomMessageReceived?(data: any): void
186
+ onAfterConnect?(
187
+ self: TLSyncClient<TestRecord, Store<TestRecord, any>>,
188
+ details: { isReadonly: boolean }
189
+ ): void
190
+ didCancel?(): boolean
191
+ }> = {}
192
+ ): TLSyncClient<TestRecord, Store<TestRecord, any>> {
193
+ return new TLSyncClient<TestRecord, Store<TestRecord, any>>({
194
+ store,
195
+ socket,
196
+ presence,
197
+ presenceMode,
198
+ onLoad,
199
+ onSyncError,
200
+ onCustomMessageReceived,
201
+ onAfterConnect,
202
+ ...overrides,
203
+ })
204
+ }
205
+
206
+ describe('Construction and Initialization', () => {
207
+ it('creates a client with required configuration', () => {
208
+ client = createClient()
209
+ expect(client).toBeInstanceOf(TLSyncClient)
210
+ expect(client.store).toBe(store)
211
+ expect(client.socket).toBe(socket)
212
+ expect(client.presenceState).toBe(presence)
213
+ expect(client.presenceMode).toBe(presenceMode)
214
+ })
215
+
216
+ it('initializes with correct default state', () => {
217
+ client = createClient()
218
+ expect(client.isConnectedToRoom).toBe(false)
219
+ })
220
+
221
+ it('sends connect message when socket is already online', () => {
222
+ client = createClient()
223
+ expect(socket.getSentMessages()).toHaveLength(1)
224
+ expect(socket.getLastSentMessage().type).toBe('connect')
225
+ })
226
+
227
+ it('waits for socket to come online before sending connect message', () => {
228
+ socket.connectionStatus = 'offline'
229
+ client = createClient()
230
+ expect(socket.getSentMessages()).toHaveLength(0)
231
+
232
+ socket.mockConnectionStatus('online')
233
+ expect(socket.getSentMessages()).toHaveLength(1)
234
+ expect(socket.getLastSentMessage().type).toBe('connect')
235
+ })
236
+
237
+ it('sets up window.tlsync for debugging', () => {
238
+ client = createClient()
239
+ expect((globalThis as any).tlsync).toBe(client)
240
+ })
241
+
242
+ it('handles optional callbacks', () => {
243
+ client = createClient({
244
+ onCustomMessageReceived: undefined,
245
+ onAfterConnect: undefined,
246
+ })
247
+ expect(client).toBeInstanceOf(TLSyncClient)
248
+ })
249
+
250
+ it('handles optional presence mode', () => {
251
+ client = createClient({
252
+ presenceMode: undefined,
253
+ })
254
+ expect(client.presenceMode).toBeUndefined()
255
+ })
256
+ })
257
+
258
+ describe('Connection Lifecycle', () => {
259
+ beforeEach(() => {
260
+ client = createClient()
261
+ socket.clearSentMessages()
262
+ })
263
+
264
+ it('handles successful connection', () => {
265
+ const connectMessage = createConnectMessage()
266
+
267
+ socket.mockServerMessage(connectMessage)
268
+
269
+ expect(client.isConnectedToRoom).toBe(true)
270
+ expect(onLoad).toHaveBeenCalledWith(client)
271
+ expect(onAfterConnect).toHaveBeenCalledWith(client, { isReadonly: false })
272
+ })
273
+
274
+ it('handles connection with readonly mode', () => {
275
+ const connectMessage = createConnectMessage({ isReadonly: true })
276
+
277
+ socket.mockServerMessage(connectMessage)
278
+
279
+ expect(onAfterConnect).toHaveBeenCalledWith(client, { isReadonly: true })
280
+ })
281
+
282
+ it('handles socket going offline', () => {
283
+ // First connect
284
+ socket.mockServerMessage(createConnectMessage())
285
+ expect(client.isConnectedToRoom).toBe(true)
286
+
287
+ // Then go offline
288
+ socket.mockConnectionStatus('offline')
289
+ expect(client.isConnectedToRoom).toBe(false)
290
+ })
291
+
292
+ it('handles socket errors', () => {
293
+ socket.mockConnectionStatus('error', 'Connection failed')
294
+ expect(onSyncError).toHaveBeenCalledWith('Connection failed')
295
+ })
296
+
297
+ it('sends ping messages periodically', () => {
298
+ // Connect first
299
+ socket.mockServerMessage(createConnectMessage())
300
+
301
+ socket.clearSentMessages()
302
+
303
+ // Advance time to trigger ping
304
+ vi.advanceTimersByTime(5000)
305
+ expect(socket.getSentMessages()).toHaveLength(1)
306
+ expect(socket.getLastSentMessage().type).toBe('ping')
307
+ })
308
+
309
+ it('resets connection if no server interaction for too long', () => {
310
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
311
+
312
+ // Connect first
313
+ socket.mockServerMessage(createConnectMessage())
314
+
315
+ // Advance time beyond health check threshold
316
+ vi.advanceTimersByTime(15000) // Greater than MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION
317
+
318
+ expect(consoleSpy).toHaveBeenCalledWith(
319
+ expect.stringContaining("Haven't heard from the server in a while")
320
+ )
321
+ expect(client.isConnectedToRoom).toBe(false)
322
+
323
+ consoleSpy.mockRestore()
324
+ })
325
+ })
326
+
327
+ describe('Message Handling', () => {
328
+ beforeEach(() => {
329
+ client = createClient()
330
+ // Connect first
331
+ socket.mockServerMessage(createConnectMessage())
332
+ socket.clearSentMessages()
333
+ })
334
+
335
+ it('handles pong messages', () => {
336
+ const pongMessage: Extract<TLSocketServerSentEvent<TestRecord>, { type: 'pong' }> = {
337
+ type: 'pong',
338
+ }
339
+
340
+ socket.mockServerMessage(pongMessage)
341
+ // Pong messages are just used to update lastServerInteractionTimestamp
342
+ // No specific assertion needed beyond not throwing
343
+ })
344
+
345
+ it('handles custom messages', () => {
346
+ const customData = { type: 'chat', message: 'Hello world' }
347
+ const customMessage: Extract<TLSocketServerSentEvent<TestRecord>, { type: 'custom' }> = {
348
+ type: 'custom',
349
+ data: customData,
350
+ }
351
+
352
+ socket.mockServerMessage(customMessage)
353
+
354
+ expect(onCustomMessageReceived).toHaveBeenCalledWith(customData)
355
+ })
356
+
357
+ it('handles data messages and triggers rebase', () => {
358
+ const dataMessage: Extract<TLSocketServerSentEvent<TestRecord>, { type: 'data' }> = {
359
+ type: 'data',
360
+ data: [
361
+ {
362
+ type: 'patch',
363
+ serverClock: 2,
364
+ diff: {},
365
+ },
366
+ ],
367
+ }
368
+
369
+ socket.mockServerMessage(dataMessage)
370
+ // Rebase is throttled, so advance timers
371
+ vi.advanceTimersByTime(100)
372
+ })
373
+
374
+ it('handles legacy patch messages', () => {
375
+ const patchMessage: Extract<TLSocketServerSentEvent<TestRecord>, { type: 'patch' }> = {
376
+ type: 'patch',
377
+ serverClock: 2,
378
+ diff: {},
379
+ }
380
+
381
+ socket.mockServerMessage(patchMessage)
382
+ vi.advanceTimersByTime(100)
383
+ })
384
+
385
+ it('ignores messages when not connected to room', () => {
386
+ // Reset connection
387
+ client.isConnectedToRoom = false
388
+
389
+ const dataMessage: Extract<TLSocketServerSentEvent<TestRecord>, { type: 'data' }> = {
390
+ type: 'data',
391
+ data: [],
392
+ }
393
+
394
+ socket.mockServerMessage(dataMessage)
395
+ // Should not process the message or throw
396
+ })
397
+
398
+ it('handles incompatibility_error messages', () => {
399
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
400
+
401
+ const errorMessage: Extract<
402
+ TLSocketServerSentEvent<TestRecord>,
403
+ { type: 'incompatibility_error' }
404
+ > = {
405
+ type: 'incompatibility_error',
406
+ reason: 'clientTooOld',
407
+ }
408
+
409
+ socket.mockServerMessage(errorMessage)
410
+
411
+ expect(consoleSpy).toHaveBeenCalledWith(
412
+ expect.stringContaining('incompatibility error is legacy')
413
+ )
414
+
415
+ consoleSpy.mockRestore()
416
+ })
417
+ })
418
+
419
+ describe('Store Synchronization', () => {
420
+ beforeEach(() => {
421
+ client = createClient()
422
+ // Connect first
423
+ socket.mockServerMessage(createConnectMessage())
424
+ socket.clearSentMessages()
425
+ })
426
+
427
+ it('sends push requests for local changes', () => {
428
+ const pageId = PageRecordType.createId()
429
+ store.put([
430
+ PageRecordType.create({
431
+ id: pageId,
432
+ name: 'Test Page',
433
+ index: 'a1' as any,
434
+ }),
435
+ ])
436
+
437
+ vi.advanceTimersByTime(100)
438
+
439
+ expect(socket.getSentMessages()).toHaveLength(1)
440
+ const message = socket.getLastSentMessage() as TLPushRequest<TestRecord>
441
+ expect(message.type).toBe('push')
442
+ expect(message.diff).toBeDefined()
443
+ })
444
+
445
+ it('does not send push requests when offline', () => {
446
+ socket.mockConnectionStatus('offline')
447
+
448
+ const pageId = PageRecordType.createId()
449
+ store.put([
450
+ PageRecordType.create({
451
+ id: pageId,
452
+ name: 'Test Page',
453
+ index: 'a1' as any,
454
+ }),
455
+ ])
456
+
457
+ vi.advanceTimersByTime(100)
458
+
459
+ expect(socket.getSentMessages()).toHaveLength(0)
460
+ })
461
+
462
+ it('throttles push request sending', () => {
463
+ // Make multiple rapid changes without waiting for timers
464
+ for (let i = 0; i < 5; i++) {
465
+ const pageId = PageRecordType.createId()
466
+ store.put([
467
+ PageRecordType.create({
468
+ id: pageId,
469
+ name: `Test Page ${i}`,
470
+ index: `a${i}` as any,
471
+ }),
472
+ ])
473
+ }
474
+
475
+ // After throttle resolves
476
+ vi.advanceTimersByTime(100)
477
+ // Should have sent at least one message but possibly consolidated multiple changes
478
+ const messages = socket.getSentMessages()
479
+ expect(messages.length).toBeGreaterThan(0)
480
+ expect(messages.length).toBeLessThanOrEqual(5)
481
+ })
482
+ })
483
+
484
+ describe('Presence Management', () => {
485
+ let presenceRecord: TestRecord
486
+
487
+ beforeEach(() => {
488
+ // Mock a presence record type
489
+ presenceRecord = {
490
+ id: 'presence:user1' as any,
491
+ typeName: 'instance_presence',
492
+ cursor: { x: 100, y: 200 },
493
+ userName: 'Test User',
494
+ } as any
495
+
496
+ client = createClient()
497
+
498
+ // Connect first
499
+ socket.mockServerMessage(createConnectMessage())
500
+ socket.clearSentMessages()
501
+ })
502
+
503
+ it('sends presence updates when presence changes', () => {
504
+ presence.set(presenceRecord)
505
+ vi.advanceTimersByTime(100)
506
+
507
+ const messages = socket.getSentMessages()
508
+ const pushMessages = messages.filter(
509
+ (msg) => msg.type === 'push'
510
+ ) as TLPushRequest<TestRecord>[]
511
+ expect(pushMessages.length).toBeGreaterThan(0)
512
+
513
+ const messageWithPresence = pushMessages.find((msg) => msg.presence)
514
+ expect(messageWithPresence).toBeDefined()
515
+ expect(messageWithPresence!.presence).toBeDefined()
516
+ })
517
+
518
+ it('does not send presence when mode is solo', () => {
519
+ presenceMode.set('solo')
520
+ presence.set(presenceRecord)
521
+ vi.advanceTimersByTime(100)
522
+
523
+ expect(socket.getSentMessages()).toHaveLength(0)
524
+ })
525
+
526
+ it('sends full presence on first update', () => {
527
+ presence.set(presenceRecord)
528
+ vi.advanceTimersByTime(100)
529
+
530
+ const messages = socket.getSentMessages()
531
+ const pushMessages = messages.filter(
532
+ (msg) => msg.type === 'push'
533
+ ) as TLPushRequest<TestRecord>[]
534
+ const messageWithPresence = pushMessages.find((msg) => msg.presence)
535
+
536
+ expect(messageWithPresence).toBeDefined()
537
+ expect(messageWithPresence!.presence![0]).toBe(RecordOpType.Put)
538
+ expect(messageWithPresence!.presence![1]).toBe(presenceRecord)
539
+ })
540
+
541
+ it('sends presence diffs for subsequent updates', () => {
542
+ // Set initial presence
543
+ presence.set(presenceRecord)
544
+ vi.advanceTimersByTime(100)
545
+ socket.clearSentMessages()
546
+
547
+ // Update presence
548
+ const updatedPresence = { ...presenceRecord, cursor: { x: 150, y: 250 } }
549
+ presence.set(updatedPresence as TestRecord)
550
+ vi.advanceTimersByTime(100)
551
+
552
+ const messages = socket.getSentMessages()
553
+ const pushMessages = messages.filter(
554
+ (msg) => msg.type === 'push'
555
+ ) as TLPushRequest<TestRecord>[]
556
+ const messageWithPresence = pushMessages.find((msg) => msg.presence)
557
+
558
+ expect(messageWithPresence).toBeDefined()
559
+ expect(messageWithPresence!.presence![0]).toBe(RecordOpType.Patch)
560
+ })
561
+
562
+ it('does not send presence when offline', () => {
563
+ socket.mockConnectionStatus('offline')
564
+
565
+ presence.set(presenceRecord)
566
+ vi.advanceTimersByTime(100)
567
+
568
+ expect(socket.getSentMessages()).toHaveLength(0)
569
+ })
570
+ })
571
+
572
+ describe('Error Handling', () => {
573
+ beforeEach(() => {
574
+ client = createClient()
575
+ })
576
+
577
+ it('handles rebase errors gracefully', () => {
578
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
579
+
580
+ // Connect first
581
+ socket.mockServerMessage(createConnectMessage())
582
+
583
+ // Simulate a corrupted message that causes rebase to fail
584
+ const malformedMessage: TLSocketServerSentDataEvent<TestRecord> = {
585
+ type: 'push_result',
586
+ action: 'commit',
587
+ serverClock: 2,
588
+ clientClock: 999999, // Non-existent client clock
589
+ }
590
+
591
+ socket.mockServerMessage({
592
+ type: 'data',
593
+ data: [malformedMessage],
594
+ })
595
+
596
+ vi.advanceTimersByTime(100)
597
+
598
+ expect(consoleSpy).toHaveBeenCalled()
599
+ consoleSpy.mockRestore()
600
+ })
601
+
602
+ it('handles store corruption recovery', () => {
603
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
604
+
605
+ // Connect first
606
+ socket.mockServerMessage(createConnectMessage())
607
+
608
+ // Clear initial messages
609
+ socket.clearSentMessages()
610
+
611
+ // Mock store as corrupted
612
+ vi.spyOn(store, 'isPossiblyCorrupted').mockReturnValue(true)
613
+
614
+ // Try to make a change
615
+ const pageId = PageRecordType.createId()
616
+ store.put([
617
+ PageRecordType.create({
618
+ id: pageId,
619
+ name: 'Test',
620
+ index: 'a1' as any,
621
+ }),
622
+ ])
623
+
624
+ vi.advanceTimersByTime(100)
625
+
626
+ // Should not send new messages when store is corrupted
627
+ expect(socket.getSentMessages()).toHaveLength(0)
628
+
629
+ consoleSpy.mockRestore()
630
+ })
631
+
632
+ it('handles didCancel function', () => {
633
+ const didCancel = vi.fn(() => true)
634
+ client = createClient({ didCancel })
635
+
636
+ // Make a change that would trigger cancellation
637
+ const pageId = PageRecordType.createId()
638
+ store.put([
639
+ PageRecordType.create({
640
+ id: pageId,
641
+ name: 'Test',
642
+ index: 'a1' as any,
643
+ }),
644
+ ])
645
+
646
+ expect(didCancel).toHaveBeenCalled()
647
+ })
648
+ })
649
+
650
+ describe('Cleanup and Disposal', () => {
651
+ beforeEach(() => {
652
+ client = createClient()
653
+ })
654
+
655
+ it('properly cleans up resources on close', () => {
656
+ client.close()
657
+
658
+ // Should not throw errors after close
659
+ expect(() => {
660
+ const pageId = PageRecordType.createId()
661
+ store.put([
662
+ PageRecordType.create({
663
+ id: pageId,
664
+ name: 'Test',
665
+ index: 'a1' as any,
666
+ }),
667
+ ])
668
+ vi.advanceTimersByTime(100)
669
+ }).not.toThrow()
670
+ })
671
+
672
+ it('cancels throttled functions on close', () => {
673
+ // Reset and ensure we're connected
674
+ socket.clearSentMessages()
675
+
676
+ const pageId = PageRecordType.createId()
677
+ store.put([
678
+ PageRecordType.create({
679
+ id: pageId,
680
+ name: 'Test',
681
+ index: 'a1' as any,
682
+ }),
683
+ ])
684
+
685
+ // Close before throttle resolves
686
+ client.close()
687
+
688
+ // Should not send new messages after close (except the connect message that was already sent)
689
+ const messagesBefore = socket.getSentMessages().length
690
+ vi.advanceTimersByTime(100)
691
+ expect(socket.getSentMessages().length).toBe(messagesBefore)
692
+ })
693
+ })
694
+
695
+ describe('Connection Recovery', () => {
696
+ beforeEach(() => {
697
+ client = createClient()
698
+ })
699
+
700
+ it('handles reconnection with speculative changes', () => {
701
+ // Connect initially
702
+ socket.mockServerMessage(createConnectMessage())
703
+
704
+ // Make local changes while online
705
+ const pageId = PageRecordType.createId()
706
+ store.put([
707
+ PageRecordType.create({
708
+ id: pageId,
709
+ name: 'Test',
710
+ index: 'a1' as any,
711
+ }),
712
+ ])
713
+
714
+ // Go offline
715
+ socket.mockConnectionStatus('offline')
716
+
717
+ // Make more changes while offline
718
+ store.update(pageId, (p) => ({ ...p, name: 'Updated Offline' }))
719
+
720
+ // Reconnect
721
+ socket.mockConnectionStatus('online')
722
+
723
+ // Should send a new connect message
724
+ expect(socket.getSentMessages().some((msg) => msg.type === 'connect')).toBe(true)
725
+ })
726
+
727
+ it('handles wipe_all reconnection', () => {
728
+ // Connect initially with some data
729
+ const pageId = PageRecordType.createId()
730
+ store.put([
731
+ PageRecordType.create({
732
+ id: pageId,
733
+ name: 'Test',
734
+ index: 'a1' as any,
735
+ }),
736
+ ])
737
+
738
+ socket.mockServerMessage(
739
+ createConnectMessage({
740
+ diff: {
741
+ [TLDOCUMENT_ID]: [
742
+ RecordOpType.Put,
743
+ DocumentRecordType.create({
744
+ id: TLDOCUMENT_ID,
745
+ gridSize: 20,
746
+ }),
747
+ ],
748
+ },
749
+ })
750
+ )
751
+
752
+ // Should apply server data
753
+ const doc = store.get(TLDOCUMENT_ID)
754
+ expect(doc?.gridSize).toBe(20)
755
+ })
756
+ })
757
+
758
+ describe('Complex Scenarios', () => {
759
+ beforeEach(() => {
760
+ client = createClient()
761
+ // Connect first
762
+ socket.mockServerMessage(createConnectMessage())
763
+ socket.clearSentMessages()
764
+ })
765
+
766
+ it('handles rapid connection state changes', () => {
767
+ // Rapidly change connection states
768
+ socket.mockConnectionStatus('offline')
769
+ socket.mockConnectionStatus('online')
770
+ socket.mockConnectionStatus('error', 'Test error')
771
+
772
+ expect(onSyncError).toHaveBeenCalledWith('Test error')
773
+ })
774
+
775
+ it('handles multiple simultaneous presence and document changes', () => {
776
+ // Set presence
777
+ const presenceRecord = {
778
+ id: 'presence:user1' as any,
779
+ typeName: 'instance_presence',
780
+ cursor: { x: 100, y: 200 },
781
+ } as TestRecord
782
+
783
+ presence.set(presenceRecord)
784
+
785
+ // Make document changes
786
+ const pageId = PageRecordType.createId()
787
+ store.put([
788
+ PageRecordType.create({
789
+ id: pageId,
790
+ name: 'Test',
791
+ index: 'a1' as any,
792
+ }),
793
+ ])
794
+
795
+ vi.advanceTimersByTime(100)
796
+
797
+ // Should send messages
798
+ const messages = socket.getSentMessages()
799
+ expect(messages.length).toBeGreaterThan(0)
800
+
801
+ // Check if any push messages contain presence or document data
802
+ const pushMessages = messages.filter(
803
+ (msg) => msg.type === 'push'
804
+ ) as TLPushRequest<TestRecord>[]
805
+ const hasPresenceOrDocument = pushMessages.some((msg) => msg.presence || msg.diff)
806
+ expect(hasPresenceOrDocument).toBe(true)
807
+ })
808
+
809
+ it('handles server clock advancement correctly', () => {
810
+ // Send multiple server messages with advancing clocks
811
+ for (let i = 1; i <= 5; i++) {
812
+ socket.mockServerMessage({
813
+ type: 'data',
814
+ data: [
815
+ {
816
+ type: 'patch',
817
+ serverClock: i + 1,
818
+ diff: {},
819
+ },
820
+ ],
821
+ })
822
+ }
823
+
824
+ vi.advanceTimersByTime(100)
825
+ // Should track the latest server clock internally
826
+ })
827
+ })
828
+ })