@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.
- package/dist-cjs/index.d.ts +7 -2
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/ClientWebSocketAdapter.js +35 -13
- package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +18 -18
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/SQLiteSyncStorage.js +15 -7
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +9 -8
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +3 -1
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +50 -5
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/chunk.js +35 -3
- package/dist-cjs/lib/chunk.js.map +2 -2
- package/dist-esm/index.d.mts +7 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/ClientWebSocketAdapter.mjs +36 -14
- package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +18 -18
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/SQLiteSyncStorage.mjs +15 -7
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +10 -9
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +3 -1
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +50 -5
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/chunk.mjs +35 -3
- package/dist-esm/lib/chunk.mjs.map +2 -2
- package/package.json +7 -7
- package/src/lib/ClientWebSocketAdapter.test.ts +190 -16
- package/src/lib/ClientWebSocketAdapter.ts +45 -16
- package/src/lib/InMemorySyncStorage.test.ts +21 -0
- package/src/lib/InMemorySyncStorage.ts +20 -22
- package/src/lib/SQLiteSyncStorage.test.ts +60 -1
- package/src/lib/SQLiteSyncStorage.ts +22 -14
- package/src/lib/TLSocketRoom.ts +16 -9
- package/src/lib/TLSyncClient.ts +2 -0
- package/src/lib/TLSyncRoom.ts +66 -5
- package/src/lib/chunk.test.ts +88 -1
- package/src/lib/chunk.ts +66 -3
- package/src/test/TLSocketRoom.test.ts +63 -0
- package/src/test/TLSyncRoom.test.ts +130 -0
- 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
|
-
|
|
80
|
-
|
|
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(() =>
|
|
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
|
|
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!(
|
|
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(() =>
|
|
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(() =>
|
|
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
|
-
|
|
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(() =>
|
|
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(() =>
|
|
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.
|
|
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(() =>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
520
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
d.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
this.stmts.
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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,
|