@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.
Files changed (47) hide show
  1. package/dist-cjs/index.d.ts +7 -2
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/ClientWebSocketAdapter.js +46 -18
  4. package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
  5. package/dist-cjs/lib/InMemorySyncStorage.js +18 -18
  6. package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
  7. package/dist-cjs/lib/SQLiteSyncStorage.js +15 -7
  8. package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
  9. package/dist-cjs/lib/TLSocketRoom.js +9 -8
  10. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncClient.js +3 -1
  12. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  13. package/dist-cjs/lib/TLSyncRoom.js +52 -6
  14. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  15. package/dist-cjs/lib/chunk.js +35 -3
  16. package/dist-cjs/lib/chunk.js.map +2 -2
  17. package/dist-esm/index.d.mts +7 -2
  18. package/dist-esm/index.mjs +1 -1
  19. package/dist-esm/lib/ClientWebSocketAdapter.mjs +47 -19
  20. package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
  21. package/dist-esm/lib/InMemorySyncStorage.mjs +18 -18
  22. package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
  23. package/dist-esm/lib/SQLiteSyncStorage.mjs +15 -7
  24. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
  25. package/dist-esm/lib/TLSocketRoom.mjs +10 -9
  26. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  27. package/dist-esm/lib/TLSyncClient.mjs +3 -1
  28. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  29. package/dist-esm/lib/TLSyncRoom.mjs +52 -6
  30. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  31. package/dist-esm/lib/chunk.mjs +35 -3
  32. package/dist-esm/lib/chunk.mjs.map +2 -2
  33. package/package.json +11 -7
  34. package/src/lib/ClientWebSocketAdapter.test.ts +221 -16
  35. package/src/lib/ClientWebSocketAdapter.ts +62 -23
  36. package/src/lib/InMemorySyncStorage.test.ts +21 -0
  37. package/src/lib/InMemorySyncStorage.ts +20 -22
  38. package/src/lib/SQLiteSyncStorage.test.ts +60 -1
  39. package/src/lib/SQLiteSyncStorage.ts +22 -14
  40. package/src/lib/TLSocketRoom.ts +16 -9
  41. package/src/lib/TLSyncClient.ts +2 -0
  42. package/src/lib/TLSyncRoom.ts +71 -6
  43. package/src/lib/chunk.test.ts +88 -1
  44. package/src/lib/chunk.ts +66 -3
  45. package/src/test/TLSocketRoom.test.ts +63 -0
  46. package/src/test/TLSyncRoom.test.ts +176 -0
  47. 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', () => {