@tldraw/sync-core 5.5.2 → 5.6.0-canary.0091c91994d6

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 +23 -8
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
  4. package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
  5. package/dist-cjs/lib/TLSocketRoom.js +40 -11
  6. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  7. package/dist-cjs/lib/TLSyncClient.js +5 -0
  8. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  9. package/dist-cjs/lib/TLSyncRoom.js +30 -44
  10. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncStorage.js +2 -2
  12. package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
  13. package/dist-cjs/lib/diff.js +15 -7
  14. package/dist-cjs/lib/diff.js.map +2 -2
  15. package/dist-esm/index.d.mts +23 -8
  16. package/dist-esm/index.mjs +1 -1
  17. package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
  18. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
  19. package/dist-esm/lib/TLSocketRoom.mjs +40 -11
  20. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  21. package/dist-esm/lib/TLSyncClient.mjs +5 -0
  22. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  23. package/dist-esm/lib/TLSyncRoom.mjs +30 -44
  24. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  25. package/dist-esm/lib/TLSyncStorage.mjs +2 -2
  26. package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
  27. package/dist-esm/lib/diff.mjs +16 -8
  28. package/dist-esm/lib/diff.mjs.map +2 -2
  29. package/package.json +18 -18
  30. package/src/lib/InMemorySyncStorage.ts +2 -2
  31. package/src/lib/SQLiteSyncStorage.ts +2 -2
  32. package/src/lib/TLSocketRoom.ts +50 -11
  33. package/src/lib/TLSyncClient.test.ts +61 -0
  34. package/src/lib/TLSyncClient.ts +9 -0
  35. package/src/lib/TLSyncRoom.ts +51 -48
  36. package/src/lib/TLSyncStorage.ts +7 -2
  37. package/src/lib/diff.test.ts +91 -1
  38. package/src/lib/diff.ts +30 -9
  39. package/src/test/TLSocketRoom.test.ts +132 -1
  40. package/src/test/TLSyncRoom.test.ts +124 -0
  41. package/src/test/upgradeDowngrade.test.ts +21 -0
@@ -146,13 +146,18 @@ export interface TLSyncForwardDiff<R extends UnknownRecord> {
146
146
  }
147
147
 
148
148
  /**
149
+ * @param legacyAppendMode - When true, string appends are emitted as puts (see `diffRecord`); the
150
+ * room sets this whenever a connected client predates string-append support.
149
151
  * @internal
150
152
  */
151
- export function toNetworkDiff<R extends UnknownRecord>(diff: TLSyncForwardDiff<R>): NetworkDiff<R> {
153
+ export function toNetworkDiff<R extends UnknownRecord>(
154
+ diff: TLSyncForwardDiff<R>,
155
+ legacyAppendMode = false
156
+ ): NetworkDiff<R> {
152
157
  const networkDiff: NetworkDiff<R> = {}
153
158
  for (const [id, put] of objectMapEntriesIterable(diff.puts)) {
154
159
  if (Array.isArray(put)) {
155
- const patch = diffRecord(put[0], put[1])
160
+ const patch = diffRecord(put[0], put[1], legacyAppendMode)
156
161
  if (patch) {
157
162
  networkDiff[id] = [RecordOpType.Patch, patch]
158
163
  }
@@ -39,6 +39,52 @@ describe('computing diffs (D)', () => {
39
39
  expect(patch).toEqual({ c: [ValueOpType.Put, 3] })
40
40
  expect(applyObjectDiff(a, patch!)).toEqual(b)
41
41
  })
42
+
43
+ it('[D2] treats a key whose value is undefined as absent', () => {
44
+ // undefined does not survive JSON, so `['put', undefined]` would arrive as
45
+ // `['put', null]` and fail validators that only accept the key being missing
46
+ const present = { a: 1, props: { foo: 'x' } }
47
+ const explicitlyUndefined = { a: 1, props: { foo: undefined } }
48
+ const absent = { a: 1, props: {} }
49
+
50
+ expect(diffRecord(present, explicitlyUndefined)).toEqual({
51
+ props: [ValueOpType.Patch, { foo: [ValueOpType.Delete] }],
52
+ })
53
+ expect(diffRecord(explicitlyUndefined, present)).toEqual({
54
+ props: [ValueOpType.Patch, { foo: [ValueOpType.Put, 'x'] }],
55
+ })
56
+ expect(diffRecord(absent, explicitlyUndefined)).toBeNull()
57
+ expect(diffRecord(explicitlyUndefined, absent)).toBeNull()
58
+ expect(diffRecord({ a: undefined }, { a: undefined })).toBeNull()
59
+ })
60
+
61
+ it('[D2] only considers own keys, not Object.prototype members', () => {
62
+ const withCtor = { meta: { constructor: 1, toString: 2 } }
63
+ const without = { meta: {} }
64
+
65
+ expect(diffRecord(withCtor, without)).toEqual({
66
+ meta: [
67
+ ValueOpType.Patch,
68
+ { constructor: [ValueOpType.Delete], toString: [ValueOpType.Delete] },
69
+ ],
70
+ })
71
+ expect(diffRecord(without, withCtor)).toEqual({
72
+ meta: [
73
+ ValueOpType.Patch,
74
+ { constructor: [ValueOpType.Put, 1], toString: [ValueOpType.Put, 2] },
75
+ ],
76
+ })
77
+ })
78
+
79
+ it('[D2] ignores __proto__ keys, which applyObjectDiff would drop (AD8)', () => {
80
+ // JSON.parse creates '__proto__' as an own key; diffing it would store it on the server
81
+ // while peers receive an op they drop
82
+ const polluted = JSON.parse('{"__proto__":{"evil":1},"meta":{"__proto__":{"evil":1}}}')
83
+ const clean = { meta: {} }
84
+
85
+ expect(diffRecord(clean, polluted)).toBeNull()
86
+ expect(diffRecord(polluted, clean)).toBeNull()
87
+ })
42
88
  })
43
89
 
44
90
  describe('top-level keys vs props/meta (D3)', () => {
@@ -102,7 +148,8 @@ describe('computing diffs (D)', () => {
102
148
 
103
149
  const diff = diffRecord(prev, next)
104
150
  expect(diff).toBeTruthy()
105
- expect(diff!.optional).toEqual([ValueOpType.Put, undefined])
151
+ // undefined is not representable on the wire, so it reads as the key being absent (D2)
152
+ expect(diff!.optional).toEqual([ValueOpType.Delete])
106
153
  expect(diff!.nullable).toEqual([ValueOpType.Put, 'value'])
107
154
  })
108
155
  })
@@ -378,6 +425,25 @@ describe('computing diffs (D)', () => {
378
425
  arr: [ValueOpType.Patch, { '0': [ValueOpType.Put, null] }],
379
426
  })
380
427
  })
428
+
429
+ it('[D6] puts a changed index when an item switches between array and object', () => {
430
+ // index deletes and named puts don't apply cleanly across shapes, so a patch would
431
+ // leave the receiver with a sparse array / an object where an array is expected
432
+ const arrayItem = { meta: { items: [[1, 2]] } }
433
+ const objectItem = { meta: { items: [{ x: 1 }] } }
434
+
435
+ const toObject = diffRecord(arrayItem, objectItem)!
436
+ expect(toObject).toEqual({
437
+ meta: [
438
+ ValueOpType.Patch,
439
+ { items: [ValueOpType.Patch, { '0': [ValueOpType.Put, { x: 1 }] }] },
440
+ ],
441
+ })
442
+ expect(applyObjectDiff(arrayItem, toObject)).toEqual(objectItem)
443
+
444
+ const toArray = diffRecord(objectItem, arrayItem)!
445
+ expect(applyObjectDiff(objectItem, toArray)).toEqual(arrayItem)
446
+ })
381
447
  })
382
448
 
383
449
  describe('different-length arrays (D7)', () => {
@@ -747,6 +813,30 @@ describe('applying diffs (AD)', () => {
747
813
 
748
814
  expect(applyObjectDiff(obj, diff)).toBe(obj)
749
815
  })
816
+
817
+ it('[AD5] deleting an inherited key has no effect', () => {
818
+ const obj = { a: 1 }
819
+ // `toString` collides with Object.prototype's own member in the literal's type, hence the cast
820
+ const diff = { toString: [ValueOpType.Delete] } as unknown as ObjectDiff
821
+
822
+ expect(applyObjectDiff(obj, diff)).toBe(obj)
823
+ })
824
+ })
825
+
826
+ describe('untrusted keys (AD8)', () => {
827
+ it('[AD8] ignores __proto__ keys instead of changing the prototype', () => {
828
+ // JSON.parse produces '__proto__' as an own key, so a peer can put it in a diff
829
+ const obj = { a: 1, props: { b: 2 } }
830
+ const diff: ObjectDiff = JSON.parse(
831
+ '{"__proto__":["put",{"isAdmin":true}],"props":["patch",{"__proto__":["put",{"polluted":1}]}]}'
832
+ )
833
+
834
+ const result: any = applyObjectDiff(obj, diff)
835
+ expect(result).toBe(obj)
836
+ expect(Object.getPrototypeOf(result)).toBe(Object.prototype)
837
+ expect(result.isAdmin).toBeUndefined()
838
+ expect(result.props.polluted).toBeUndefined()
839
+ })
750
840
  })
751
841
 
752
842
  describe('patching non-objects (AD6)', () => {
package/src/lib/diff.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { RecordsDiff, UnknownRecord } from '@tldraw/store'
2
- import { isEqual, objectMapEntries, objectMapValues } from '@tldraw/utils'
2
+ import { hasOwnProperty, isEqual, objectMapEntries, objectMapValues } from '@tldraw/utils'
3
3
 
4
4
  /**
5
5
  * Constants representing the types of operations that can be applied to records in network diffs.
@@ -200,15 +200,21 @@ function diffObject(
200
200
  return null
201
201
  }
202
202
  let result: ObjectDiff | null = null
203
+ // Own-property checks throughout: `in` would see Object.prototype members, so a user key named
204
+ // e.g. 'constructor' would never diff as an add or delete. And a key whose value is `undefined`
205
+ // is treated as absent: `undefined` doesn't survive JSON, so `['put', undefined]` would arrive
206
+ // as `['put', null]` and fail validators that accept only the missing key.
203
207
  for (const key of Object.keys(prev)) {
204
- // if key is not in next then it was deleted
205
- if (!(key in next)) {
208
+ // `result['__proto__'] = op` would set the diff's prototype, and applyObjectDiff drops the key (AD8)
209
+ if (key === '__proto__') continue
210
+ const prevValue = (prev as any)[key]
211
+ if (prevValue === undefined) continue
212
+ const nextValue = hasOwnProperty(next, key) ? (next as any)[key] : undefined
213
+ if (nextValue === undefined) {
206
214
  if (!result) result = {}
207
215
  result[key] = [ValueOpType.Delete]
208
216
  continue
209
217
  }
210
- const prevValue = (prev as any)[key]
211
- const nextValue = (next as any)[key]
212
218
  if (
213
219
  nestedKeys?.has(key) ||
214
220
  (Array.isArray(prevValue) && Array.isArray(nextValue)) ||
@@ -226,10 +232,13 @@ function diffObject(
226
232
  }
227
233
  }
228
234
  for (const key of Object.keys(next)) {
235
+ if (key === '__proto__') continue
236
+ const nextValue = (next as any)[key]
237
+ if (nextValue === undefined) continue
229
238
  // if key is in next but not in prev then it was added
230
- if (!(key in prev)) {
239
+ if (!hasOwnProperty(prev, key) || (prev as any)[key] === undefined) {
231
240
  if (!result) result = {}
232
- result[key] = [ValueOpType.Put, (next as any)[key]]
241
+ result[key] = [ValueOpType.Put, nextValue]
233
242
  }
234
243
  }
235
244
  return result
@@ -245,7 +254,15 @@ function diffValue(valueA: unknown, valueB: unknown, legacyAppendMode: boolean):
245
254
  return [ValueOpType.Append, appendedText, valueA.length]
246
255
  }
247
256
  return [ValueOpType.Put, valueB]
248
- } else if (!valueA || !valueB || typeof valueA !== 'object' || typeof valueB !== 'object') {
257
+ } else if (
258
+ !valueA ||
259
+ !valueB ||
260
+ typeof valueA !== 'object' ||
261
+ typeof valueB !== 'object' ||
262
+ // an array on one side and a plain object on the other can't be expressed as a patch:
263
+ // index deletes and named puts don't apply cleanly to either shape
264
+ Array.isArray(valueA) !== Array.isArray(valueB)
265
+ ) {
249
266
  return isEqual(valueA, valueB) ? null : [ValueOpType.Put, valueB]
250
267
  } else {
251
268
  const diff = diffObject(valueA, valueB, undefined, legacyAppendMode)
@@ -348,6 +365,10 @@ export function applyObjectDiff<T extends object>(object: T, objectDiff: ObjectD
348
365
  }
349
366
  }
350
367
  for (const [key, op] of Object.entries(objectDiff)) {
368
+ // Diffs come off the wire from untrusted peers. `newObject['__proto__'] = v` would set the
369
+ // record's prototype (JSON.parse creates '__proto__' as an own key), so a client could give
370
+ // server-held records attacker-controlled inherited fields; skip such keys outright.
371
+ if (key === '__proto__') continue
351
372
  switch (op[0]) {
352
373
  case ValueOpType.Put: {
353
374
  const value = op[1]
@@ -383,7 +404,7 @@ export function applyObjectDiff<T extends object>(object: T, objectDiff: ObjectD
383
404
  break
384
405
  }
385
406
  case ValueOpType.Delete: {
386
- if (key in object) {
407
+ if (hasOwnProperty(object, key)) {
387
408
  if (!newObject) {
388
409
  if (isArray) {
389
410
  console.error("Can't delete array item yet (this should never happen)")
@@ -21,7 +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
+ import { chunk, MAX_ASSEMBLED_MESSAGE_CHARS } from '../lib/chunk'
25
25
  import { RecordOpType } from '../lib/diff'
26
26
  import { DEFAULT_INITIAL_SNAPSHOT, InMemorySyncStorage } from '../lib/InMemorySyncStorage'
27
27
  import { getTlsyncProtocolVersion } from '../lib/protocol'
@@ -454,6 +454,75 @@ describe('28. TLSocketRoom (SR)', () => {
454
454
  expect(room.getSessions()).toHaveLength(0)
455
455
  })
456
456
 
457
+ it('[SR5] ignores chunks from a superseded socket so they do not corrupt the replacement socket', () => {
458
+ const log: TLSyncLog = { warn: vi.fn(), error: vi.fn() }
459
+ const room = new TLSocketRoom({ log })
460
+ const oldSocket = createMockSocket()
461
+ connectSession(room, 'S', oldSocket)
462
+ // the client reconnects under the same session id while the old socket is still draining
463
+ const newSocket = createMockSocket()
464
+ connectSession(room, 'S', newSocket)
465
+ vi.mocked(newSocket.send).mockClear()
466
+
467
+ const oldChunks = chunk(JSON.stringify({ type: 'ping' }), 4)
468
+ const newChunks = chunk(JSON.stringify({ type: 'ping' }), 5)
469
+ expect(oldChunks.length).toBeGreaterThan(1)
470
+ expect(newChunks.length).toBeGreaterThan(1)
471
+ // interleave the two sockets' chunk sequences, as a hibernation host delivers them
472
+ for (let i = 0; i < Math.max(oldChunks.length, newChunks.length); i++) {
473
+ if (oldChunks[i]) room.handleSocketMessage('S', oldChunks[i], oldSocket)
474
+ if (newChunks[i]) room.handleSocketMessage('S', newChunks[i], newSocket)
475
+ }
476
+ // a lone complete message from the old socket mid-sequence is ignored too
477
+ room.handleSocketMessage('S', JSON.stringify({ type: 'ping' }), oldSocket)
478
+
479
+ expect(log.error).not.toHaveBeenCalled()
480
+ expect(newSocket.close).not.toHaveBeenCalled()
481
+ expect(room.getSessions()).toEqual([
482
+ expect.objectContaining({ sessionId: 'S', isConnected: true }),
483
+ ])
484
+ expect(vi.mocked(newSocket.send).mock.calls.map((c) => JSON.parse(c[0]).type)).toEqual([
485
+ 'pong',
486
+ ])
487
+ })
488
+
489
+ it("[SR4][SR5] the listeners attached by handleSocketConnect drop a superseded socket's messages", () => {
490
+ function createListeningSocket() {
491
+ const listeners: Record<string, ((e: any) => void)[]> = {}
492
+ const socket: WebSocketMinimal & { fire(type: string, data: string): void } = {
493
+ send: vi.fn(),
494
+ close: vi.fn(),
495
+ readyState: WebSocket.OPEN,
496
+ addEventListener: vi.fn((type: string, fn: (e: any) => void) => {
497
+ ;(listeners[type] ??= []).push(fn)
498
+ }),
499
+ removeEventListener: vi.fn(),
500
+ fire(type, data) {
501
+ for (const fn of listeners[type] ?? []) fn({ data })
502
+ },
503
+ }
504
+ return socket
505
+ }
506
+ const log: TLSyncLog = { warn: vi.fn(), error: vi.fn() }
507
+ const room = new TLSocketRoom({ log })
508
+ const oldSocket = createListeningSocket()
509
+ connectSession(room, 'S', oldSocket)
510
+ const newSocket = createListeningSocket()
511
+ connectSession(room, 'S', newSocket)
512
+
513
+ const [first, ...rest] = chunk(JSON.stringify({ type: 'ping' }), 5)
514
+ newSocket.fire('message', first)
515
+ // the old socket's tail chunk would otherwise be assembled onto the new socket's sequence
516
+ oldSocket.fire('message', '0_kPAAAjzgAA')
517
+ for (const c of rest) newSocket.fire('message', c)
518
+
519
+ expect(log.error).not.toHaveBeenCalled()
520
+ expect(newSocket.close).not.toHaveBeenCalled()
521
+ expect(vi.mocked(newSocket.send).mock.calls.map((c) => JSON.parse(c[0]).type)).toContain(
522
+ 'pong'
523
+ )
524
+ })
525
+
457
526
  it('[SR5] warns when receiving a message from an unknown session', () => {
458
527
  const log: TLSyncLog = { warn: vi.fn(), error: vi.fn() }
459
528
  const room = new TLSocketRoom({ log })
@@ -585,6 +654,68 @@ describe('28. TLSocketRoom (SR)', () => {
585
654
  expect(room.getSessions()[0].isConnected).toBe(false)
586
655
  expect(socket.close).toHaveBeenCalled()
587
656
  })
657
+
658
+ it.each(['handleSocketClose', 'handleSocketError'] as const)(
659
+ "[SR6][SR12] %s ignores a superseded socket's late event after a same-id reconnect",
660
+ (method) => {
661
+ vi.useFakeTimers()
662
+ try {
663
+ const onSessionSnapshot = vi.fn()
664
+ const room = new TLSocketRoom({ onSessionSnapshot })
665
+ const oldSocket = createMockSocket()
666
+ connectSession(room, 'S', oldSocket)
667
+ const newSocket = createMockSocket()
668
+ connectSession(room, 'S', newSocket)
669
+
670
+ room[method]('S', oldSocket)
671
+
672
+ expect(room.getSessions()).toEqual([
673
+ expect.objectContaining({ sessionId: 'S', isConnected: true }),
674
+ ])
675
+ expect(newSocket.close).not.toHaveBeenCalled()
676
+ // the live session's pending snapshot survives the superseded event
677
+ vi.advanceTimersByTime(5100)
678
+ expect(onSessionSnapshot).toHaveBeenCalledTimes(1)
679
+
680
+ room[method]('S', newSocket)
681
+ expect(room.getSessions()[0].isConnected).toBe(false)
682
+ } finally {
683
+ vi.useRealTimers()
684
+ }
685
+ }
686
+ )
687
+
688
+ it("[SR4][SR6] a reconnect detaches the previous socket's listeners", () => {
689
+ const listeners = new Map<string, (e: any) => void>()
690
+ const oldSocket = createMockSocket({
691
+ addEventListener: vi.fn((type: string, fn: (e: any) => void) => listeners.set(type, fn)),
692
+ removeEventListener: vi.fn((type: string, fn: (e: any) => void) => {
693
+ if (listeners.get(type) === fn) listeners.delete(type)
694
+ }),
695
+ })
696
+ connectSession(room, 'S', oldSocket)
697
+ expect([...listeners.keys()].sort()).toEqual(['close', 'error', 'message'])
698
+
699
+ connectSession(room, 'S', createMockSocket())
700
+
701
+ expect(listeners.size).toBe(0)
702
+ })
703
+
704
+ it.each(['close', 'error'] as const)(
705
+ '[SR4][SR6] firing the attached %s listener cancels the session',
706
+ (type) => {
707
+ const listeners = new Map<string, (e: any) => void>()
708
+ const socket = createMockSocket({
709
+ addEventListener: vi.fn((t: string, fn: (e: any) => void) => listeners.set(t, fn)),
710
+ })
711
+ connectSession(room, 'S', socket)
712
+
713
+ listeners.get(type)!(new Event(type))
714
+
715
+ expect(room.getSessions()[0].isConnected).toBe(false)
716
+ expect(socket.close).toHaveBeenCalled()
717
+ }
718
+ )
588
719
  })
589
720
 
590
721
  describe('session reporting', () => {
@@ -2482,6 +2482,130 @@ describe('26. Session lifecycle (SES)', () => {
2482
2482
  expect(socket.__lastMessage).toEqual({ type: 'pong' })
2483
2483
  })
2484
2484
 
2485
+ it('[SES5] no session receives string-append ops while a client without append support is connected', () => {
2486
+ vi.useFakeTimers()
2487
+ const { room } = makeRoom()
2488
+ const v8 = connectSession(room, 'v8', { protocolVersion: getTlsyncProtocolVersion() })
2489
+ const v7 = connectSession(room, 'v7', { protocolVersion: 7 })
2490
+ expect(room.getCanEmitStringAppend()).toBe(false)
2491
+
2492
+ const hasAppend = (msg: any) => JSON.stringify(msg).includes('"append"')
2493
+
2494
+ // a full put over an existing record whose only change is a string append
2495
+ room.handleMessage('v8', {
2496
+ type: 'push',
2497
+ clientClock: 1,
2498
+ diff: { [pageRecord.id]: ['put', { ...pageRecord, name: pageRecord.name + ' more' }] },
2499
+ } as TLPushRequest<TLRecord>)
2500
+ vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
2501
+ expect(sentDataMessages(v7)).toEqual([
2502
+ {
2503
+ type: 'patch',
2504
+ diff: { [pageRecord.id]: ['patch', { name: ['put', pageRecord.name + ' more'] }] },
2505
+ serverClock: 1,
2506
+ },
2507
+ ])
2508
+ expect(sentDataMessages(v8).some(hasAppend)).toBe(false)
2509
+ clearSocket(v7)
2510
+ clearSocket(v8)
2511
+
2512
+ // a patch whose recomputed broadcast would otherwise be an append
2513
+ room.handleMessage('v8', {
2514
+ type: 'push',
2515
+ clientClock: 2,
2516
+ diff: { [pageRecord.id]: ['patch', { name: ['put', pageRecord.name + ' more and more'] }] },
2517
+ } as TLPushRequest<TLRecord>)
2518
+ vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
2519
+ expect(sentDataMessages(v7)).toEqual([
2520
+ {
2521
+ type: 'patch',
2522
+ diff: {
2523
+ [pageRecord.id]: ['patch', { name: ['put', pageRecord.name + ' more and more'] }],
2524
+ },
2525
+ serverClock: 2,
2526
+ },
2527
+ ])
2528
+ expect(sentDataMessages(v8)).toEqual([
2529
+ { type: 'push_result', clientClock: 2, serverClock: 2, action: 'commit' },
2530
+ ])
2531
+ })
2532
+
2533
+ it('[SES6] handleResumedSession rejects a session whose schema the server can no longer reconcile', () => {
2534
+ const { room } = makeRoom()
2535
+ const socket = makeSocket()
2536
+ const newerSchema: SerializedSchemaV2 = {
2537
+ schemaVersion: 2,
2538
+ sequences: {
2539
+ ...(room.serializedSchema as SerializedSchemaV2).sequences,
2540
+ 'com.tldraw.store': 999,
2541
+ },
2542
+ }
2543
+
2544
+ room.handleResumedSession({
2545
+ sessionId: 'resumed',
2546
+ socket,
2547
+ meta: undefined,
2548
+ isReadonly: false,
2549
+ serializedSchema: newerSchema,
2550
+ presenceId: null,
2551
+ presenceRecord: null,
2552
+ requiresLegacyRejection: false,
2553
+ supportsStringAppend: true,
2554
+ })
2555
+
2556
+ expect(room.sessions.has('resumed')).toBe(false)
2557
+ expect(socket.close).toHaveBeenCalledWith(
2558
+ TLSyncErrorCloseEventCode,
2559
+ TLSyncErrorCloseEventReason.SERVER_TOO_OLD
2560
+ )
2561
+ })
2562
+
2563
+ it('[SES6] rejecting a resumed session removes its restored presence for other sessions', () => {
2564
+ vi.useFakeTimers()
2565
+ const { room } = makeRoom()
2566
+ const makePresence = (name: string) =>
2567
+ InstancePresenceRecordType.create({
2568
+ id: InstancePresenceRecordType.createId(name),
2569
+ currentPageId: pageRecord.id,
2570
+ userId: createUserId(name),
2571
+ userName: name,
2572
+ })
2573
+ const resume = (sessionId: string, serializedSchema: SerializedSchema) => {
2574
+ const socket = makeSocket()
2575
+ const presenceRecord = makePresence(sessionId)
2576
+ room.handleResumedSession({
2577
+ sessionId,
2578
+ socket,
2579
+ meta: undefined,
2580
+ isReadonly: false,
2581
+ serializedSchema,
2582
+ presenceId: presenceRecord.id,
2583
+ presenceRecord,
2584
+ requiresLegacyRejection: false,
2585
+ supportsStringAppend: true,
2586
+ })
2587
+ return { socket, presenceId: presenceRecord.id }
2588
+ }
2589
+
2590
+ const other = resume('other', room.serializedSchema)
2591
+ const rejected = resume('rejected', {
2592
+ schemaVersion: 2,
2593
+ sequences: {
2594
+ ...(room.serializedSchema as SerializedSchemaV2).sequences,
2595
+ 'com.tldraw.store': 999,
2596
+ },
2597
+ })
2598
+ vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL * 2)
2599
+
2600
+ expect(room.sessions.has('rejected')).toBe(false)
2601
+ expect(room.presenceStore.get(rejected.presenceId)).toBeUndefined()
2602
+ // other clients still hold this presence from before the socket slept
2603
+ expect(sentDataMessages(other.socket).at(-1)).toMatchObject({
2604
+ type: 'patch',
2605
+ diff: { [rejected.presenceId]: [RecordOpType.Remove] },
2606
+ })
2607
+ })
2608
+
2485
2609
  it('[SES7] a message from an unknown session id logs a warning and is ignored', () => {
2486
2610
  const warn = vi.fn()
2487
2611
  const { room } = makeRoom({ log: { warn } })
@@ -413,6 +413,27 @@ it('[HS3] out-of-date clients will receive incompatibility errors', () => {
413
413
  expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
414
414
  })
415
415
 
416
+ it.each([
417
+ ['a v2 schema without sequences', { schemaVersion: 2 }],
418
+ ['a v2 schema with null sequences', { schemaVersion: 2, sequences: null }],
419
+ ['a null schema', null],
420
+ ])('[HS3] clients sending %s are rejected as too old', (_, schema) => {
421
+ const server = new TestServer(schemaV3)
422
+ const id = 'test_malformed_schema'
423
+ const socket = mockSocket()
424
+
425
+ server.room.handleNewSession({ sessionId: id, socket, meta: undefined, isReadonly: false })
426
+ server.room.handleMessage(id, {
427
+ type: 'connect',
428
+ connectRequestId: 'test',
429
+ lastServerClock: 0,
430
+ protocolVersion: getTlsyncProtocolVersion(),
431
+ schema: schema as any,
432
+ })
433
+
434
+ expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
435
+ })
436
+
416
437
  it('[HS2] clients using an out-of-date protocol will receive compatibility errors', () => {
417
438
  const actualVersion = getTlsyncProtocolVersion()
418
439
  mockGetTlsyncProtocolVersion.mockReturnValue(actualVersion + 1)