@tldraw/sync-core 5.5.0-canary.d0d1c4dfcd91 → 5.5.0-canary.d112bdaa69ee

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 (41) 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/TLSocketRoom.js +9 -8
  8. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  9. package/dist-cjs/lib/TLSyncClient.js +3 -1
  10. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncRoom.js +22 -3
  12. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  13. package/dist-cjs/lib/chunk.js +25 -2
  14. package/dist-cjs/lib/chunk.js.map +2 -2
  15. package/dist-esm/index.d.mts +7 -2
  16. package/dist-esm/index.mjs +1 -1
  17. package/dist-esm/lib/ClientWebSocketAdapter.mjs +47 -19
  18. package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
  19. package/dist-esm/lib/InMemorySyncStorage.mjs +18 -18
  20. package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
  21. package/dist-esm/lib/TLSocketRoom.mjs +10 -9
  22. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  23. package/dist-esm/lib/TLSyncClient.mjs +3 -1
  24. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  25. package/dist-esm/lib/TLSyncRoom.mjs +22 -3
  26. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  27. package/dist-esm/lib/chunk.mjs +25 -2
  28. package/dist-esm/lib/chunk.mjs.map +2 -2
  29. package/package.json +11 -7
  30. package/src/lib/ClientWebSocketAdapter.test.ts +221 -16
  31. package/src/lib/ClientWebSocketAdapter.ts +62 -23
  32. package/src/lib/InMemorySyncStorage.test.ts +21 -0
  33. package/src/lib/InMemorySyncStorage.ts +20 -22
  34. package/src/lib/TLSocketRoom.ts +16 -9
  35. package/src/lib/TLSyncClient.ts +2 -0
  36. package/src/lib/TLSyncRoom.ts +32 -3
  37. package/src/lib/chunk.test.ts +54 -1
  38. package/src/lib/chunk.ts +46 -2
  39. package/src/test/TLSocketRoom.test.ts +63 -0
  40. package/src/test/TLSyncRoom.test.ts +71 -0
  41. package/src/test/objectStore.test.ts +35 -0
@@ -122,6 +122,8 @@ export const TLSyncErrorCloseEventReason = {
122
122
  RATE_LIMITED: 'RATE_LIMITED',
123
123
  /** Room has reached maximum capacity */
124
124
  ROOM_FULL: 'ROOM_FULL',
125
+ /** A single message exceeded the size the server will assemble */
126
+ MESSAGE_TOO_LARGE: 'MESSAGE_TOO_LARGE',
125
127
  } as const
126
128
 
127
129
  /**
@@ -88,6 +88,29 @@ export const DATA_MESSAGE_DEBOUNCE_INTERVAL = 1000 / 60
88
88
 
89
89
  const timeSince = (time: number) => Date.now() - time
90
90
 
91
+ // WebSocket close frames cap the reason at 123 UTF-8 bytes; a longer one makes `close()` throw
92
+ const MAX_CLOSE_REASON_BYTES = 123
93
+ const closeReasonEncoder = new TextEncoder()
94
+
95
+ const truncatedSuffix = (droppedBytes: number) => `... (+${droppedBytes} bytes)`
96
+
97
+ function truncateCloseReason(reason: string) {
98
+ const totalBytes = closeReasonEncoder.encode(reason).length
99
+ if (totalBytes <= MAX_CLOSE_REASON_BYTES) return reason
100
+ // the dropped count can't have more digits than the total, so reserving room for it always fits
101
+ const budget = MAX_CLOSE_REASON_BYTES - truncatedSuffix(totalBytes).length
102
+ let out = ''
103
+ let outBytes = 0
104
+ // step by code point so a multi-byte character is never cut in half
105
+ for (const char of reason) {
106
+ const charBytes = closeReasonEncoder.encode(char).length
107
+ if (outBytes + charBytes > budget) break
108
+ out += char
109
+ outBytes += charBytes
110
+ }
111
+ return out + truncatedSuffix(totalBytes - outBytes)
112
+ }
113
+
91
114
  /**
92
115
  * Snapshot of a room's complete state that can be persisted and restored.
93
116
  * Contains all documents, tombstones, and metadata needed to reconstruct the room.
@@ -559,7 +582,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
559
582
 
560
583
  try {
561
584
  if (fatalReason) {
562
- session.socket.close(TLSyncErrorCloseEventCode, fatalReason)
585
+ // the session is already gone, so a throwing close() would leave its socket open
586
+ session.socket.close(TLSyncErrorCloseEventCode, truncateCloseReason(fatalReason))
563
587
  } else {
564
588
  session.socket.close()
565
589
  }
@@ -941,7 +965,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
941
965
  * Sends appropriate error messages before closing the connection.
942
966
  *
943
967
  * @param sessionId - The session to reject
944
- * @param fatalReason - The reason for rejection (optional)
968
+ * @param fatalReason - The reason for rejection (optional). WebSocket close reasons are capped
969
+ * at 123 UTF-8 bytes, so a longer reason is truncated and ends with `... (+N bytes)`.
945
970
  * @example
946
971
  * ```ts
947
972
  * // Reject due to version mismatch
@@ -998,6 +1023,10 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
998
1023
 
999
1024
  private forceAllReconnect() {
1000
1025
  for (const session of this.sessions.values()) {
1026
+ // Only connected clients hold state at a clock we can no longer diff from. A session
1027
+ // mid-handshake gets hydrated from its own lastServerClock in the same transaction, so
1028
+ // removing it would close its socket and then re-add it as Connected (resurrected).
1029
+ if (session.state !== RoomSessionState.Connected) continue
1001
1030
  this.removeSession(session.sessionId)
1002
1031
  }
1003
1032
  }
@@ -1091,7 +1120,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1091
1120
 
1092
1121
  const requiresDownMigrations = migrations.value.length > 0
1093
1122
 
1094
- const connect = async (msg: Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>) => {
1123
+ const connect = (msg: Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>) => {
1095
1124
  this.sessions.set(session.sessionId, {
1096
1125
  state: RoomSessionState.Connected,
1097
1126
  sessionId: session.sessionId,
@@ -1,5 +1,11 @@
1
1
  import { assert } from 'tldraw'
2
- import { JsonChunkAssembler, chunk } from './chunk'
2
+ import {
3
+ JsonChunkAssembler,
4
+ MAX_ASSEMBLED_MESSAGE_CHARS,
5
+ MAX_CHUNK_COUNT,
6
+ MessageTooLargeError,
7
+ chunk,
8
+ } from './chunk'
3
9
 
4
10
  describe('chunk (CH1–CH3)', () => {
5
11
  describe('size boundary (CH1)', () => {
@@ -403,4 +409,51 @@ describe('JsonChunkAssembler (CH4–CH8)', () => {
403
409
  })
404
410
  })
405
411
  })
412
+
413
+ describe('assembly bounds (CH10)', () => {
414
+ it('[CH10] rejects a first chunk declaring more than MAX_CHUNK_COUNT chunks', () => {
415
+ const unchunker = new JsonChunkAssembler()
416
+
417
+ const result = unchunker.handleMessage(`${MAX_CHUNK_COUNT}_{"a":`)
418
+ expect(result).toMatchObject({
419
+ error: expect.objectContaining({ message: `Too many chunks: ${MAX_CHUNK_COUNT + 1}` }),
420
+ })
421
+ expect(unchunker.state).toBe('idle')
422
+ })
423
+
424
+ it('[CH10] accepts a declared count at the limit', () => {
425
+ const unchunker = new JsonChunkAssembler()
426
+
427
+ expect(unchunker.handleMessage(`${MAX_CHUNK_COUNT - 1}_{"a":`)).toBeNull()
428
+ expect(unchunker.state).not.toBe('idle')
429
+ })
430
+
431
+ it('[CH10] rejects an assembly once its accumulated bodies pass MAX_ASSEMBLED_MESSAGE_CHARS', () => {
432
+ const unchunker = new JsonChunkAssembler()
433
+ // A chunk stream that never sends its final chunk. Before CH10 this accumulated
434
+ // without limit for as long as the sender kept sending.
435
+ const body = 'x'.repeat(1024 * 1024)
436
+ const chunksNeeded = Math.ceil(MAX_ASSEMBLED_MESSAGE_CHARS / body.length) + 1
437
+
438
+ let result = null
439
+ for (let i = 0; i < chunksNeeded; i++) {
440
+ result = unchunker.handleMessage(`${chunksNeeded - i}_${body}`)
441
+ if (result) break
442
+ }
443
+
444
+ expect(result).toMatchObject({ error: expect.any(MessageTooLargeError) })
445
+ expect(unchunker.state).toBe('idle')
446
+ })
447
+
448
+ it('[CH10] leaves the assembler usable after rejecting an oversized assembly', () => {
449
+ const unchunker = new JsonChunkAssembler()
450
+ const body = 'x'.repeat(1024 * 1024)
451
+ const chunksNeeded = Math.ceil(MAX_ASSEMBLED_MESSAGE_CHARS / body.length) + 1
452
+ for (let i = 0; i < chunksNeeded; i++) {
453
+ if (unchunker.handleMessage(`${chunksNeeded - i}_${body}`)) break
454
+ }
455
+
456
+ expect(unchunker.handleMessage('{"test": true}')).toMatchObject({ data: { test: true } })
457
+ })
458
+ })
406
459
  })
package/src/lib/chunk.ts CHANGED
@@ -8,6 +8,35 @@ const MAX_BYTES_PER_CHAR = 4
8
8
  // in the (admittedly impossible) worst case, the max size is 1/4 of a megabyte
9
9
  const MAX_SAFE_MESSAGE_SIZE = MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES / MAX_BYTES_PER_CHAR
10
10
 
11
+ // JsonChunkAssembler holds every chunk of a message until the last one arrives, and the sender
12
+ // decides how many that is — the count is just a prefix on the first chunk. Nothing in the
13
+ // protocol obliges a sender to ever send the last chunk, so without a ceiling a single connection
14
+ // can make the receiver hold an arbitrarily large buffer for as long as the socket stays open.
15
+ //
16
+ // The cap counts UTF-16 code units, which is what a JS string costs in memory: 16M of them is the
17
+ // 32MB of retained string data a room can afford to hold per session and stay inside a Durable
18
+ // Object's 128MB instance limit with room for the document itself. It is far above anything
19
+ // chunk() produces for a real document push. The character cap is the real bound; the chunk-count
20
+ // cap only rejects an absurd declared count on arrival instead of waiting for the characters to
21
+ // accumulate, and is loose enough not to constrain a sender that chunks more finely than the
22
+ // default.
23
+ export const MAX_ASSEMBLED_MESSAGE_CHARS = 16 * 1024 * 1024
24
+ export const MAX_CHUNK_COUNT = 100_000
25
+
26
+ /**
27
+ * Thrown when a message would exceed MAX_ASSEMBLED_MESSAGE_CHARS, on either side of the wire.
28
+ * Distinct from the other assembly errors because it is fatal: retrying sends the same
29
+ * oversized message, so the session is rejected rather than reset.
30
+ *
31
+ * @internal
32
+ */
33
+ export class MessageTooLargeError extends Error {
34
+ constructor(chars: number) {
35
+ super(`Message too large: ${chars} characters, max ${MAX_ASSEMBLED_MESSAGE_CHARS}`)
36
+ this.name = 'MessageTooLargeError'
37
+ }
38
+ }
39
+
11
40
  /**
12
41
  * Splits a string into smaller chunks suitable for transmission over WebSockets.
13
42
  * This function ensures messages don't exceed size limits imposed by platforms like Cloudflare Workers (1MB max).
@@ -100,6 +129,7 @@ export class JsonChunkAssembler {
100
129
  | {
101
130
  chunksReceived: string[]
102
131
  totalChunks: number
132
+ charsReceived: number
103
133
  } = 'idle'
104
134
 
105
135
  /**
@@ -110,7 +140,8 @@ export class JsonChunkAssembler {
110
140
  * @param msg - The message to process, either JSON or chunk format
111
141
  * @returns Result object with data/stringified on success, error object on failure, or null for incomplete chunks
112
142
  * - `\{ data: object, stringified: string \}` - Successfully parsed complete message
113
- * - `\{ error: Error \}` - Parse error or invalid chunk sequence
143
+ * - `\{ error: Error \}` - Parse error, invalid chunk sequence, or a message exceeding
144
+ * MAX_ASSEMBLED_MESSAGE_CHARS / MAX_CHUNK_COUNT
114
145
  * - `null` - Chunk received but more chunks expected
115
146
  *
116
147
  * @example
@@ -144,17 +175,30 @@ export class JsonChunkAssembler {
144
175
  const data = match[2]
145
176
 
146
177
  if (this.state === 'idle') {
178
+ const totalChunks = numChunksRemaining + 1
179
+ if (totalChunks > MAX_CHUNK_COUNT) {
180
+ // Nothing has been buffered yet, so the state is already idle.
181
+ return { error: new Error(`Too many chunks: ${totalChunks}`) }
182
+ }
147
183
  this.state = {
148
184
  chunksReceived: [data],
149
- totalChunks: numChunksRemaining + 1,
185
+ totalChunks,
186
+ charsReceived: data.length,
150
187
  }
151
188
  } else {
152
189
  this.state.chunksReceived.push(data)
190
+ this.state.charsReceived += data.length
153
191
  if (numChunksRemaining !== this.state.totalChunks - this.state.chunksReceived.length) {
154
192
  this.state = 'idle'
155
193
  return { error: new Error(`Chunks received in wrong order`) }
156
194
  }
157
195
  }
196
+ if (this.state.charsReceived > MAX_ASSEMBLED_MESSAGE_CHARS) {
197
+ // Drop what was buffered rather than keeping it until the socket closes.
198
+ const charsReceived = this.state.charsReceived
199
+ this.state = 'idle'
200
+ return { error: new MessageTooLargeError(charsReceived) }
201
+ }
158
202
  if (this.state.chunksReceived.length === this.state.totalChunks) {
159
203
  try {
160
204
  const stringified = this.state.chunksReceived.join('')
@@ -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)', () => {
@@ -2340,6 +2386,31 @@ describe('26. Session lifecycle (SES)', () => {
2340
2386
  expect(room.sessions.size).toBe(0)
2341
2387
  })
2342
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
+
2343
2414
  it('[HS2][SES5] sets supportsStringAppend to false for protocol version 7', () => {
2344
2415
  const { room } = makeRoom()
2345
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', () => {