@tldraw/sync-core 5.5.0-next.f9308d324e1c → 5.5.1
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 +46 -18
- 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 +52 -6
- 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 +47 -19
- 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 +52 -6
- 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 +11 -7
- package/src/lib/ClientWebSocketAdapter.test.ts +221 -16
- package/src/lib/ClientWebSocketAdapter.ts +62 -23
- 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 +71 -6
- 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 +176 -0
- package/src/test/objectStore.test.ts +35 -0
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
ZERO_INDEX_KEY,
|
|
22
22
|
} from 'tldraw'
|
|
23
23
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
24
|
+
import { MAX_ASSEMBLED_MESSAGE_CHARS } from '../lib/chunk'
|
|
24
25
|
import { RecordOpType } from '../lib/diff'
|
|
25
26
|
import { DEFAULT_INITIAL_SNAPSHOT, InMemorySyncStorage } from '../lib/InMemorySyncStorage'
|
|
26
27
|
import { getTlsyncProtocolVersion } from '../lib/protocol'
|
|
@@ -210,6 +211,45 @@ describe('28. TLSocketRoom (SR)', () => {
|
|
|
210
211
|
consoleSpy.mockRestore()
|
|
211
212
|
})
|
|
212
213
|
|
|
214
|
+
it('[SR3] the default logger also reaches TLSyncRoom, so authorizer failures are reported', () => {
|
|
215
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
216
|
+
try {
|
|
217
|
+
const room = new TLSocketRoom({
|
|
218
|
+
authorizeRecord: {
|
|
219
|
+
page: () => {
|
|
220
|
+
throw new Error('authorizer exploded')
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
const socket = createMockSocket()
|
|
225
|
+
connectSession(room, 'test-session', socket)
|
|
226
|
+
|
|
227
|
+
const pageId = PageRecordType.createId('new-page')
|
|
228
|
+
room.handleSocketMessage(
|
|
229
|
+
'test-session',
|
|
230
|
+
JSON.stringify({
|
|
231
|
+
type: 'push',
|
|
232
|
+
clientClock: 1,
|
|
233
|
+
diff: {
|
|
234
|
+
[pageId]: [
|
|
235
|
+
RecordOpType.Put,
|
|
236
|
+
PageRecordType.create({ id: pageId, name: 'New Page', index: 'a2' as any }),
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
})
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
// the authorizer threw, and the host heard about it without passing a logger
|
|
243
|
+
expect(room.getRecord(pageId)).toBeUndefined()
|
|
244
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
245
|
+
'record authorizer threw; rejecting the write',
|
|
246
|
+
expect.objectContaining({ message: 'authorizer exploded' })
|
|
247
|
+
)
|
|
248
|
+
} finally {
|
|
249
|
+
consoleSpy.mockRestore()
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
|
|
213
253
|
it('[SR3] uses custom logger when provided', () => {
|
|
214
254
|
const mockLog: TLSyncLog = {
|
|
215
255
|
warn: vi.fn(),
|
|
@@ -353,6 +393,29 @@ describe('28. TLSocketRoom (SR)', () => {
|
|
|
353
393
|
expect(room.getSessions()[0].isConnected).toBe(false)
|
|
354
394
|
})
|
|
355
395
|
|
|
396
|
+
it('[SR5] rejects the session when an assembly exceeds the size cap', () => {
|
|
397
|
+
const log: TLSyncLog = { warn: vi.fn(), error: vi.fn() }
|
|
398
|
+
const room = new TLSocketRoom({ log })
|
|
399
|
+
const socket = createMockSocket()
|
|
400
|
+
connectSession(room, 'test-session', socket)
|
|
401
|
+
|
|
402
|
+
// A chunk stream that never sends its last chunk. Unlike the other assembly errors
|
|
403
|
+
// this one is fatal: a reconnecting client would re-send the same oversized message.
|
|
404
|
+
const body = 'x'.repeat(1024 * 1024)
|
|
405
|
+
const chunksNeeded = Math.ceil(MAX_ASSEMBLED_MESSAGE_CHARS / body.length) + 1
|
|
406
|
+
for (let i = 0; i < chunksNeeded; i++) {
|
|
407
|
+
room.handleSocketMessage('test-session', `${chunksNeeded - i}_${body}`)
|
|
408
|
+
if ((log.error as any).mock.calls.length) break
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
expect(log.error).toHaveBeenCalledWith('Error assembling message', expect.anything())
|
|
412
|
+
expect(socket.close).toHaveBeenCalledWith(
|
|
413
|
+
TLSyncErrorCloseEventCode,
|
|
414
|
+
TLSyncErrorCloseEventReason.MESSAGE_TOO_LARGE
|
|
415
|
+
)
|
|
416
|
+
expect(room.getSessions()).toHaveLength(0)
|
|
417
|
+
})
|
|
418
|
+
|
|
356
419
|
it('[SR5] rejects the session with UNKNOWN_ERROR when message handling throws', () => {
|
|
357
420
|
const log: TLSyncLog = { warn: vi.fn(), error: vi.fn() }
|
|
358
421
|
const room = new TLSocketRoom({ log })
|
|
@@ -1019,6 +1019,52 @@ describe('23. Connect handshake (HS)', () => {
|
|
|
1019
1019
|
expect(room.sessions.get('current-client-session')?.state).toBe(RoomSessionState.Connected)
|
|
1020
1020
|
expect(socket.__lastMessage?.type).toBe('connect')
|
|
1021
1021
|
})
|
|
1022
|
+
|
|
1023
|
+
it('[RC5][HS6] a wipeAll during a handshake closes connected sessions but hydrates the connecting one', async () => {
|
|
1024
|
+
const { room, storage } = makeRoom()
|
|
1025
|
+
const socketA = connectSession(room, 'a')
|
|
1026
|
+
const removed = vi.fn()
|
|
1027
|
+
room.events.on('session_removed', removed)
|
|
1028
|
+
|
|
1029
|
+
// 'b' has opened its socket but not yet sent its connect message
|
|
1030
|
+
const socketB = makeSocket()
|
|
1031
|
+
room.handleNewSession({ sessionId: 'b', socket: socketB, meta: undefined, isReadonly: false })
|
|
1032
|
+
|
|
1033
|
+
// the room hasn't seen this change yet because the storage notifies on a microtask
|
|
1034
|
+
const newPage = makePage('wipe_page', 'Wipe Page')
|
|
1035
|
+
storage.transaction((txn) => {
|
|
1036
|
+
txn.set(newPage.id, newPage)
|
|
1037
|
+
})
|
|
1038
|
+
storage.tombstoneHistoryStartsAtClock.set(storage.getClock())
|
|
1039
|
+
|
|
1040
|
+
// the handshake's own transaction runs broadcastChanges first and hits the wipeAll
|
|
1041
|
+
room.handleMessage('b', {
|
|
1042
|
+
type: 'connect',
|
|
1043
|
+
connectRequestId: 'connect-b',
|
|
1044
|
+
lastServerClock: 0,
|
|
1045
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
1046
|
+
schema: room.serializedSchema,
|
|
1047
|
+
} satisfies TLConnectRequest)
|
|
1048
|
+
|
|
1049
|
+
expect(socketA.close).toHaveBeenCalled()
|
|
1050
|
+
expect(room.sessions.has('a')).toBe(false)
|
|
1051
|
+
|
|
1052
|
+
expect(socketB.close).not.toHaveBeenCalled()
|
|
1053
|
+
expect(room.sessions.get('b')?.state).toBe(RoomSessionState.Connected)
|
|
1054
|
+
expect(socketB.__messages).toHaveLength(1)
|
|
1055
|
+
const connectMessage = socketB.__lastMessage as Extract<
|
|
1056
|
+
TLSocketServerSentEvent<any>,
|
|
1057
|
+
{ type: 'connect' }
|
|
1058
|
+
>
|
|
1059
|
+
expect(connectMessage.type).toBe('connect')
|
|
1060
|
+
expect(connectMessage.hydrationType).toBe('wipe_all')
|
|
1061
|
+
expect(connectMessage.diff[newPage.id]).toEqual(['put', newPage])
|
|
1062
|
+
|
|
1063
|
+
await Promise.resolve()
|
|
1064
|
+
await Promise.resolve()
|
|
1065
|
+
expect(removed.mock.calls.map(([e]) => e.sessionId)).toEqual(['a'])
|
|
1066
|
+
expect(room.sessions.has('b')).toBe(true)
|
|
1067
|
+
})
|
|
1022
1068
|
})
|
|
1023
1069
|
|
|
1024
1070
|
describe('24. Push handling (RP)', () => {
|
|
@@ -1949,6 +1995,111 @@ describe('25. Messaging and broadcast (RB)', () => {
|
|
|
1949
1995
|
expect(socketB.sendMessage).not.toHaveBeenCalled()
|
|
1950
1996
|
})
|
|
1951
1997
|
|
|
1998
|
+
it('[RB3] a debounced flush into a socket that closed meanwhile cancels the session instead of sending', () => {
|
|
1999
|
+
vi.useFakeTimers()
|
|
2000
|
+
const { room, socketB } = setupTwoSessions()
|
|
2001
|
+
const newPage = makePage('page_3', 'v1')
|
|
2002
|
+
|
|
2003
|
+
room.handleMessage('a', {
|
|
2004
|
+
type: 'push',
|
|
2005
|
+
clientClock: 1,
|
|
2006
|
+
diff: { [newPage.id]: ['put', newPage] },
|
|
2007
|
+
} as TLPushRequest<TLRecord>)
|
|
2008
|
+
room.handleMessage('a', {
|
|
2009
|
+
type: 'push',
|
|
2010
|
+
clientClock: 2,
|
|
2011
|
+
diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
|
|
2012
|
+
} as TLPushRequest<TLRecord>)
|
|
2013
|
+
expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
|
|
2014
|
+
|
|
2015
|
+
// the socket closes while the second message sits in the debounce buffer
|
|
2016
|
+
socketB.isOpen = false
|
|
2017
|
+
vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
|
|
2018
|
+
|
|
2019
|
+
expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
|
|
2020
|
+
expect(room.sessions.get('b')?.state).toBe(RoomSessionState.AwaitingRemoval)
|
|
2021
|
+
})
|
|
2022
|
+
|
|
2023
|
+
it('[RC7] close() drops pending debounced flushes so nothing is sent into closed sockets afterwards', () => {
|
|
2024
|
+
vi.useFakeTimers()
|
|
2025
|
+
const { room, socketB } = setupTwoSessions()
|
|
2026
|
+
const newPage = makePage('page_3', 'v1')
|
|
2027
|
+
|
|
2028
|
+
room.handleMessage('a', {
|
|
2029
|
+
type: 'push',
|
|
2030
|
+
clientClock: 1,
|
|
2031
|
+
diff: { [newPage.id]: ['put', newPage] },
|
|
2032
|
+
} as TLPushRequest<TLRecord>)
|
|
2033
|
+
room.handleMessage('a', {
|
|
2034
|
+
type: 'push',
|
|
2035
|
+
clientClock: 2,
|
|
2036
|
+
diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
|
|
2037
|
+
} as TLPushRequest<TLRecord>)
|
|
2038
|
+
expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
|
|
2039
|
+
|
|
2040
|
+
room.close()
|
|
2041
|
+
expect(room.isClosed()).toBe(true)
|
|
2042
|
+
vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
|
|
2043
|
+
|
|
2044
|
+
expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
|
|
2045
|
+
expect(socketB.close).toHaveBeenCalled()
|
|
2046
|
+
})
|
|
2047
|
+
|
|
2048
|
+
it('[RC7] close() closes every socket even when one of them throws on close', () => {
|
|
2049
|
+
const { room, socketA, socketB } = setupTwoSessions()
|
|
2050
|
+
socketA.close.mockImplementationOnce(() => {
|
|
2051
|
+
throw new Error('already closing')
|
|
2052
|
+
})
|
|
2053
|
+
|
|
2054
|
+
expect(() => room.close()).not.toThrow()
|
|
2055
|
+
expect(room.isClosed()).toBe(true)
|
|
2056
|
+
expect(socketB.close).toHaveBeenCalled()
|
|
2057
|
+
})
|
|
2058
|
+
|
|
2059
|
+
it('[RC7] close() forgets its sessions, and late socket close events emit no lifecycle events', () => {
|
|
2060
|
+
vi.useFakeTimers()
|
|
2061
|
+
const { room, socketA } = setupTwoSessions()
|
|
2062
|
+
const removed = vi.fn()
|
|
2063
|
+
const becameEmpty = vi.fn()
|
|
2064
|
+
room.events.on('session_removed', removed)
|
|
2065
|
+
room.events.on('room_became_empty', becameEmpty)
|
|
2066
|
+
|
|
2067
|
+
// b's socket closes before close(), a's closes after: both must stay silent
|
|
2068
|
+
room.handleClose('b')
|
|
2069
|
+
room.close()
|
|
2070
|
+
expect(room.sessions.size).toBe(0)
|
|
2071
|
+
room.handleClose('a')
|
|
2072
|
+
|
|
2073
|
+
vi.advanceTimersByTime(SESSION_REMOVAL_WAIT_TIME + 2001)
|
|
2074
|
+
expect(removed).not.toHaveBeenCalled()
|
|
2075
|
+
expect(becameEmpty).not.toHaveBeenCalled()
|
|
2076
|
+
expect(socketA.sendMessage).not.toHaveBeenCalled()
|
|
2077
|
+
})
|
|
2078
|
+
|
|
2079
|
+
it('[RC7] sessions added after close() are rejected and their sockets closed', () => {
|
|
2080
|
+
const { room } = makeRoom()
|
|
2081
|
+
room.close()
|
|
2082
|
+
|
|
2083
|
+
const late = makeSocket()
|
|
2084
|
+
room.handleNewSession({ sessionId: 'late', socket: late, meta: undefined, isReadonly: false })
|
|
2085
|
+
expect(late.close).toHaveBeenCalled()
|
|
2086
|
+
|
|
2087
|
+
const resumed = makeSocket()
|
|
2088
|
+
room.handleResumedSession({
|
|
2089
|
+
sessionId: 'resumed',
|
|
2090
|
+
socket: resumed,
|
|
2091
|
+
meta: undefined,
|
|
2092
|
+
isReadonly: false,
|
|
2093
|
+
serializedSchema: room.serializedSchema,
|
|
2094
|
+
presenceId: null,
|
|
2095
|
+
presenceRecord: null,
|
|
2096
|
+
requiresLegacyRejection: false,
|
|
2097
|
+
supportsStringAppend: true,
|
|
2098
|
+
})
|
|
2099
|
+
expect(resumed.close).toHaveBeenCalled()
|
|
2100
|
+
expect(room.sessions.size).toBe(0)
|
|
2101
|
+
})
|
|
2102
|
+
|
|
1952
2103
|
it('[RB4] a per-session migration failure during broadcast rejects only the affected session', () => {
|
|
1953
2104
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
1954
2105
|
try {
|
|
@@ -2235,6 +2386,31 @@ describe('26. Session lifecycle (SES)', () => {
|
|
|
2235
2386
|
expect(room.sessions.size).toBe(0)
|
|
2236
2387
|
})
|
|
2237
2388
|
|
|
2389
|
+
it('[SES4] a reason longer than 123 UTF-8 bytes is truncated on a code-point boundary with a dropped-bytes suffix', () => {
|
|
2390
|
+
const { room } = makeRoom()
|
|
2391
|
+
|
|
2392
|
+
const exact = connectSession(room, 'exact')
|
|
2393
|
+
room.rejectSession('exact', 'x'.repeat(123))
|
|
2394
|
+
expect(exact.close).toHaveBeenCalledWith(TLSyncErrorCloseEventCode, 'x'.repeat(123))
|
|
2395
|
+
|
|
2396
|
+
// '... (+200 bytes)' reserves 16 bytes, leaving 107 for the reason
|
|
2397
|
+
const ascii = connectSession(room, 'ascii')
|
|
2398
|
+
room.rejectSession('ascii', 'x'.repeat(200))
|
|
2399
|
+
expect(ascii.close).toHaveBeenCalledWith(
|
|
2400
|
+
TLSyncErrorCloseEventCode,
|
|
2401
|
+
'x'.repeat(107) + '... (+93 bytes)'
|
|
2402
|
+
)
|
|
2403
|
+
|
|
2404
|
+
// 161 bytes in, 107 to fill: 1 + 26 × 4 = 105, and a 27th 4-byte character is dropped whole
|
|
2405
|
+
const emoji = connectSession(room, 'emoji')
|
|
2406
|
+
room.rejectSession('emoji', 'x' + '\u{1F600}'.repeat(40))
|
|
2407
|
+
expect(emoji.close).toHaveBeenCalledWith(
|
|
2408
|
+
TLSyncErrorCloseEventCode,
|
|
2409
|
+
'x' + '\u{1F600}'.repeat(26) + '... (+56 bytes)'
|
|
2410
|
+
)
|
|
2411
|
+
expect(room.sessions.size).toBe(0)
|
|
2412
|
+
})
|
|
2413
|
+
|
|
2238
2414
|
it('[HS2][SES5] sets supportsStringAppend to false for protocol version 7', () => {
|
|
2239
2415
|
const { room } = makeRoom()
|
|
2240
2416
|
connectSession(room, 'v7-session', { protocolVersion: 7 })
|
|
@@ -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', () => {
|