@tldraw/sync-core 5.5.0-canary.d64d005dcf06 → 5.5.0-canary.d7142fa77bb5
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/SQLiteSyncStorage.js +15 -7
- package/dist-cjs/lib/SQLiteSyncStorage.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/SQLiteSyncStorage.mjs +15 -7
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +4 -7
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/package.json +6 -6
- package/src/lib/ClientWebSocketAdapter.test.ts +122 -2
- package/src/lib/ClientWebSocketAdapter.ts +30 -14
- package/src/lib/SQLiteSyncStorage.test.ts +60 -1
- package/src/lib/SQLiteSyncStorage.ts +22 -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
|
|
|
@@ -527,6 +527,46 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
527
527
|
adapter.close()
|
|
528
528
|
expect(() => adapter.close()).not.toThrow()
|
|
529
529
|
})
|
|
530
|
+
|
|
531
|
+
it('[CW9][RM5] closing immediately does not start another getUri call', async () => {
|
|
532
|
+
const getUri = vi.fn(() => 'ws://localhost:2233')
|
|
533
|
+
const testAdapter = new ClientWebSocketAdapter(getUri)
|
|
534
|
+
testAdapter.close()
|
|
535
|
+
const callsAtClose = getUri.mock.calls.length
|
|
536
|
+
|
|
537
|
+
await vi.advanceTimersByTimeAsync(INACTIVE_MAX_DELAY)
|
|
538
|
+
|
|
539
|
+
expect(getUri).toHaveBeenCalledTimes(callsAtClose)
|
|
540
|
+
expect(testAdapter._ws).toBeNull()
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it('[CW9][RM5] close reports offline without notifying listeners or reconnecting', async () => {
|
|
544
|
+
let uriCallCount = 0
|
|
545
|
+
const testAdapter = new ClientWebSocketAdapter(() => {
|
|
546
|
+
uriCallCount++
|
|
547
|
+
return 'ws://localhost:2233'
|
|
548
|
+
})
|
|
549
|
+
const onStatusChange = vi.fn()
|
|
550
|
+
testAdapter.onStatusChange(onStatusChange)
|
|
551
|
+
await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN)
|
|
552
|
+
const callsBeforeClose = uriCallCount
|
|
553
|
+
onStatusChange.mockClear()
|
|
554
|
+
|
|
555
|
+
testAdapter.close()
|
|
556
|
+
expect(testAdapter.connectionStatus).toBe('offline')
|
|
557
|
+
// let the socket's own close event land, then run out any (leaked) reconnect timer
|
|
558
|
+
vi.useRealTimers()
|
|
559
|
+
await sleep(50)
|
|
560
|
+
vi.useFakeTimers()
|
|
561
|
+
vi.advanceTimersByTime(INACTIVE_MAX_DELAY)
|
|
562
|
+
vi.useRealTimers()
|
|
563
|
+
await sleep(20)
|
|
564
|
+
vi.useFakeTimers()
|
|
565
|
+
|
|
566
|
+
expect(onStatusChange).not.toHaveBeenCalled()
|
|
567
|
+
expect(uriCallCount).toBe(callsBeforeClose)
|
|
568
|
+
expect(testAdapter._ws).toBeNull()
|
|
569
|
+
})
|
|
530
570
|
})
|
|
531
571
|
|
|
532
572
|
describe('orphaned sockets (CW10)', () => {
|
|
@@ -710,7 +750,7 @@ describe('ReconnectManager', () => {
|
|
|
710
750
|
// it's necessary to close the socket, as otherwise the websocket might stay half-open
|
|
711
751
|
connectedServerSocket.close()
|
|
712
752
|
wsServer.close()
|
|
713
|
-
await waitFor(() => adapter.
|
|
753
|
+
await waitFor(() => adapter.connectionStatus === 'offline')
|
|
714
754
|
expect(adapter._reconnectManager.intendedDelay).toBeGreaterThanOrEqual(INACTIVE_MIN_DELAY)
|
|
715
755
|
|
|
716
756
|
hiddenMock.mockReturnValue(false)
|
|
@@ -776,6 +816,32 @@ describe('ReconnectManager', () => {
|
|
|
776
816
|
expect(adapter._ws).toBeNull()
|
|
777
817
|
})
|
|
778
818
|
|
|
819
|
+
it.each(['throws', 'rejects'])(
|
|
820
|
+
'[RM1][CW1] getUri that %s is retried on the backoff instead of stranding the connection',
|
|
821
|
+
async (failure) => {
|
|
822
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
823
|
+
let attempts = 0
|
|
824
|
+
const testAdapter = new ClientWebSocketAdapter(() => {
|
|
825
|
+
attempts++
|
|
826
|
+
if (attempts < 3) {
|
|
827
|
+
const error = new Error('token endpoint unavailable')
|
|
828
|
+
if (failure === 'throws') throw error
|
|
829
|
+
return Promise.reject(error)
|
|
830
|
+
}
|
|
831
|
+
return 'ws://localhost:2234'
|
|
832
|
+
})
|
|
833
|
+
try {
|
|
834
|
+
await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN)
|
|
835
|
+
expect(attempts).toBe(3)
|
|
836
|
+
expect(consoleErrorSpy).toHaveBeenCalledTimes(2)
|
|
837
|
+
expect(testAdapter.connectionStatus).toBe('online')
|
|
838
|
+
} finally {
|
|
839
|
+
testAdapter.close()
|
|
840
|
+
consoleErrorSpy.mockRestore()
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
)
|
|
844
|
+
|
|
779
845
|
it('[RM5] close cancels timers and removes the reconnect event listeners', () => {
|
|
780
846
|
const testAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
|
|
781
847
|
const manager = testAdapter._reconnectManager
|
|
@@ -794,3 +860,57 @@ describe('ReconnectManager', () => {
|
|
|
794
860
|
expect(() => manager.close()).not.toThrow()
|
|
795
861
|
})
|
|
796
862
|
})
|
|
863
|
+
|
|
864
|
+
describe('URI failure boundaries', () => {
|
|
865
|
+
it.each(['resolves', 'rejects'])('ignores getUri that %s after close', async (outcome) => {
|
|
866
|
+
const uri = promiseWithResolve<string>()
|
|
867
|
+
const getUri = vi.fn(() => uri)
|
|
868
|
+
const testAdapter = new ClientWebSocketAdapter(getUri)
|
|
869
|
+
const onStatusChange = vi.fn()
|
|
870
|
+
testAdapter.onStatusChange(onStatusChange)
|
|
871
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
872
|
+
try {
|
|
873
|
+
await Promise.resolve()
|
|
874
|
+
testAdapter.close()
|
|
875
|
+
if (outcome === 'resolves') uri.resolve('ws://localhost:2234')
|
|
876
|
+
else uri.reject(new Error('token endpoint unavailable'))
|
|
877
|
+
await vi.advanceTimersByTimeAsync(INACTIVE_MAX_DELAY)
|
|
878
|
+
|
|
879
|
+
expect(getUri).toHaveBeenCalledTimes(1)
|
|
880
|
+
expect(testAdapter._ws).toBeNull()
|
|
881
|
+
expect(testAdapter.connectionStatus).toBe('offline')
|
|
882
|
+
expect(onStatusChange).not.toHaveBeenCalled()
|
|
883
|
+
expect(consoleError).not.toHaveBeenCalled()
|
|
884
|
+
} finally {
|
|
885
|
+
testAdapter.close()
|
|
886
|
+
consoleError.mockRestore()
|
|
887
|
+
}
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
it.each([
|
|
891
|
+
{ uri: 'not a URL', openSocket: false, error: 'Invalid URL' },
|
|
892
|
+
{
|
|
893
|
+
uri: 'ws://localhost:2234',
|
|
894
|
+
openSocket: true,
|
|
895
|
+
error: 'There should be no connection attempts while already connected',
|
|
896
|
+
},
|
|
897
|
+
])('does not treat $error as a getUri failure', async ({ uri, openSocket, error }) => {
|
|
898
|
+
let nextUri: string | Promise<string> = new Promise(() => {})
|
|
899
|
+
const testAdapter = new ClientWebSocketAdapter(() => nextUri)
|
|
900
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
901
|
+
try {
|
|
902
|
+
await Promise.resolve()
|
|
903
|
+
nextUri = uri
|
|
904
|
+
if (openSocket) testAdapter._ws = mockSocket(WebSocket.OPEN)
|
|
905
|
+
|
|
906
|
+
const manager = testAdapter._reconnectManager as unknown as {
|
|
907
|
+
scheduleAttempt(): Promise<void>
|
|
908
|
+
}
|
|
909
|
+
await expect(manager.scheduleAttempt()).rejects.toThrow(error)
|
|
910
|
+
expect(consoleError).not.toHaveBeenCalled()
|
|
911
|
+
} finally {
|
|
912
|
+
testAdapter.close()
|
|
913
|
+
consoleError.mockRestore()
|
|
914
|
+
}
|
|
915
|
+
})
|
|
916
|
+
})
|
|
@@ -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 (
|
|
@@ -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,
|
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', () => {
|