@tldraw/sync-core 5.5.0-canary.fa8dc460ac73 → 5.5.0-canary.fdb2d37fcb2a
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.js +1 -1
- package/dist-cjs/lib/ClientWebSocketAdapter.js +24 -12
- package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +4 -7
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/ClientWebSocketAdapter.mjs +24 -12
- package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +4 -7
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/package.json +7 -7
- package/src/lib/ClientWebSocketAdapter.test.ts +163 -16
- package/src/lib/ClientWebSocketAdapter.ts +30 -14
- package/src/lib/TLSocketRoom.ts +8 -7
- package/src/test/TLSocketRoom.test.ts +39 -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
|
|
|
@@ -73,24 +73,38 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
73
73
|
connectedServerSocket = socket
|
|
74
74
|
})
|
|
75
75
|
|
|
76
|
+
// Bound per test on an OS-assigned port. A fixed port raced against the previous test's
|
|
77
|
+
// server, which closes asynchronously, and intermittently failed with EADDRINUSE.
|
|
78
|
+
let port: number
|
|
79
|
+
|
|
76
80
|
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
|
|
77
|
-
beforeEach(() => {
|
|
81
|
+
beforeEach(async () => {
|
|
78
82
|
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
wsServer = new WebSocketServer({ port: 0 })
|
|
84
|
+
// Attach before the adapter exists: the server is already listening by the time the
|
|
85
|
+
// adapter is constructed, so a handler added afterwards can miss the first connection.
|
|
81
86
|
wsServer.on('connection', connectMock as any)
|
|
87
|
+
await new Promise<void>((resolve, reject) => {
|
|
88
|
+
wsServer.once('listening', resolve)
|
|
89
|
+
wsServer.once('error', reject)
|
|
90
|
+
})
|
|
91
|
+
port = (wsServer.address() as { port: number }).port
|
|
92
|
+
adapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
82
93
|
})
|
|
83
94
|
|
|
84
95
|
afterEach(() => {
|
|
85
96
|
consoleWarnSpy.mockRestore()
|
|
86
97
|
adapter.close()
|
|
98
|
+
// Not awaited: the next test binds its own OS-assigned port, so a server still closing
|
|
99
|
+
// here can't collide with it. Awaiting would deliver in-flight messages to the socket
|
|
100
|
+
// adapter.close() just orphaned.
|
|
87
101
|
wsServer.close()
|
|
88
102
|
connectMock.mockClear()
|
|
89
103
|
})
|
|
90
104
|
|
|
91
105
|
describe('connection and initial state (CW1, CW2)', () => {
|
|
92
106
|
it('[CW2] starts with connectionStatus offline while the internal status is initial', () => {
|
|
93
|
-
const newAdapter = new ClientWebSocketAdapter(() =>
|
|
107
|
+
const newAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
94
108
|
try {
|
|
95
109
|
expect(newAdapter._connectionStatus.get()).toBe('initial')
|
|
96
110
|
expect(newAdapter.connectionStatus).toBe('offline')
|
|
@@ -113,7 +127,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
113
127
|
let uriCallCount = 0
|
|
114
128
|
const dynamicAdapter = new ClientWebSocketAdapter(() => {
|
|
115
129
|
uriCallCount++
|
|
116
|
-
return `ws://localhost
|
|
130
|
+
return `ws://localhost:${port}?attempt=${uriCallCount}`
|
|
117
131
|
})
|
|
118
132
|
try {
|
|
119
133
|
await waitFor(() => dynamicAdapter._ws?.readyState === WebSocket.OPEN)
|
|
@@ -143,7 +157,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
143
157
|
// no socket can be created until the promise resolves
|
|
144
158
|
expect(asyncAdapter._ws).toBeNull()
|
|
145
159
|
|
|
146
|
-
resolveUri!(
|
|
160
|
+
resolveUri!(`ws://localhost:${port}`)
|
|
147
161
|
|
|
148
162
|
await waitFor(() => asyncAdapter._ws?.readyState === WebSocket.OPEN)
|
|
149
163
|
expect(asyncAdapter.connectionStatus).toBe('online')
|
|
@@ -301,7 +315,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
301
315
|
|
|
302
316
|
describe('URI conversion (CW1)', () => {
|
|
303
317
|
it('[CW1] converts http URIs to ws and connects', async () => {
|
|
304
|
-
const httpAdapter = new ClientWebSocketAdapter(() =>
|
|
318
|
+
const httpAdapter = new ClientWebSocketAdapter(() => `http://localhost:${port}`)
|
|
305
319
|
try {
|
|
306
320
|
await waitFor(() => httpAdapter._ws?.readyState === WebSocket.OPEN)
|
|
307
321
|
expect(httpAdapter._ws!.url).toMatch(/^ws:\/\//)
|
|
@@ -312,7 +326,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
312
326
|
})
|
|
313
327
|
|
|
314
328
|
it('[CW1] converts https URIs to wss', async () => {
|
|
315
|
-
const httpsAdapter = new ClientWebSocketAdapter(() =>
|
|
329
|
+
const httpsAdapter = new ClientWebSocketAdapter(() => `https://localhost:${port}`)
|
|
316
330
|
try {
|
|
317
331
|
await waitFor(() => httpsAdapter._ws !== null)
|
|
318
332
|
expect(httpsAdapter._ws!.url).toMatch(/^wss:\/\//)
|
|
@@ -527,6 +541,46 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
527
541
|
adapter.close()
|
|
528
542
|
expect(() => adapter.close()).not.toThrow()
|
|
529
543
|
})
|
|
544
|
+
|
|
545
|
+
it('[CW9][RM5] closing immediately does not start another getUri call', async () => {
|
|
546
|
+
const getUri = vi.fn(() => `ws://localhost:${port}`)
|
|
547
|
+
const testAdapter = new ClientWebSocketAdapter(getUri)
|
|
548
|
+
testAdapter.close()
|
|
549
|
+
const callsAtClose = getUri.mock.calls.length
|
|
550
|
+
|
|
551
|
+
await vi.advanceTimersByTimeAsync(INACTIVE_MAX_DELAY)
|
|
552
|
+
|
|
553
|
+
expect(getUri).toHaveBeenCalledTimes(callsAtClose)
|
|
554
|
+
expect(testAdapter._ws).toBeNull()
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('[CW9][RM5] close reports offline without notifying listeners or reconnecting', async () => {
|
|
558
|
+
let uriCallCount = 0
|
|
559
|
+
const testAdapter = new ClientWebSocketAdapter(() => {
|
|
560
|
+
uriCallCount++
|
|
561
|
+
return `ws://localhost:${port}`
|
|
562
|
+
})
|
|
563
|
+
const onStatusChange = vi.fn()
|
|
564
|
+
testAdapter.onStatusChange(onStatusChange)
|
|
565
|
+
await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN)
|
|
566
|
+
const callsBeforeClose = uriCallCount
|
|
567
|
+
onStatusChange.mockClear()
|
|
568
|
+
|
|
569
|
+
testAdapter.close()
|
|
570
|
+
expect(testAdapter.connectionStatus).toBe('offline')
|
|
571
|
+
// let the socket's own close event land, then run out any (leaked) reconnect timer
|
|
572
|
+
vi.useRealTimers()
|
|
573
|
+
await sleep(50)
|
|
574
|
+
vi.useFakeTimers()
|
|
575
|
+
vi.advanceTimersByTime(INACTIVE_MAX_DELAY)
|
|
576
|
+
vi.useRealTimers()
|
|
577
|
+
await sleep(20)
|
|
578
|
+
vi.useFakeTimers()
|
|
579
|
+
|
|
580
|
+
expect(onStatusChange).not.toHaveBeenCalled()
|
|
581
|
+
expect(uriCallCount).toBe(callsBeforeClose)
|
|
582
|
+
expect(testAdapter._ws).toBeNull()
|
|
583
|
+
})
|
|
530
584
|
})
|
|
531
585
|
|
|
532
586
|
describe('orphaned sockets (CW10)', () => {
|
|
@@ -566,17 +620,26 @@ describe('ReconnectManager', () => {
|
|
|
566
620
|
connectedServerSocket = socket
|
|
567
621
|
})
|
|
568
622
|
|
|
623
|
+
// See the note on the other suite's port: bound per test on an OS-assigned port.
|
|
624
|
+
let port: number
|
|
625
|
+
|
|
569
626
|
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
|
|
570
|
-
beforeEach(() => {
|
|
627
|
+
beforeEach(async () => {
|
|
571
628
|
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
572
|
-
|
|
573
|
-
wsServer = new WebSocketServer({ port: 2234 })
|
|
629
|
+
wsServer = new WebSocketServer({ port: 0 })
|
|
574
630
|
wsServer.on('connection', connectMock as any)
|
|
631
|
+
await new Promise<void>((resolve, reject) => {
|
|
632
|
+
wsServer.once('listening', resolve)
|
|
633
|
+
wsServer.once('error', reject)
|
|
634
|
+
})
|
|
635
|
+
port = (wsServer.address() as { port: number }).port
|
|
636
|
+
adapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
575
637
|
})
|
|
576
638
|
|
|
577
639
|
afterEach(() => {
|
|
578
640
|
consoleWarnSpy.mockRestore()
|
|
579
641
|
adapter.close()
|
|
642
|
+
// Not awaited - see the note on the other suite's teardown.
|
|
580
643
|
wsServer.close()
|
|
581
644
|
connectMock.mockClear()
|
|
582
645
|
})
|
|
@@ -591,7 +654,7 @@ describe('ReconnectManager', () => {
|
|
|
591
654
|
})
|
|
592
655
|
|
|
593
656
|
it('[RM1] reconnection delays back off exponentially, bounded by the active delays', () => {
|
|
594
|
-
const testAdapter = new ClientWebSocketAdapter(() =>
|
|
657
|
+
const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
595
658
|
try {
|
|
596
659
|
const manager = testAdapter._reconnectManager
|
|
597
660
|
// make every disconnected() call take the "attempt now" branch
|
|
@@ -616,7 +679,7 @@ describe('ReconnectManager', () => {
|
|
|
616
679
|
|
|
617
680
|
it('[RM1] uses the inactive delay bounds when the tab is hidden', () => {
|
|
618
681
|
const hiddenMock = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true)
|
|
619
|
-
const testAdapter = new ClientWebSocketAdapter(() =>
|
|
682
|
+
const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
620
683
|
try {
|
|
621
684
|
const manager = testAdapter._reconnectManager
|
|
622
685
|
;(manager as any).lastAttemptStart = Date.now() - 1_000_000
|
|
@@ -710,7 +773,7 @@ describe('ReconnectManager', () => {
|
|
|
710
773
|
// it's necessary to close the socket, as otherwise the websocket might stay half-open
|
|
711
774
|
connectedServerSocket.close()
|
|
712
775
|
wsServer.close()
|
|
713
|
-
await waitFor(() => adapter.
|
|
776
|
+
await waitFor(() => adapter.connectionStatus === 'offline')
|
|
714
777
|
expect(adapter._reconnectManager.intendedDelay).toBeGreaterThanOrEqual(INACTIVE_MIN_DELAY)
|
|
715
778
|
|
|
716
779
|
hiddenMock.mockReturnValue(false)
|
|
@@ -776,8 +839,34 @@ describe('ReconnectManager', () => {
|
|
|
776
839
|
expect(adapter._ws).toBeNull()
|
|
777
840
|
})
|
|
778
841
|
|
|
842
|
+
it.each(['throws', 'rejects'])(
|
|
843
|
+
'[RM1][CW1] getUri that %s is retried on the backoff instead of stranding the connection',
|
|
844
|
+
async (failure) => {
|
|
845
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
846
|
+
let attempts = 0
|
|
847
|
+
const testAdapter = new ClientWebSocketAdapter(() => {
|
|
848
|
+
attempts++
|
|
849
|
+
if (attempts < 3) {
|
|
850
|
+
const error = new Error('token endpoint unavailable')
|
|
851
|
+
if (failure === 'throws') throw error
|
|
852
|
+
return Promise.reject(error)
|
|
853
|
+
}
|
|
854
|
+
return `ws://localhost:${port}`
|
|
855
|
+
})
|
|
856
|
+
try {
|
|
857
|
+
await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN)
|
|
858
|
+
expect(attempts).toBe(3)
|
|
859
|
+
expect(consoleErrorSpy).toHaveBeenCalledTimes(2)
|
|
860
|
+
expect(testAdapter.connectionStatus).toBe('online')
|
|
861
|
+
} finally {
|
|
862
|
+
testAdapter.close()
|
|
863
|
+
consoleErrorSpy.mockRestore()
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
)
|
|
867
|
+
|
|
779
868
|
it('[RM5] close cancels timers and removes the reconnect event listeners', () => {
|
|
780
|
-
const testAdapter = new ClientWebSocketAdapter(() =>
|
|
869
|
+
const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
781
870
|
const manager = testAdapter._reconnectManager
|
|
782
871
|
|
|
783
872
|
testAdapter.close()
|
|
@@ -794,3 +883,61 @@ describe('ReconnectManager', () => {
|
|
|
794
883
|
expect(() => manager.close()).not.toThrow()
|
|
795
884
|
})
|
|
796
885
|
})
|
|
886
|
+
|
|
887
|
+
describe('URI failure boundaries', () => {
|
|
888
|
+
// These tests never open a real socket - the adapter is closed first, or the socket is a
|
|
889
|
+
// mock - so the URI only has to parse. No server, hence no port to bind.
|
|
890
|
+
const UNUSED_URI = 'ws://localhost:1'
|
|
891
|
+
|
|
892
|
+
it.each(['resolves', 'rejects'])('ignores getUri that %s after close', async (outcome) => {
|
|
893
|
+
const uri = promiseWithResolve<string>()
|
|
894
|
+
const getUri = vi.fn(() => uri)
|
|
895
|
+
const testAdapter = new ClientWebSocketAdapter(getUri)
|
|
896
|
+
const onStatusChange = vi.fn()
|
|
897
|
+
testAdapter.onStatusChange(onStatusChange)
|
|
898
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
899
|
+
try {
|
|
900
|
+
await Promise.resolve()
|
|
901
|
+
testAdapter.close()
|
|
902
|
+
if (outcome === 'resolves') uri.resolve(UNUSED_URI)
|
|
903
|
+
else uri.reject(new Error('token endpoint unavailable'))
|
|
904
|
+
await vi.advanceTimersByTimeAsync(INACTIVE_MAX_DELAY)
|
|
905
|
+
|
|
906
|
+
expect(getUri).toHaveBeenCalledTimes(1)
|
|
907
|
+
expect(testAdapter._ws).toBeNull()
|
|
908
|
+
expect(testAdapter.connectionStatus).toBe('offline')
|
|
909
|
+
expect(onStatusChange).not.toHaveBeenCalled()
|
|
910
|
+
expect(consoleError).not.toHaveBeenCalled()
|
|
911
|
+
} finally {
|
|
912
|
+
testAdapter.close()
|
|
913
|
+
consoleError.mockRestore()
|
|
914
|
+
}
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
it.each([
|
|
918
|
+
{ uri: 'not a URL', openSocket: false, error: 'Invalid URL' },
|
|
919
|
+
{
|
|
920
|
+
uri: UNUSED_URI,
|
|
921
|
+
openSocket: true,
|
|
922
|
+
error: 'There should be no connection attempts while already connected',
|
|
923
|
+
},
|
|
924
|
+
])('does not treat $error as a getUri failure', async ({ uri, openSocket, error }) => {
|
|
925
|
+
let nextUri: string | Promise<string> = new Promise(() => {})
|
|
926
|
+
const testAdapter = new ClientWebSocketAdapter(() => nextUri)
|
|
927
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
928
|
+
try {
|
|
929
|
+
await Promise.resolve()
|
|
930
|
+
nextUri = uri
|
|
931
|
+
if (openSocket) testAdapter._ws = mockSocket(WebSocket.OPEN)
|
|
932
|
+
|
|
933
|
+
const manager = testAdapter._reconnectManager as unknown as {
|
|
934
|
+
scheduleAttempt(): Promise<void>
|
|
935
|
+
}
|
|
936
|
+
await expect(manager.scheduleAttempt()).rejects.toThrow(error)
|
|
937
|
+
expect(consoleError).not.toHaveBeenCalled()
|
|
938
|
+
} finally {
|
|
939
|
+
testAdapter.close()
|
|
940
|
+
consoleError.mockRestore()
|
|
941
|
+
}
|
|
942
|
+
})
|
|
943
|
+
})
|
|
@@ -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'
|
|
@@ -513,21 +516,33 @@ export class ReconnectManager {
|
|
|
513
516
|
}
|
|
514
517
|
}
|
|
515
518
|
|
|
516
|
-
private scheduleAttempt() {
|
|
519
|
+
private async scheduleAttempt() {
|
|
517
520
|
assert(this.state === 'pendingAttempt')
|
|
521
|
+
if (this.isDisposed) return
|
|
518
522
|
debug('scheduling a connection attempt')
|
|
519
|
-
|
|
520
|
-
|
|
523
|
+
|
|
524
|
+
let uri: string
|
|
525
|
+
try {
|
|
526
|
+
uri = await this.getUri()
|
|
527
|
+
} catch (error) {
|
|
528
|
+
// A failed auth-token fetch must retry instead of leaving us with no socket or timer.
|
|
521
529
|
if (this.state !== 'pendingAttempt' || this.isDisposed) return
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
530
|
+
console.error('Failed to get the websocket URI, retrying', error)
|
|
531
|
+
this.state = 'delay'
|
|
532
|
+
this.reconnectTimeout = setTimeout(() => this.disconnected(), this.intendedDelay)
|
|
533
|
+
return
|
|
534
|
+
}
|
|
526
535
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
536
|
+
// this can happen if the promise gets resolved too late
|
|
537
|
+
if (this.state !== 'pendingAttempt' || this.isDisposed) return
|
|
538
|
+
assert(
|
|
539
|
+
this.socketAdapter._ws?.readyState !== WebSocket.OPEN,
|
|
540
|
+
'There should be no connection attempts while already connected'
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
this.lastAttemptStart = Date.now()
|
|
544
|
+
this.socketAdapter._setNewSocket(new WebSocket(httpToWs(uri)))
|
|
545
|
+
this.state = 'pendingAttemptResult'
|
|
531
546
|
}
|
|
532
547
|
|
|
533
548
|
private getMaxDelay() {
|
|
@@ -641,6 +656,7 @@ export class ReconnectManager {
|
|
|
641
656
|
debug('ReconnectManager.disconnected')
|
|
642
657
|
// This either means we're freshly disconnected, or the last connection attempt failed;
|
|
643
658
|
// either way, time to try again.
|
|
659
|
+
if (this.isDisposed) return
|
|
644
660
|
|
|
645
661
|
// Guard against delayed notifications and recheck synchronously
|
|
646
662
|
if (
|
package/src/lib/TLSocketRoom.ts
CHANGED
|
@@ -273,13 +273,16 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
273
273
|
})
|
|
274
274
|
)
|
|
275
275
|
}
|
|
276
|
+
// The default logger belongs to both layers: TLSyncRoom is where authorizer throws are
|
|
277
|
+
// logged, and a host that passes no `log` would otherwise never see them.
|
|
278
|
+
this.log = 'log' in opts ? opts.log : { error: console.error }
|
|
276
279
|
this.room = new TLSyncRoom<R, SessionMeta>({
|
|
277
280
|
onPresenceChange: opts.onPresenceChange,
|
|
278
281
|
onCommittedChanges: opts.onCommittedChanges,
|
|
279
282
|
objectTypes: opts.objectTypes,
|
|
280
283
|
authorizeRecord: opts.authorizeRecord,
|
|
281
284
|
schema: opts.schema ?? (createTLSchema() as any),
|
|
282
|
-
log:
|
|
285
|
+
log: this.log,
|
|
283
286
|
storage,
|
|
284
287
|
clientTimeout: opts.clientTimeout,
|
|
285
288
|
})
|
|
@@ -295,7 +298,6 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
295
298
|
})
|
|
296
299
|
}
|
|
297
300
|
})
|
|
298
|
-
this.log = 'log' in opts ? opts.log : { error: console.error }
|
|
299
301
|
}
|
|
300
302
|
|
|
301
303
|
/**
|
|
@@ -854,12 +856,11 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
854
856
|
if (this.isClosed()) {
|
|
855
857
|
throw new Error('Cannot update store on a closed room')
|
|
856
858
|
}
|
|
859
|
+
// read through the transaction rather than getSnapshot(): the document snapshot excludes the
|
|
860
|
+
// object-store lane, which would make object-lane records invisible (and undeletable) here
|
|
861
|
+
const records = this.storage.transaction((txn) => Object.fromEntries(txn.entries())).result
|
|
857
862
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
858
|
-
const ctx = new StoreUpdateContext<R>(
|
|
859
|
-
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
860
|
-
Object.fromEntries(this.getCurrentSnapshot().documents.map((d) => [d.state.id, d.state])),
|
|
861
|
-
this.room.schema
|
|
862
|
-
)
|
|
863
|
+
const ctx = new StoreUpdateContext<R>(records, this.room.schema)
|
|
863
864
|
try {
|
|
864
865
|
await updater(ctx)
|
|
865
866
|
} finally {
|
|
@@ -210,6 +210,45 @@ describe('28. TLSocketRoom (SR)', () => {
|
|
|
210
210
|
consoleSpy.mockRestore()
|
|
211
211
|
})
|
|
212
212
|
|
|
213
|
+
it('[SR3] the default logger also reaches TLSyncRoom, so authorizer failures are reported', () => {
|
|
214
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
215
|
+
try {
|
|
216
|
+
const room = new TLSocketRoom({
|
|
217
|
+
authorizeRecord: {
|
|
218
|
+
page: () => {
|
|
219
|
+
throw new Error('authorizer exploded')
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
const socket = createMockSocket()
|
|
224
|
+
connectSession(room, 'test-session', socket)
|
|
225
|
+
|
|
226
|
+
const pageId = PageRecordType.createId('new-page')
|
|
227
|
+
room.handleSocketMessage(
|
|
228
|
+
'test-session',
|
|
229
|
+
JSON.stringify({
|
|
230
|
+
type: 'push',
|
|
231
|
+
clientClock: 1,
|
|
232
|
+
diff: {
|
|
233
|
+
[pageId]: [
|
|
234
|
+
RecordOpType.Put,
|
|
235
|
+
PageRecordType.create({ id: pageId, name: 'New Page', index: 'a2' as any }),
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
})
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
// the authorizer threw, and the host heard about it without passing a logger
|
|
242
|
+
expect(room.getRecord(pageId)).toBeUndefined()
|
|
243
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
244
|
+
'record authorizer threw; rejecting the write',
|
|
245
|
+
expect.objectContaining({ message: 'authorizer exploded' })
|
|
246
|
+
)
|
|
247
|
+
} finally {
|
|
248
|
+
consoleSpy.mockRestore()
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
|
|
213
252
|
it('[SR3] uses custom logger when provided', () => {
|
|
214
253
|
const mockLog: TLSyncLog = {
|
|
215
254
|
warn: vi.fn(),
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
TLSocketServerSentEvent,
|
|
9
9
|
getTlsyncProtocolVersion,
|
|
10
10
|
} from '../lib/protocol'
|
|
11
|
+
import { TLSocketRoom } from '../lib/TLSocketRoom'
|
|
11
12
|
import { TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from '../lib/TLSyncClient'
|
|
12
13
|
import { RoomSnapshot, TLRoomSocket, TLSyncRoom } from '../lib/TLSyncRoom'
|
|
13
14
|
|
|
@@ -354,6 +355,40 @@ describe('object store lane', () => {
|
|
|
354
355
|
const connectMsg = late.__messages.find((m: any) => m.type === 'connect') as any
|
|
355
356
|
expect(Object.keys(connectMsg.diff).sort()).toEqual([doc.id, note.id].sort())
|
|
356
357
|
})
|
|
358
|
+
|
|
359
|
+
it('[US1][US3] updateStore sees and can delete object-lane records', async () => {
|
|
360
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
361
|
+
const socketRoom = new TLSocketRoom<R, void>({
|
|
362
|
+
schema,
|
|
363
|
+
storage: new InMemorySyncStorage<R>({
|
|
364
|
+
snapshot: { documents: [], clock: 0, documentClock: 0, schema: schema.serialize() },
|
|
365
|
+
objectTypes: ['note'],
|
|
366
|
+
}),
|
|
367
|
+
objectTypes: ['note'],
|
|
368
|
+
})
|
|
369
|
+
disposables.push(() => socketRoom.close())
|
|
370
|
+
const doc = Doc.create({ title: 'canvas' })
|
|
371
|
+
const note = Note.create({ text: 'annotation' })
|
|
372
|
+
socketRoom.storage.transaction((txn) => {
|
|
373
|
+
txn.set(doc.id, doc)
|
|
374
|
+
txn.set(note.id, note)
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
// the update context reads both lanes, not just the document snapshot
|
|
378
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
379
|
+
await socketRoom.updateStore((store) => {
|
|
380
|
+
expect(store.get(note.id)).toEqual(note)
|
|
381
|
+
expect(
|
|
382
|
+
store
|
|
383
|
+
.getAll()
|
|
384
|
+
.map((r) => r.id)
|
|
385
|
+
.sort()
|
|
386
|
+
).toEqual([doc.id, note.id].sort())
|
|
387
|
+
store.delete(note.id)
|
|
388
|
+
})
|
|
389
|
+
expect(socketRoom.getRecord(note.id)).toBeUndefined()
|
|
390
|
+
expect(socketRoom.getRecord(doc.id)).toEqual(doc)
|
|
391
|
+
})
|
|
357
392
|
})
|
|
358
393
|
|
|
359
394
|
describe('connect message', () => {
|