@tldraw/sync-core 5.3.0-canary.ecf14a605aef → 5.3.0-internal.d205573d66cb

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 (51) hide show
  1. package/dist-cjs/index.d.ts +76 -3
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/InMemorySyncStorage.js +55 -20
  5. package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
  6. package/dist-cjs/lib/RoomSession.js.map +1 -1
  7. package/dist-cjs/lib/SQLiteSyncStorage.js +115 -52
  8. package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
  9. package/dist-cjs/lib/TLSocketRoom.js +26 -2
  10. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncClient.js +6 -1
  12. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  13. package/dist-cjs/lib/TLSyncRoom.js +43 -5
  14. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  15. package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
  16. package/dist-cjs/lib/protocol.js.map +2 -2
  17. package/dist-esm/index.d.mts +76 -3
  18. package/dist-esm/index.mjs +1 -1
  19. package/dist-esm/index.mjs.map +2 -2
  20. package/dist-esm/lib/InMemorySyncStorage.mjs +55 -20
  21. package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
  22. package/dist-esm/lib/RoomSession.mjs.map +1 -1
  23. package/dist-esm/lib/SQLiteSyncStorage.mjs +115 -52
  24. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
  25. package/dist-esm/lib/TLSocketRoom.mjs +26 -2
  26. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  27. package/dist-esm/lib/TLSyncClient.mjs +6 -1
  28. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  29. package/dist-esm/lib/TLSyncRoom.mjs +43 -5
  30. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  31. package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
  32. package/dist-esm/lib/protocol.mjs.map +2 -2
  33. package/package.json +6 -6
  34. package/src/index.ts +1 -0
  35. package/src/lib/InMemorySyncStorage.ts +74 -21
  36. package/src/lib/RoomSession.ts +7 -2
  37. package/src/lib/SQLiteSyncStorage.test.ts +29 -2
  38. package/src/lib/SQLiteSyncStorage.ts +158 -59
  39. package/src/lib/TLSocketRoom.ts +53 -2
  40. package/src/lib/TLSyncClient.test.ts +9 -2
  41. package/src/lib/TLSyncClient.ts +15 -3
  42. package/src/lib/TLSyncRoom.ts +76 -4
  43. package/src/lib/TLSyncStorage.ts +8 -0
  44. package/src/lib/protocol.ts +17 -0
  45. package/src/test/TLSocketRoom.test.ts +37 -2
  46. package/src/test/TLSyncClientRebase.test.ts +285 -0
  47. package/src/test/TLSyncRoom.test.ts +25 -0
  48. package/src/test/TestServer.ts +14 -4
  49. package/src/test/objectStore.test.ts +387 -0
  50. package/src/test/storageContractSuite.ts +79 -0
  51. package/src/test/upgradeDowngrade.test.ts +47 -0
@@ -0,0 +1,387 @@
1
+ import { BaseRecord, RecordId, StoreSchema, createRecordType } from '@tldraw/store'
2
+ import { vi, type Mock } from 'vitest'
3
+ import { RecordOpType, ValueOpType } from '../lib/diff'
4
+ import { InMemorySyncStorage } from '../lib/InMemorySyncStorage'
5
+ import {
6
+ TLConnectRequest,
7
+ TLPushRequest,
8
+ TLSocketServerSentEvent,
9
+ getTlsyncProtocolVersion,
10
+ } from '../lib/protocol'
11
+ import { TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from '../lib/TLSyncClient'
12
+ import { RoomSnapshot, TLRoomSocket, TLSyncRoom } from '../lib/TLSyncRoom'
13
+
14
+ // A generic object-lane test schema: `doc` records live in the document lane, `note` records are
15
+ // served through the object-store lane (see `objectTypes` below). Deliberately not comment-shaped —
16
+ // the lane is a generic primitive.
17
+
18
+ interface DocRecord extends BaseRecord<'doc', RecordId<DocRecord>> {
19
+ title: string
20
+ }
21
+ interface NoteRecord extends BaseRecord<'note', RecordId<NoteRecord>> {
22
+ text: string
23
+ }
24
+ interface PresenceRecord extends BaseRecord<'presence', RecordId<PresenceRecord>> {
25
+ name: string
26
+ }
27
+
28
+ const Doc = createRecordType<DocRecord>('doc', {
29
+ scope: 'document',
30
+ validator: { validate: (value) => value as DocRecord },
31
+ })
32
+ const Note = createRecordType<NoteRecord>('note', {
33
+ scope: 'document',
34
+ validator: { validate: (value) => value as NoteRecord },
35
+ })
36
+ const Presence = createRecordType<PresenceRecord>('presence', {
37
+ scope: 'presence',
38
+ validator: { validate: (value) => value as PresenceRecord },
39
+ })
40
+
41
+ type R = DocRecord | NoteRecord | PresenceRecord
42
+
43
+ const schema = StoreSchema.create<R>({ doc: Doc, note: Note, presence: Presence })
44
+
45
+ type MockSocket = TLRoomSocket<any> & {
46
+ __messages: TLSocketServerSentEvent<any>[]
47
+ sendMessage: Mock
48
+ close: Mock
49
+ }
50
+
51
+ function makeSocket(): MockSocket {
52
+ const socket: MockSocket = {
53
+ __messages: [],
54
+ // cloning because the room reuses/clears message objects after sending
55
+ sendMessage: vi.fn((msg: TLSocketServerSentEvent<any>) => {
56
+ socket.__messages.push(structuredClone(msg))
57
+ }),
58
+ close: vi.fn(() => {
59
+ socket.isOpen = false
60
+ }),
61
+ isOpen: true,
62
+ }
63
+ return socket
64
+ }
65
+
66
+ function sentDataMessages(socket: MockSocket): any[] {
67
+ return socket.__messages.flatMap((msg: any) => (msg.type === 'data' ? msg.data : []))
68
+ }
69
+
70
+ function clearSocket(socket: MockSocket) {
71
+ socket.sendMessage.mockClear()
72
+ socket.__messages.length = 0
73
+ }
74
+
75
+ const disposables: Array<() => void> = []
76
+ afterEach(() => {
77
+ for (const dispose of disposables) dispose()
78
+ disposables.length = 0
79
+ })
80
+
81
+ function makeRoom(
82
+ opts: {
83
+ snapshot?: RoomSnapshot
84
+ objectTypes?: readonly string[]
85
+ onCommittedChanges?(args: { diff: any; documentClock: number }): void
86
+ } = {}
87
+ ) {
88
+ const storage = new InMemorySyncStorage<R>({
89
+ snapshot: opts.snapshot ?? {
90
+ documents: [],
91
+ clock: 0,
92
+ documentClock: 0,
93
+ schema: schema.serialize(),
94
+ },
95
+ // keep the storage partition in step with the room's object lane
96
+ objectTypes: opts.objectTypes,
97
+ })
98
+ const room = new TLSyncRoom<R, undefined>({
99
+ schema,
100
+ storage,
101
+ objectTypes: opts.objectTypes,
102
+ onCommittedChanges: opts.onCommittedChanges,
103
+ })
104
+ disposables.push(() => room.close())
105
+ return { storage, room }
106
+ }
107
+
108
+ function connectSession(
109
+ room: TLSyncRoom<any, any>,
110
+ sessionId: string,
111
+ opts: {
112
+ isReadonly?: boolean
113
+ objectAccess?: 'read' | 'write'
114
+ clear?: boolean
115
+ } = {}
116
+ ): MockSocket {
117
+ const socket = makeSocket()
118
+ room.handleNewSession({
119
+ sessionId,
120
+ socket,
121
+ meta: undefined,
122
+ isReadonly: opts.isReadonly ?? false,
123
+ objectAccess: opts.objectAccess,
124
+ })
125
+ room.handleMessage(sessionId, {
126
+ type: 'connect',
127
+ connectRequestId: 'connect-' + sessionId,
128
+ lastServerClock: 0,
129
+ protocolVersion: getTlsyncProtocolVersion(),
130
+ schema: room.serializedSchema,
131
+ } satisfies TLConnectRequest)
132
+ if (opts.clear !== false) {
133
+ clearSocket(socket)
134
+ }
135
+ return socket
136
+ }
137
+
138
+ function push(
139
+ room: TLSyncRoom<any, any>,
140
+ sessionId: string,
141
+ diff: TLPushRequest<any>['diff'],
142
+ clientClock = 1
143
+ ) {
144
+ room.handleMessage(sessionId, { type: 'push', clientClock, diff } satisfies TLPushRequest<any>)
145
+ }
146
+
147
+ function lastPushResult(socket: MockSocket) {
148
+ return sentDataMessages(socket)
149
+ .filter((m) => m.type === 'push_result')
150
+ .at(-1)
151
+ }
152
+
153
+ function storedIds(storage: InMemorySyncStorage<R>) {
154
+ // span both partitions: the document snapshot no longer contains object-lane records
155
+ return [...storage.getSnapshot().documents, ...storage.getObjectsSnapshot()]
156
+ .map((d) => d.state.id)
157
+ .sort()
158
+ }
159
+
160
+ describe('object store lane', () => {
161
+ describe('type partition', () => {
162
+ it('excludes object types from documentTypes and exposes them via objectTypes', () => {
163
+ const { room } = makeRoom({ objectTypes: ['note'] })
164
+ expect(room.documentTypes.has('doc')).toBe(true)
165
+ expect(room.documentTypes.has('note')).toBe(false)
166
+ expect(room.objectTypes.has('note')).toBe(true)
167
+ })
168
+
169
+ it('keeps documentTypes intact when no objectTypes are configured', () => {
170
+ const { room } = makeRoom()
171
+ expect(room.documentTypes.has('doc')).toBe(true)
172
+ expect(room.documentTypes.has('note')).toBe(true)
173
+ expect(room.objectTypes.size).toBe(0)
174
+ })
175
+
176
+ it('rejects object types that are not registered in the schema', () => {
177
+ expect(() => makeRoom({ objectTypes: ['nonexistent'] })).toThrow(
178
+ "object type 'nonexistent' is not registered"
179
+ )
180
+ })
181
+
182
+ it('rejects object types that are not document-scoped', () => {
183
+ expect(() => makeRoom({ objectTypes: ['presence'] })).toThrow(
184
+ "object type 'presence' must have scope 'document'"
185
+ )
186
+ })
187
+ })
188
+
189
+ describe('object writes', () => {
190
+ it('commits object puts, broadcasts them, and hydrates them on connect', () => {
191
+ const { room, storage } = makeRoom({ objectTypes: ['note'] })
192
+ const writer = connectSession(room, 'writer')
193
+ const observer = connectSession(room, 'observer')
194
+
195
+ const note = Note.create({ text: 'hello' })
196
+ push(room, 'writer', { [note.id]: [RecordOpType.Put, note] })
197
+
198
+ expect(lastPushResult(writer)).toMatchObject({ action: 'commit' })
199
+ expect(storedIds(storage)).toEqual([note.id])
200
+
201
+ // broadcast to the other session
202
+ room._flushDataMessages('observer')
203
+ expect(
204
+ sentDataMessages(observer).find(
205
+ (m) => m.type === 'patch' && m.diff[note.id]?.[0] === RecordOpType.Put
206
+ )
207
+ ).toBeDefined()
208
+
209
+ // a fresh connect hydrates the object record
210
+ const late = connectSession(room, 'late', { clear: false })
211
+ const connectMsg = late.__messages.find((m: any) => m.type === 'connect') as any
212
+ expect(connectMsg.diff[note.id]).toEqual([RecordOpType.Put, note])
213
+ })
214
+
215
+ it('still rejects unknown record types for writable sessions', () => {
216
+ const { room } = makeRoom({ objectTypes: ['note'] })
217
+ const socket = connectSession(room, 'writer')
218
+
219
+ push(room, 'writer', {
220
+ 'mystery:1': [RecordOpType.Put, { id: 'mystery:1', typeName: 'mystery' } as any],
221
+ })
222
+
223
+ expect(socket.close).toHaveBeenCalledWith(
224
+ TLSyncErrorCloseEventCode,
225
+ TLSyncErrorCloseEventReason.INVALID_RECORD
226
+ )
227
+ })
228
+
229
+ it('skips unknown record types for denied sessions without rejecting (legacy readonly behavior)', () => {
230
+ const { room, storage } = makeRoom({ objectTypes: ['note'] })
231
+ const socket = connectSession(room, 'reader', { isReadonly: true })
232
+
233
+ push(room, 'reader', {
234
+ 'mystery:1': [RecordOpType.Put, { id: 'mystery:1', typeName: 'mystery' } as any],
235
+ })
236
+
237
+ expect(socket.close).not.toHaveBeenCalled()
238
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
239
+ expect(storedIds(storage)).toEqual([])
240
+ })
241
+ })
242
+
243
+ describe('permission axes', () => {
244
+ it('lets a readonly session with object write access write objects but not documents', () => {
245
+ const { room, storage } = makeRoom({ objectTypes: ['note'] })
246
+ // "can comment but not edit"
247
+ const socket = connectSession(room, 'commenter', {
248
+ isReadonly: true,
249
+ objectAccess: 'write',
250
+ })
251
+
252
+ const doc = Doc.create({ title: 'nope' })
253
+ const note = Note.create({ text: 'yep' })
254
+ push(room, 'commenter', {
255
+ [doc.id]: [RecordOpType.Put, doc],
256
+ [note.id]: [RecordOpType.Put, note],
257
+ })
258
+
259
+ // only the object op survives, so the client is rebased onto it
260
+ expect(lastPushResult(socket)).toMatchObject({
261
+ action: { rebaseWithDiff: { [note.id]: [RecordOpType.Put, note] } },
262
+ })
263
+ expect(storedIds(storage)).toEqual([note.id])
264
+ })
265
+
266
+ it('blocks object writes for objectAccess: read while document writes still commit', () => {
267
+ const { room, storage } = makeRoom({ objectTypes: ['note'] })
268
+ const socket = connectSession(room, 'editor', { objectAccess: 'read' })
269
+
270
+ const doc = Doc.create({ title: 'yep' })
271
+ const note = Note.create({ text: 'nope' })
272
+ push(room, 'editor', {
273
+ [doc.id]: [RecordOpType.Put, doc],
274
+ [note.id]: [RecordOpType.Put, note],
275
+ })
276
+
277
+ expect(lastPushResult(socket)).toMatchObject({
278
+ action: { rebaseWithDiff: { [doc.id]: [RecordOpType.Put, doc] } },
279
+ })
280
+ expect(storedIds(storage)).toEqual([doc.id])
281
+ })
282
+
283
+ it('discards object-only pushes entirely for objectAccess: read', () => {
284
+ const { room, storage } = makeRoom({ objectTypes: ['note'] })
285
+ const socket = connectSession(room, 'reader', { objectAccess: 'read' })
286
+
287
+ const note = Note.create({ text: 'nope' })
288
+ push(room, 'reader', { [note.id]: [RecordOpType.Put, note] })
289
+
290
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
291
+ expect(storedIds(storage)).toEqual([])
292
+ })
293
+
294
+ it('gates object patches and removes by objectAccess', () => {
295
+ const note = Note.create({ text: 'original' })
296
+ const { room, storage } = makeRoom({
297
+ objectTypes: ['note'],
298
+ snapshot: {
299
+ documents: [{ state: note, lastChangedClock: 0 }],
300
+ clock: 0,
301
+ documentClock: 0,
302
+ schema: schema.serialize(),
303
+ },
304
+ })
305
+ const reader = connectSession(room, 'reader', { objectAccess: 'read' })
306
+
307
+ push(room, 'reader', {
308
+ [note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'tampered'] }],
309
+ })
310
+ expect(lastPushResult(reader)).toMatchObject({ action: 'discard' })
311
+
312
+ push(room, 'reader', { [note.id]: [RecordOpType.Remove] }, 2)
313
+ expect(lastPushResult(reader)).toMatchObject({ action: 'discard' })
314
+ expect(storedIds(storage)).toEqual([note.id])
315
+
316
+ // a readonly-but-object-writable session can remove the object record
317
+ connectSession(room, 'commenter', { isReadonly: true, objectAccess: 'write' })
318
+ push(room, 'commenter', { [note.id]: [RecordOpType.Remove] })
319
+ expect(storedIds(storage)).toEqual([])
320
+ })
321
+ })
322
+
323
+ describe('storage partition', () => {
324
+ it('keeps object records out of the document snapshot but in the objects snapshot', () => {
325
+ const { room, storage } = makeRoom({ objectTypes: ['note'] })
326
+ connectSession(room, 'writer')
327
+
328
+ const doc = Doc.create({ title: 'canvas' })
329
+ const note = Note.create({ text: 'annotation' })
330
+ push(room, 'writer', {
331
+ [doc.id]: [RecordOpType.Put, doc],
332
+ [note.id]: [RecordOpType.Put, note],
333
+ })
334
+
335
+ // the document snapshot is pure-document by construction
336
+ expect(storage.getSnapshot().documents.map((d) => d.state.id)).toEqual([doc.id])
337
+ // the object lane is persisted separately
338
+ expect(storage.getObjectsSnapshot().map((d) => d.state.id)).toEqual([note.id])
339
+
340
+ // but a fresh connect still hydrates both (shared clock + change feed)
341
+ const late = connectSession(room, 'late', { clear: false })
342
+ const connectMsg = late.__messages.find((m: any) => m.type === 'connect') as any
343
+ expect(Object.keys(connectMsg.diff).sort()).toEqual([doc.id, note.id].sort())
344
+ })
345
+ })
346
+
347
+ describe('connect message', () => {
348
+ it('carries the session objectAccess', () => {
349
+ const { room } = makeRoom({ objectTypes: ['note'] })
350
+
351
+ const writer = connectSession(room, 'writer', { clear: false })
352
+ const writerConnect = writer.__messages.find((m: any) => m.type === 'connect') as any
353
+ expect(writerConnect.objectAccess).toBe('write')
354
+
355
+ const reader = connectSession(room, 'reader', { objectAccess: 'read', clear: false })
356
+ const readerConnect = reader.__messages.find((m: any) => m.type === 'connect') as any
357
+ expect(readerConnect.objectAccess).toBe('read')
358
+ })
359
+ })
360
+
361
+ describe('onCommittedChanges projection hook', () => {
362
+ it('fires with the committed object diff', async () => {
363
+ const onCommittedChanges = vi.fn()
364
+ const { room } = makeRoom({ objectTypes: ['note'], onCommittedChanges })
365
+ connectSession(room, 'writer')
366
+
367
+ const note = Note.create({ text: 'hello' })
368
+ push(room, 'writer', { [note.id]: [RecordOpType.Put, note] })
369
+ await Promise.resolve() // the hook fires on a microtask
370
+
371
+ expect(onCommittedChanges).toHaveBeenCalledTimes(1)
372
+ expect(onCommittedChanges.mock.calls[0][0].diff.puts[note.id]).toEqual(note)
373
+ })
374
+
375
+ it('does not fire when every op in the push was denied', async () => {
376
+ const onCommittedChanges = vi.fn()
377
+ const { room } = makeRoom({ objectTypes: ['note'], onCommittedChanges })
378
+ connectSession(room, 'reader', { objectAccess: 'read' })
379
+
380
+ const note = Note.create({ text: 'nope' })
381
+ push(room, 'reader', { [note.id]: [RecordOpType.Put, note] })
382
+ await Promise.resolve()
383
+
384
+ expect(onCommittedChanges).not.toHaveBeenCalled()
385
+ })
386
+ })
387
+ })
@@ -41,6 +41,7 @@ export function makePage(id: string, name = id, index = 'a2') {
41
41
  export interface StorageContractFactory {
42
42
  create(opts?: {
43
43
  snapshot?: RoomSnapshot
44
+ objectTypes?: readonly string[]
44
45
  onChange?(arg: TLSyncStorageOnChangeCallbackProps): void
45
46
  }): TLSyncStorage<TLRecord>
46
47
  }
@@ -61,6 +62,84 @@ export function registerStorageContractTests(factory: StorageContractFactory) {
61
62
  expect(snapshot.documents.map((d) => d.lastChangedClock)).toEqual([0, 0])
62
63
  })
63
64
 
65
+ describe('object-store lane partition', () => {
66
+ // the storage doesn't care about record semantics, so 'page' stands in as a
67
+ // generic object-lane type here
68
+ const createPartitioned = (snapshot?: RoomSnapshot) =>
69
+ factory.create({ snapshot, objectTypes: ['page'] })
70
+
71
+ it('partitions object-lane records out of getSnapshot into getObjectsSnapshot', () => {
72
+ const storage = createPartitioned(
73
+ makeContractSnapshot(contractRecords, { documentClock: 5 })
74
+ )
75
+ const snapshot = storage.getSnapshot!()
76
+ expect(snapshot.documents.map((d) => d.state.typeName)).toEqual(['document'])
77
+ // clock + schema are unaffected by the partition
78
+ expect(snapshot.documentClock).toBe(5)
79
+
80
+ expect(storage.getObjectsSnapshot!().map((d) => d.state.typeName)).toEqual(['page'])
81
+ })
82
+
83
+ it('returns an empty objects snapshot when no objectTypes are configured', () => {
84
+ const storage = create(makeContractSnapshot(contractRecords))
85
+ expect(storage.getSnapshot!().documents.length).toBe(2)
86
+ expect(storage.getObjectsSnapshot!()).toEqual([])
87
+ })
88
+
89
+ it('routes writes by type and shares the clock, tombstones, and change feed', () => {
90
+ const storage = createPartitioned(makeContractSnapshot(contractRecords))
91
+ const newPage = makePage('lane')
92
+ storage.transaction((txn) => txn.set(newPage.id, newPage))
93
+
94
+ // the write landed in the object partition, not the document snapshot
95
+ expect(storage.getSnapshot!().documents.map((d) => d.state.id)).toEqual([TLDOCUMENT_ID])
96
+ expect(storage.getObjectsSnapshot!().map((d) => d.state.id)).toContain(newPage.id)
97
+
98
+ // reads and the change feed span both partitions
99
+ storage.transaction((txn) => {
100
+ expect(txn.get(newPage.id)).toEqual(newPage)
101
+ expect(txn.getChangesSince(0)!.diff.puts[newPage.id]).toEqual(newPage)
102
+ })
103
+
104
+ // deleting an object-lane record writes a shared tombstone
105
+ const clockBeforeDelete = storage.getClock()
106
+ storage.transaction((txn) => txn.delete(newPage.id))
107
+ expect(storage.getObjectsSnapshot!().map((d) => d.state.id)).not.toContain(newPage.id)
108
+ storage.transaction((txn) => {
109
+ expect(txn.getChangesSince(clockBeforeDelete)!.diff.deletes).toContain(newPage.id)
110
+ })
111
+ })
112
+
113
+ it('iterates both partitions so schema migrations cover object-lane records', () => {
114
+ const storage = createPartitioned(makeContractSnapshot(contractRecords))
115
+ storage.transaction((txn) => {
116
+ const ids = Array.from(txn.keys()).sort()
117
+ expect(ids).toEqual(contractRecords.map((r) => r.id).sort())
118
+ expect(
119
+ Array.from(txn.values())
120
+ .map((r) => r.id)
121
+ .sort()
122
+ ).toEqual(ids)
123
+ expect(
124
+ Array.from(txn.entries())
125
+ .map(([id]) => id)
126
+ .sort()
127
+ ).toEqual(ids)
128
+ })
129
+ })
130
+
131
+ it('round-trips through a merged snapshot (persist lanes separately, re-seed together)', () => {
132
+ const storage = createPartitioned(makeContractSnapshot(contractRecords))
133
+ const merged = {
134
+ ...storage.getSnapshot!(),
135
+ documents: [...storage.getSnapshot!().documents, ...storage.getObjectsSnapshot!()],
136
+ }
137
+ const reseeded = createPartitioned(merged)
138
+ expect(reseeded.getSnapshot!().documents.map((d) => d.state.typeName)).toEqual(['document'])
139
+ expect(reseeded.getObjectsSnapshot!().map((d) => d.state.typeName)).toEqual(['page'])
140
+ })
141
+ })
142
+
64
143
  describe('transactions', () => {
65
144
  it('[SS2] returns the callback result along with clock metadata', () => {
66
145
  const storage = create(makeContractSnapshot(contractRecords, { documentClock: 7 }))
@@ -2,6 +2,7 @@ import { computed } from '@tldraw/state'
2
2
  import {
3
3
  BaseRecord,
4
4
  RecordId,
5
+ SerializedSchema,
5
6
  Store,
6
7
  StoreSchema,
7
8
  UnknownRecord,
@@ -10,6 +11,7 @@ import {
10
11
  createRecordMigrationSequence,
11
12
  createRecordType,
12
13
  } from '@tldraw/store'
14
+ import { commentSchemaRecords, createTLSchema } from '@tldraw/tlschema'
13
15
  import { vi, type Mock } from 'vitest'
14
16
  import { RecordOpType, ValueOpType } from '../lib/diff'
15
17
  import { TLSocketServerSentEvent, getTlsyncProtocolVersion } from '../lib/protocol'
@@ -218,6 +220,47 @@ class TestInstance {
218
220
  }
219
221
  }
220
222
 
223
+ describe('record types unknown to older clients', () => {
224
+ // Mirrors a deploy that adds the comment record types: stale tabs still run a schema
225
+ // without them, while the server registers them and serves rooms that may contain them. The
226
+ // comment types ship guard migrations (retroactive, no `down`), so the server must reject
227
+ // stale sessions with CLIENT_TOO_OLD (prompting a refresh) rather than sending them record
228
+ // types their store cannot represent.
229
+ const schemaWithComments = createTLSchema({ records: commentSchemaRecords })
230
+ const schemaWithoutComments = createTLSchema()
231
+
232
+ function connectWith(server: TestServer<any>, schema: SerializedSchema) {
233
+ const id = 'stale-tab'
234
+ const socket = mockSocket()
235
+ server.room.handleNewSession({ sessionId: id, socket, meta: undefined, isReadonly: false })
236
+ server.room.handleMessage(id, {
237
+ type: 'connect',
238
+ connectRequestId: 'test',
239
+ lastServerClock: 0,
240
+ protocolVersion: getTlsyncProtocolVersion(),
241
+ schema,
242
+ })
243
+ return socket
244
+ }
245
+
246
+ it('[HS3] rejects sessions whose schema predates the comment types', () => {
247
+ const server = new TestServer(schemaWithComments as any, undefined, {
248
+ objectTypes: ['comment', 'comment-thread'],
249
+ })
250
+ const socket = connectWith(server, schemaWithoutComments.serialize())
251
+ expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
252
+ })
253
+
254
+ it('[HS3] accepts sessions whose schema registers the comment types', () => {
255
+ const server = new TestServer(schemaWithComments as any, undefined, {
256
+ objectTypes: ['comment', 'comment-thread'],
257
+ })
258
+ const socket = connectWith(server, schemaWithComments.serialize())
259
+ expect(socket.close).not.toHaveBeenCalled()
260
+ expect((socket.sendMessage as Mock).mock.calls[0][0]).toMatchObject({ type: 'connect' })
261
+ })
262
+ })
263
+
221
264
  it('[MG2] the server can handle receiving v1 stuff from the client', () => {
222
265
  const t = new TestInstance()
223
266
  t.oldSocketPair.connect()
@@ -568,6 +611,7 @@ describe('when the client is too old', () => {
568
611
  schema: schemaV2.serialize(),
569
612
  serverClock: 10,
570
613
  isReadonly: false,
614
+ objectAccess: 'write',
571
615
  } satisfies TLSocketServerSentEvent<RV2>)
572
616
 
573
617
  expect(v1SendMessage).toHaveBeenCalledWith({
@@ -579,6 +623,7 @@ describe('when the client is too old', () => {
579
623
  schema: schemaV2.serialize(),
580
624
  serverClock: 10,
581
625
  isReadonly: false,
626
+ objectAccess: 'write',
582
627
  } satisfies TLSocketServerSentEvent<RV2>)
583
628
 
584
629
  v2SendMessage.mockClear()
@@ -1100,6 +1145,7 @@ describe('when the client is the same version', () => {
1100
1145
  schema: schemaV2.serialize(),
1101
1146
  serverClock: 10,
1102
1147
  isReadonly: false,
1148
+ objectAccess: 'write',
1103
1149
  } satisfies TLSocketServerSentEvent<RV2>)
1104
1150
 
1105
1151
  expect(bSocket.sendMessage).toHaveBeenCalledWith({
@@ -1111,6 +1157,7 @@ describe('when the client is the same version', () => {
1111
1157
  schema: schemaV2.serialize(),
1112
1158
  serverClock: 10,
1113
1159
  isReadonly: false,
1160
+ objectAccess: 'write',
1114
1161
  } satisfies TLSocketServerSentEvent<RV2>)
1115
1162
  ;(aSocket.sendMessage as Mock).mockClear()
1116
1163
  ;(bSocket.sendMessage as Mock).mockClear()