@tldraw/sync-core 5.5.0-next.a2698a88ecfb → 5.5.0-next.b6a9f7cd7be2

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 (47) hide show
  1. package/dist-cjs/index.d.ts +7 -2
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/ClientWebSocketAdapter.js +35 -13
  4. package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
  5. package/dist-cjs/lib/InMemorySyncStorage.js +18 -18
  6. package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
  7. package/dist-cjs/lib/SQLiteSyncStorage.js +15 -7
  8. package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
  9. package/dist-cjs/lib/TLSocketRoom.js +9 -8
  10. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncClient.js +3 -1
  12. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  13. package/dist-cjs/lib/TLSyncRoom.js +50 -5
  14. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  15. package/dist-cjs/lib/chunk.js +35 -3
  16. package/dist-cjs/lib/chunk.js.map +2 -2
  17. package/dist-esm/index.d.mts +7 -2
  18. package/dist-esm/index.mjs +1 -1
  19. package/dist-esm/lib/ClientWebSocketAdapter.mjs +36 -14
  20. package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
  21. package/dist-esm/lib/InMemorySyncStorage.mjs +18 -18
  22. package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
  23. package/dist-esm/lib/SQLiteSyncStorage.mjs +15 -7
  24. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
  25. package/dist-esm/lib/TLSocketRoom.mjs +10 -9
  26. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  27. package/dist-esm/lib/TLSyncClient.mjs +3 -1
  28. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  29. package/dist-esm/lib/TLSyncRoom.mjs +50 -5
  30. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  31. package/dist-esm/lib/chunk.mjs +35 -3
  32. package/dist-esm/lib/chunk.mjs.map +2 -2
  33. package/package.json +7 -7
  34. package/src/lib/ClientWebSocketAdapter.test.ts +190 -16
  35. package/src/lib/ClientWebSocketAdapter.ts +45 -16
  36. package/src/lib/InMemorySyncStorage.test.ts +21 -0
  37. package/src/lib/InMemorySyncStorage.ts +20 -22
  38. package/src/lib/SQLiteSyncStorage.test.ts +60 -1
  39. package/src/lib/SQLiteSyncStorage.ts +22 -14
  40. package/src/lib/TLSocketRoom.ts +16 -9
  41. package/src/lib/TLSyncClient.ts +2 -0
  42. package/src/lib/TLSyncRoom.ts +66 -5
  43. package/src/lib/chunk.test.ts +88 -1
  44. package/src/lib/chunk.ts +66 -3
  45. package/src/test/TLSocketRoom.test.ts +63 -0
  46. package/src/test/TLSyncRoom.test.ts +130 -0
  47. package/src/test/objectStore.test.ts +35 -0
@@ -1,4 +1,4 @@
1
- import { warnOnce } from '@tldraw/utils'
1
+ import { promiseWithResolve, warnOnce } from '@tldraw/utils'
2
2
  import { TLRecord, sleep } from 'tldraw'
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
4
 
@@ -12,6 +12,7 @@ vi.mock('@tldraw/utils', async (importOriginal) => {
12
12
  // NOTE: setupVitest.js replaces the global WebSocket with the 'ws' package's WebSocket,
13
13
  // matching the WebSocketServer the tests connect to.
14
14
  import { WebSocketServer, WebSocket as WsWebSocket } from 'ws'
15
+ import { MAX_ASSEMBLED_MESSAGE_CHARS } from './chunk'
15
16
  import {
16
17
  ACTIVE_MAX_DELAY,
17
18
  ACTIVE_MIN_DELAY,
@@ -73,24 +74,38 @@ describe('ClientWebSocketAdapter', () => {
73
74
  connectedServerSocket = socket
74
75
  })
75
76
 
77
+ // Bound per test on an OS-assigned port. A fixed port raced against the previous test's
78
+ // server, which closes asynchronously, and intermittently failed with EADDRINUSE.
79
+ let port: number
80
+
76
81
  let consoleWarnSpy: ReturnType<typeof vi.spyOn>
77
- beforeEach(() => {
82
+ beforeEach(async () => {
78
83
  consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
79
- adapter = new ClientWebSocketAdapter(() => 'ws://localhost:2233')
80
- wsServer = new WebSocketServer({ port: 2233 })
84
+ wsServer = new WebSocketServer({ port: 0 })
85
+ // Attach before the adapter exists: the server is already listening by the time the
86
+ // adapter is constructed, so a handler added afterwards can miss the first connection.
81
87
  wsServer.on('connection', connectMock as any)
88
+ await new Promise<void>((resolve, reject) => {
89
+ wsServer.once('listening', resolve)
90
+ wsServer.once('error', reject)
91
+ })
92
+ port = (wsServer.address() as { port: number }).port
93
+ adapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
82
94
  })
83
95
 
84
96
  afterEach(() => {
85
97
  consoleWarnSpy.mockRestore()
86
98
  adapter.close()
99
+ // Not awaited: the next test binds its own OS-assigned port, so a server still closing
100
+ // here can't collide with it. Awaiting would deliver in-flight messages to the socket
101
+ // adapter.close() just orphaned.
87
102
  wsServer.close()
88
103
  connectMock.mockClear()
89
104
  })
90
105
 
91
106
  describe('connection and initial state (CW1, CW2)', () => {
92
107
  it('[CW2] starts with connectionStatus offline while the internal status is initial', () => {
93
- const newAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2233')
108
+ const newAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
94
109
  try {
95
110
  expect(newAdapter._connectionStatus.get()).toBe('initial')
96
111
  expect(newAdapter.connectionStatus).toBe('offline')
@@ -113,7 +128,7 @@ describe('ClientWebSocketAdapter', () => {
113
128
  let uriCallCount = 0
114
129
  const dynamicAdapter = new ClientWebSocketAdapter(() => {
115
130
  uriCallCount++
116
- return `ws://localhost:2233?attempt=${uriCallCount}`
131
+ return `ws://localhost:${port}?attempt=${uriCallCount}`
117
132
  })
118
133
  try {
119
134
  await waitFor(() => dynamicAdapter._ws?.readyState === WebSocket.OPEN)
@@ -143,7 +158,7 @@ describe('ClientWebSocketAdapter', () => {
143
158
  // no socket can be created until the promise resolves
144
159
  expect(asyncAdapter._ws).toBeNull()
145
160
 
146
- resolveUri!('ws://localhost:2233')
161
+ resolveUri!(`ws://localhost:${port}`)
147
162
 
148
163
  await waitFor(() => asyncAdapter._ws?.readyState === WebSocket.OPEN)
149
164
  expect(asyncAdapter.connectionStatus).toBe('online')
@@ -301,7 +316,7 @@ describe('ClientWebSocketAdapter', () => {
301
316
 
302
317
  describe('URI conversion (CW1)', () => {
303
318
  it('[CW1] converts http URIs to ws and connects', async () => {
304
- const httpAdapter = new ClientWebSocketAdapter(() => 'http://localhost:2233')
319
+ const httpAdapter = new ClientWebSocketAdapter(() => `http://localhost:${port}`)
305
320
  try {
306
321
  await waitFor(() => httpAdapter._ws?.readyState === WebSocket.OPEN)
307
322
  expect(httpAdapter._ws!.url).toMatch(/^ws:\/\//)
@@ -312,7 +327,7 @@ describe('ClientWebSocketAdapter', () => {
312
327
  })
313
328
 
314
329
  it('[CW1] converts https URIs to wss', async () => {
315
- const httpsAdapter = new ClientWebSocketAdapter(() => 'https://localhost:2233')
330
+ const httpsAdapter = new ClientWebSocketAdapter(() => `https://localhost:${port}`)
316
331
  try {
317
332
  await waitFor(() => httpsAdapter._ws !== null)
318
333
  expect(httpsAdapter._ws!.url).toMatch(/^wss:\/\//)
@@ -386,6 +401,32 @@ describe('ClientWebSocketAdapter', () => {
386
401
  )
387
402
  })
388
403
 
404
+ it('[CW6] refuses to send a message the server would reject as too large', async () => {
405
+ const onMessage = vi.fn()
406
+ connectMock.mockImplementationOnce((ws: any) => {
407
+ ws.on('message', onMessage)
408
+ })
409
+ await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
410
+
411
+ const onStatusChange = vi.fn()
412
+ adapter.onStatusChange(onStatusChange)
413
+
414
+ const message = {
415
+ ...connectMessage(),
416
+ largeData: 'x'.repeat(MAX_ASSEMBLED_MESSAGE_CHARS),
417
+ } as any
418
+ adapter.sendMessage(message)
419
+
420
+ // nothing goes on the wire: sending would only earn a close and a reconnect that
421
+ // re-sends the same message
422
+ expect(onMessage).not.toHaveBeenCalled()
423
+ expect(adapter.connectionStatus).toBe('error')
424
+ expect(onStatusChange).toHaveBeenCalledWith({
425
+ status: 'error',
426
+ reason: TLSyncErrorCloseEventReason.MESSAGE_TOO_LARGE,
427
+ })
428
+ })
429
+
389
430
  it('[CW6] silently drops the message when there is no socket', async () => {
390
431
  await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
391
432
 
@@ -527,6 +568,46 @@ describe('ClientWebSocketAdapter', () => {
527
568
  adapter.close()
528
569
  expect(() => adapter.close()).not.toThrow()
529
570
  })
571
+
572
+ it('[CW9][RM5] closing immediately does not start another getUri call', async () => {
573
+ const getUri = vi.fn(() => `ws://localhost:${port}`)
574
+ const testAdapter = new ClientWebSocketAdapter(getUri)
575
+ testAdapter.close()
576
+ const callsAtClose = getUri.mock.calls.length
577
+
578
+ await vi.advanceTimersByTimeAsync(INACTIVE_MAX_DELAY)
579
+
580
+ expect(getUri).toHaveBeenCalledTimes(callsAtClose)
581
+ expect(testAdapter._ws).toBeNull()
582
+ })
583
+
584
+ it('[CW9][RM5] close reports offline without notifying listeners or reconnecting', async () => {
585
+ let uriCallCount = 0
586
+ const testAdapter = new ClientWebSocketAdapter(() => {
587
+ uriCallCount++
588
+ return `ws://localhost:${port}`
589
+ })
590
+ const onStatusChange = vi.fn()
591
+ testAdapter.onStatusChange(onStatusChange)
592
+ await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN)
593
+ const callsBeforeClose = uriCallCount
594
+ onStatusChange.mockClear()
595
+
596
+ testAdapter.close()
597
+ expect(testAdapter.connectionStatus).toBe('offline')
598
+ // let the socket's own close event land, then run out any (leaked) reconnect timer
599
+ vi.useRealTimers()
600
+ await sleep(50)
601
+ vi.useFakeTimers()
602
+ vi.advanceTimersByTime(INACTIVE_MAX_DELAY)
603
+ vi.useRealTimers()
604
+ await sleep(20)
605
+ vi.useFakeTimers()
606
+
607
+ expect(onStatusChange).not.toHaveBeenCalled()
608
+ expect(uriCallCount).toBe(callsBeforeClose)
609
+ expect(testAdapter._ws).toBeNull()
610
+ })
530
611
  })
531
612
 
532
613
  describe('orphaned sockets (CW10)', () => {
@@ -566,17 +647,26 @@ describe('ReconnectManager', () => {
566
647
  connectedServerSocket = socket
567
648
  })
568
649
 
650
+ // See the note on the other suite's port: bound per test on an OS-assigned port.
651
+ let port: number
652
+
569
653
  let consoleWarnSpy: ReturnType<typeof vi.spyOn>
570
- beforeEach(() => {
654
+ beforeEach(async () => {
571
655
  consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
572
- adapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
573
- wsServer = new WebSocketServer({ port: 2234 })
656
+ wsServer = new WebSocketServer({ port: 0 })
574
657
  wsServer.on('connection', connectMock as any)
658
+ await new Promise<void>((resolve, reject) => {
659
+ wsServer.once('listening', resolve)
660
+ wsServer.once('error', reject)
661
+ })
662
+ port = (wsServer.address() as { port: number }).port
663
+ adapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
575
664
  })
576
665
 
577
666
  afterEach(() => {
578
667
  consoleWarnSpy.mockRestore()
579
668
  adapter.close()
669
+ // Not awaited - see the note on the other suite's teardown.
580
670
  wsServer.close()
581
671
  connectMock.mockClear()
582
672
  })
@@ -591,7 +681,7 @@ describe('ReconnectManager', () => {
591
681
  })
592
682
 
593
683
  it('[RM1] reconnection delays back off exponentially, bounded by the active delays', () => {
594
- const testAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
684
+ const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
595
685
  try {
596
686
  const manager = testAdapter._reconnectManager
597
687
  // make every disconnected() call take the "attempt now" branch
@@ -616,7 +706,7 @@ describe('ReconnectManager', () => {
616
706
 
617
707
  it('[RM1] uses the inactive delay bounds when the tab is hidden', () => {
618
708
  const hiddenMock = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true)
619
- const testAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
709
+ const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
620
710
  try {
621
711
  const manager = testAdapter._reconnectManager
622
712
  ;(manager as any).lastAttemptStart = Date.now() - 1_000_000
@@ -710,7 +800,7 @@ describe('ReconnectManager', () => {
710
800
  // it's necessary to close the socket, as otherwise the websocket might stay half-open
711
801
  connectedServerSocket.close()
712
802
  wsServer.close()
713
- await waitFor(() => adapter._ws?.readyState !== WebSocket.OPEN)
803
+ await waitFor(() => adapter.connectionStatus === 'offline')
714
804
  expect(adapter._reconnectManager.intendedDelay).toBeGreaterThanOrEqual(INACTIVE_MIN_DELAY)
715
805
 
716
806
  hiddenMock.mockReturnValue(false)
@@ -776,8 +866,34 @@ describe('ReconnectManager', () => {
776
866
  expect(adapter._ws).toBeNull()
777
867
  })
778
868
 
869
+ it.each(['throws', 'rejects'])(
870
+ '[RM1][CW1] getUri that %s is retried on the backoff instead of stranding the connection',
871
+ async (failure) => {
872
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
873
+ let attempts = 0
874
+ const testAdapter = new ClientWebSocketAdapter(() => {
875
+ attempts++
876
+ if (attempts < 3) {
877
+ const error = new Error('token endpoint unavailable')
878
+ if (failure === 'throws') throw error
879
+ return Promise.reject(error)
880
+ }
881
+ return `ws://localhost:${port}`
882
+ })
883
+ try {
884
+ await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN)
885
+ expect(attempts).toBe(3)
886
+ expect(consoleErrorSpy).toHaveBeenCalledTimes(2)
887
+ expect(testAdapter.connectionStatus).toBe('online')
888
+ } finally {
889
+ testAdapter.close()
890
+ consoleErrorSpy.mockRestore()
891
+ }
892
+ }
893
+ )
894
+
779
895
  it('[RM5] close cancels timers and removes the reconnect event listeners', () => {
780
- const testAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
896
+ const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
781
897
  const manager = testAdapter._reconnectManager
782
898
 
783
899
  testAdapter.close()
@@ -794,3 +910,61 @@ describe('ReconnectManager', () => {
794
910
  expect(() => manager.close()).not.toThrow()
795
911
  })
796
912
  })
913
+
914
+ describe('URI failure boundaries', () => {
915
+ // These tests never open a real socket - the adapter is closed first, or the socket is a
916
+ // mock - so the URI only has to parse. No server, hence no port to bind.
917
+ const UNUSED_URI = 'ws://localhost:1'
918
+
919
+ it.each(['resolves', 'rejects'])('ignores getUri that %s after close', async (outcome) => {
920
+ const uri = promiseWithResolve<string>()
921
+ const getUri = vi.fn(() => uri)
922
+ const testAdapter = new ClientWebSocketAdapter(getUri)
923
+ const onStatusChange = vi.fn()
924
+ testAdapter.onStatusChange(onStatusChange)
925
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
926
+ try {
927
+ await Promise.resolve()
928
+ testAdapter.close()
929
+ if (outcome === 'resolves') uri.resolve(UNUSED_URI)
930
+ else uri.reject(new Error('token endpoint unavailable'))
931
+ await vi.advanceTimersByTimeAsync(INACTIVE_MAX_DELAY)
932
+
933
+ expect(getUri).toHaveBeenCalledTimes(1)
934
+ expect(testAdapter._ws).toBeNull()
935
+ expect(testAdapter.connectionStatus).toBe('offline')
936
+ expect(onStatusChange).not.toHaveBeenCalled()
937
+ expect(consoleError).not.toHaveBeenCalled()
938
+ } finally {
939
+ testAdapter.close()
940
+ consoleError.mockRestore()
941
+ }
942
+ })
943
+
944
+ it.each([
945
+ { uri: 'not a URL', openSocket: false, error: 'Invalid URL' },
946
+ {
947
+ uri: UNUSED_URI,
948
+ openSocket: true,
949
+ error: 'There should be no connection attempts while already connected',
950
+ },
951
+ ])('does not treat $error as a getUri failure', async ({ uri, openSocket, error }) => {
952
+ let nextUri: string | Promise<string> = new Promise(() => {})
953
+ const testAdapter = new ClientWebSocketAdapter(() => nextUri)
954
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
955
+ try {
956
+ await Promise.resolve()
957
+ nextUri = uri
958
+ if (openSocket) testAdapter._ws = mockSocket(WebSocket.OPEN)
959
+
960
+ const manager = testAdapter._reconnectManager as unknown as {
961
+ scheduleAttempt(): Promise<void>
962
+ }
963
+ await expect(manager.scheduleAttempt()).rejects.toThrow(error)
964
+ expect(consoleError).not.toHaveBeenCalled()
965
+ } finally {
966
+ testAdapter.close()
967
+ consoleError.mockRestore()
968
+ }
969
+ })
970
+ })
@@ -1,7 +1,7 @@
1
1
  import { atom, Atom } from '@tldraw/state'
2
2
  import { TLRecord } from '@tldraw/tlschema'
3
3
  import { assert, warnOnce } from '@tldraw/utils'
4
- import { chunk } from './chunk'
4
+ import { chunk, MAX_ASSEMBLED_MESSAGE_CHARS } from './chunk'
5
5
  import { TLSocketClientSentEvent, TLSocketServerSentEvent } from './protocol'
6
6
  import {
7
7
  TLPersistentClientSocket,
@@ -93,8 +93,13 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<
93
93
  close() {
94
94
  this.isDisposed = true
95
95
  this._reconnectManager.close()
96
+ // orphan the socket before closing it (as _closeSocket does) so its onclose can't run
97
+ // _handleDisconnect after disposal — that would notify listeners and schedule a reconnect
98
+ const ws = this._ws
99
+ this._ws = null
96
100
  // WebSocket.close() is idempotent
97
- this._ws?.close()
101
+ ws?.close()
102
+ this._connectionStatus.set('offline')
98
103
  }
99
104
 
100
105
  /**
@@ -240,8 +245,6 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<
240
245
  this._handleDisconnect('manual')
241
246
  }
242
247
 
243
- // TLPersistentClientSocket stuff
244
-
245
248
  _connectionStatus: Atom<TLPersistentClientSocketStatus | 'initial'> = atom(
246
249
  'websocket connection status',
247
250
  'initial'
@@ -277,7 +280,20 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<
277
280
 
278
281
  if (!this._ws) return
279
282
  if (this.connectionStatus === 'online') {
280
- const chunks = chunk(JSON.stringify(msg))
283
+ const stringified = JSON.stringify(msg)
284
+ // The server rejects a session whose assembled message exceeds this, so sending would
285
+ // only earn a close and a reconnect that re-sends the same message. Fail here the way
286
+ // the server would, so onSyncError runs once instead of the client looping forever.
287
+ if (stringified.length > MAX_ASSEMBLED_MESSAGE_CHARS) {
288
+ this._handleDisconnect(
289
+ 'closed',
290
+ TLSyncErrorCloseEventCode,
291
+ true,
292
+ TLSyncErrorCloseEventReason.MESSAGE_TOO_LARGE
293
+ )
294
+ return
295
+ }
296
+ const chunks = chunk(stringified)
281
297
  for (const part of chunks) {
282
298
  this._ws.send(part)
283
299
  }
@@ -513,21 +529,33 @@ export class ReconnectManager {
513
529
  }
514
530
  }
515
531
 
516
- private scheduleAttempt() {
532
+ private async scheduleAttempt() {
517
533
  assert(this.state === 'pendingAttempt')
534
+ if (this.isDisposed) return
518
535
  debug('scheduling a connection attempt')
519
- Promise.resolve(this.getUri()).then((uri) => {
520
- // this can happen if the promise gets resolved too late
536
+
537
+ let uri: string
538
+ try {
539
+ uri = await this.getUri()
540
+ } catch (error) {
541
+ // A failed auth-token fetch must retry instead of leaving us with no socket or timer.
521
542
  if (this.state !== 'pendingAttempt' || this.isDisposed) return
522
- assert(
523
- this.socketAdapter._ws?.readyState !== WebSocket.OPEN,
524
- 'There should be no connection attempts while already connected'
525
- )
543
+ console.error('Failed to get the websocket URI, retrying', error)
544
+ this.state = 'delay'
545
+ this.reconnectTimeout = setTimeout(() => this.disconnected(), this.intendedDelay)
546
+ return
547
+ }
526
548
 
527
- this.lastAttemptStart = Date.now()
528
- this.socketAdapter._setNewSocket(new WebSocket(httpToWs(uri)))
529
- this.state = 'pendingAttemptResult'
530
- })
549
+ // this can happen if the promise gets resolved too late
550
+ if (this.state !== 'pendingAttempt' || this.isDisposed) return
551
+ assert(
552
+ this.socketAdapter._ws?.readyState !== WebSocket.OPEN,
553
+ 'There should be no connection attempts while already connected'
554
+ )
555
+
556
+ this.lastAttemptStart = Date.now()
557
+ this.socketAdapter._setNewSocket(new WebSocket(httpToWs(uri)))
558
+ this.state = 'pendingAttemptResult'
531
559
  }
532
560
 
533
561
  private getMaxDelay() {
@@ -641,6 +669,7 @@ export class ReconnectManager {
641
669
  debug('ReconnectManager.disconnected')
642
670
  // This either means we're freshly disconnected, or the last connection attempt failed;
643
671
  // either way, time to try again.
672
+ if (this.isDisposed) return
644
673
 
645
674
  // Guard against delayed notifications and recheck synchronously
646
675
  if (
@@ -51,6 +51,27 @@ describe('InMemorySyncStorage', () => {
51
51
  expect(storage.getClock()).toBe(25)
52
52
  })
53
53
 
54
+ it('[IM1] handles snapshots with far more records and tombstones than fit in a spread call', () => {
55
+ const documents = []
56
+ for (let i = 0; i < 150_000; i++) {
57
+ documents.push({
58
+ state: { ...contractRecords[0], id: `shape:${i}` } as TLRecord,
59
+ lastChangedClock: i,
60
+ })
61
+ }
62
+ const tombstones: Record<string, number> = {}
63
+ for (let i = 0; i < 150_000; i++) tombstones[`shape:gone-${i}`] = 150_000 + i
64
+ const storage = new InMemorySyncStorage<TLRecord>({
65
+ snapshot: makeContractSnapshot(contractRecords, {
66
+ documents,
67
+ tombstones,
68
+ documentClock: 0,
69
+ tombstoneHistoryStartsAtClock: 0,
70
+ }),
71
+ })
72
+ expect(storage.getClock()).toBe(299_999)
73
+ })
74
+
54
75
  it('[IM2] clamps tombstoneHistoryStartsAtClock down to the document clock', () => {
55
76
  const storage = new InMemorySyncStorage<TLRecord>({
56
77
  snapshot: makeContractSnapshot(contractRecords, {
@@ -153,27 +153,27 @@ export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStora
153
153
  onChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown
154
154
  } = {}) {
155
155
  this.objectTypes = new Set(objectTypes ?? [])
156
- const maxClockValue = Math.max(
157
- 0,
158
- ...Object.values(snapshot.tombstones ?? {}),
159
- ...Object.values(snapshot.documents.map((d) => d.lastChangedClock))
160
- )
156
+ // a loop rather than `Math.max(0, ...clocks)`: spreading a large room's clocks as call
157
+ // arguments overflows the stack at roughly 100k+ records/tombstones
158
+ let maxClockValue = 0
161
159
  // route snapshot entries into their partitions (a seed snapshot may carry object-lane
162
160
  // records merged in, e.g. loaded from a separate persistence lane)
163
- const toEntry = (
164
- d: RoomSnapshot['documents'][number]
165
- ): [string, { state: R; lastChangedClock: number }] => [
166
- d.state.id,
167
- { state: devFreeze(d.state) as R, lastChangedClock: d.lastChangedClock },
168
- ]
169
- this.documents = new AtomMap(
170
- 'room documents',
171
- snapshot.documents.filter((d) => !this.objectTypes.has(d.state.typeName)).map(toEntry)
172
- )
173
- this.objects = new AtomMap(
174
- 'room objects',
175
- snapshot.documents.filter((d) => this.objectTypes.has(d.state.typeName)).map(toEntry)
176
- )
161
+ const documentEntries: [string, { state: R; lastChangedClock: number }][] = []
162
+ const objectEntries: [string, { state: R; lastChangedClock: number }][] = []
163
+ for (const d of snapshot.documents) {
164
+ if (d.lastChangedClock > maxClockValue) maxClockValue = d.lastChangedClock
165
+ const entries = this.objectTypes.has(d.state.typeName) ? objectEntries : documentEntries
166
+ entries.push([
167
+ d.state.id,
168
+ { state: devFreeze(d.state) as R, lastChangedClock: d.lastChangedClock },
169
+ ])
170
+ }
171
+ const tombstoneEntries = objectMapEntries(snapshot.tombstones ?? {})
172
+ for (const [, clock] of tombstoneEntries) {
173
+ if (clock > maxClockValue) maxClockValue = clock
174
+ }
175
+ this.documents = new AtomMap('room documents', documentEntries)
176
+ this.objects = new AtomMap('room objects', objectEntries)
177
177
  const documentClock = Math.max(maxClockValue, snapshot.documentClock ?? snapshot.clock ?? 0)
178
178
 
179
179
  this.documentClock = atom('document clock', documentClock)
@@ -192,9 +192,7 @@ export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStora
192
192
  'room tombstones',
193
193
  // If the tombstone history starts now (or we didn't have the
194
194
  // tombstoneHistoryStartsAtClock) then there are no tombstones
195
- tombstoneHistoryStartsAtClock === documentClock
196
- ? []
197
- : objectMapEntries(snapshot.tombstones ?? {})
195
+ tombstoneHistoryStartsAtClock === documentClock ? [] : tombstoneEntries
198
196
  )
199
197
  if (onChange) {
200
198
  this.onChange(onChange)
@@ -1,7 +1,7 @@
1
1
  import { DatabaseSync } from 'node:sqlite'
2
2
  import { DocumentRecordType, PageRecordType, TLDOCUMENT_ID, TLRecord } from '@tldraw/tlschema'
3
3
  import { ZERO_INDEX_KEY } from '@tldraw/utils'
4
- import { describe, expect, it } from 'vitest'
4
+ import { describe, expect, it, vi } from 'vitest'
5
5
  import {
6
6
  contractRecords,
7
7
  contractSchema,
@@ -9,6 +9,7 @@ import {
9
9
  makePage,
10
10
  registerStorageContractTests,
11
11
  } from '../test/storageContractSuite'
12
+ import { MAX_TOMBSTONES } from './InMemorySyncStorage'
12
13
  import { NodeSqliteWrapper } from './NodeSqliteWrapper'
13
14
  import { SQLiteSyncStorage } from './SQLiteSyncStorage'
14
15
 
@@ -263,4 +264,62 @@ describe('SQLiteSyncStorage', () => {
263
264
  expect(row?.migrationVersion).toBe(3)
264
265
  })
265
266
  })
267
+
268
+ describe('tombstone pruning robustness', () => {
269
+ const makeTombstones = (count: number) => {
270
+ const tombstones: Record<string, number> = {}
271
+ for (let i = 0; i < count; i++) tombstones[`shape:doc${i}`] = i + 1
272
+ return tombstones
273
+ }
274
+ function makeOverfullStorage() {
275
+ const db = new DatabaseSync(':memory:')
276
+ const storage = new SQLiteSyncStorage<TLRecord>({
277
+ sql: new NodeSqliteWrapper(db),
278
+ snapshot: makeContractSnapshot(contractRecords, {
279
+ tombstones: makeTombstones(MAX_TOMBSTONES + 500),
280
+ documentClock: MAX_TOMBSTONES + 501,
281
+ tombstoneHistoryStartsAtClock: 0,
282
+ }),
283
+ })
284
+ return { db, storage, prune: (storage as any).pruneTombstones as { (): void; flush(): void } }
285
+ }
286
+
287
+ it('[SS18] a prune that fires after the database was closed does not throw', () => {
288
+ const { db, storage, prune } = makeOverfullStorage()
289
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
290
+ try {
291
+ // a delete schedules the throttled prune; the host then closes everything
292
+ storage.transaction((txn) => txn.delete(contractRecords[1].id))
293
+ db.close()
294
+ // the throttle timer fires later, from outside any caller that could catch it
295
+ expect(() => prune.flush()).not.toThrow()
296
+ expect(consoleSpy).toHaveBeenCalledWith('Failed to prune tombstones', expect.anything())
297
+ } finally {
298
+ consoleSpy.mockRestore()
299
+ }
300
+ })
301
+
302
+ it('[SS18] a prune whose delete fails leaves the history clock where it was', () => {
303
+ const { storage, prune } = makeOverfullStorage()
304
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
305
+ try {
306
+ const stmts = (storage as any).stmts
307
+ const originalRun = stmts.deleteTombstonesBefore.run
308
+ stmts.deleteTombstonesBefore.run = () => {
309
+ throw new Error('SQLITE_BUSY: database is locked')
310
+ }
311
+ prune()
312
+ expect(() => prune.flush()).not.toThrow()
313
+ stmts.deleteTombstonesBefore.run = originalRun
314
+
315
+ // neither half of the prune applied: the history start was not advanced past
316
+ // tombstones that still exist
317
+ const snapshot = storage.getSnapshot()
318
+ expect(snapshot.tombstoneHistoryStartsAtClock).toBe(0)
319
+ expect(Object.keys(snapshot.tombstones!).length).toBe(MAX_TOMBSTONES + 500)
320
+ } finally {
321
+ consoleSpy.mockRestore()
322
+ }
323
+ })
324
+ })
266
325
  })
@@ -394,7 +394,6 @@ export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage
394
394
  const documentClock = snapshot.documentClock ?? snapshot.clock ?? 0
395
395
  const tombstoneHistoryStartsAtClock = snapshot.tombstoneHistoryStartsAtClock ?? documentClock
396
396
 
397
- // Clear existing data
398
397
  this.sql.exec(`
399
398
  DELETE FROM ${documentsTable};
400
399
  DELETE FROM ${objectsTable};
@@ -410,14 +409,12 @@ export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage
410
409
  table.insert.run(doc.state.id, encodeState(doc.state), doc.lastChangedClock)
411
410
  }
412
411
 
413
- // Insert tombstones
414
412
  if (snapshot.tombstones) {
415
413
  for (const [id, clock] of objectMapEntries(snapshot.tombstones)) {
416
414
  this.stmts.insertTombstone.run(id, clock)
417
415
  }
418
416
  }
419
417
 
420
- // Insert metadata row
421
418
  this.stmts.updateMetadata.run(
422
419
  documentClock,
423
420
  tombstoneHistoryStartsAtClock,
@@ -513,18 +510,29 @@ export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage
513
510
  /** @internal */
514
511
  pruneTombstones = throttle(
515
512
  () => {
516
- const tombstoneCount = this.stmts.countTombstones.all()[0].count as number
517
- if (tombstoneCount > MAX_TOMBSTONES) {
518
- // Get all tombstones sorted by clock ascending (oldest first)
519
- const tombstones = this.stmts.iterateTombstones.all()
520
-
521
- const result = computeTombstonePruning({ tombstones, documentClock: this.getClock() })
522
- if (result) {
523
- this.stmts.setTombstoneHistoryStartsAtClock.run(result.newTombstoneHistoryStartsAtClock)
524
- // Delete all tombstones with clock < newTombstoneHistoryStartsAtClock in one operation.
525
- // This works because computeTombstonePruning ensures we never split a clock value.
526
- this.stmts.deleteTombstonesBefore.run(result.newTombstoneHistoryStartsAtClock)
513
+ // Runs from a timer, so a host that closed the database right after the last delete
514
+ // (e.g. `room.close(); db.close()` in onSessionRemoved) would otherwise get an uncaught
515
+ // throw from here; pruning is best-effort and simply runs again on the next delete.
516
+ try {
517
+ const tombstoneCount = this.stmts.countTombstones.all()[0].count as number
518
+ if (tombstoneCount > MAX_TOMBSTONES) {
519
+ // Get all tombstones sorted by clock ascending (oldest first)
520
+ const tombstones = this.stmts.iterateTombstones.all()
521
+
522
+ const result = computeTombstonePruning({ tombstones, documentClock: this.getClock() })
523
+ if (result) {
524
+ this.sql.transaction(() => {
525
+ this.stmts.setTombstoneHistoryStartsAtClock.run(
526
+ result.newTombstoneHistoryStartsAtClock
527
+ )
528
+ // Delete all tombstones with clock < newTombstoneHistoryStartsAtClock in one operation.
529
+ // This works because computeTombstonePruning ensures we never split a clock value.
530
+ this.stmts.deleteTombstonesBefore.run(result.newTombstoneHistoryStartsAtClock)
531
+ })
532
+ }
527
533
  }
534
+ } catch (e) {
535
+ console.error('Failed to prune tombstones', e)
528
536
  }
529
537
  },
530
538
  1000,