@tldraw/sync-core 4.3.0-canary.cf5673a789a1 → 4.3.0-canary.d039f3a1ab8f
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 +239 -57
- package/dist-cjs/index.js +7 -3
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +289 -0
- package/dist-cjs/lib/InMemorySyncStorage.js.map +7 -0
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/TLSocketRoom.js +117 -69
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +7 -0
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +357 -688
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-cjs/lib/TLSyncStorage.js +76 -0
- package/dist-cjs/lib/TLSyncStorage.js.map +7 -0
- package/dist-cjs/lib/recordDiff.js +52 -0
- package/dist-cjs/lib/recordDiff.js.map +7 -0
- package/dist-esm/index.d.mts +239 -57
- package/dist-esm/index.mjs +12 -5
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +274 -0
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +7 -0
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/TLSocketRoom.mjs +121 -70
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +7 -0
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +370 -702
- package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
- package/dist-esm/lib/TLSyncStorage.mjs +56 -0
- package/dist-esm/lib/TLSyncStorage.mjs.map +7 -0
- package/dist-esm/lib/recordDiff.mjs +32 -0
- package/dist-esm/lib/recordDiff.mjs.map +7 -0
- package/package.json +6 -6
- package/src/index.ts +21 -3
- package/src/lib/InMemorySyncStorage.ts +357 -0
- package/src/lib/RoomSession.test.ts +1 -0
- package/src/lib/RoomSession.ts +2 -0
- package/src/lib/TLSocketRoom.ts +228 -114
- package/src/lib/TLSyncClient.ts +12 -0
- package/src/lib/TLSyncRoom.ts +473 -913
- package/src/lib/TLSyncStorage.ts +216 -0
- package/src/lib/recordDiff.ts +73 -0
- package/src/test/InMemorySyncStorage.test.ts +1674 -0
- package/src/test/TLSocketRoom.test.ts +255 -49
- package/src/test/TLSyncRoom.test.ts +1021 -533
- package/src/test/TestServer.ts +12 -1
- package/src/test/customMessages.test.ts +1 -1
- package/src/test/presenceMode.test.ts +6 -6
- package/src/test/upgradeDowngrade.test.ts +282 -8
- package/src/test/validation.test.ts +10 -10
- package/src/test/pruneTombstones.test.ts +0 -178
|
@@ -9,21 +9,14 @@ import {
|
|
|
9
9
|
TLArrowShapeProps,
|
|
10
10
|
TLBaseShape,
|
|
11
11
|
TLDOCUMENT_ID,
|
|
12
|
-
TLDocument,
|
|
13
|
-
TLPage,
|
|
14
12
|
TLRecord,
|
|
15
13
|
TLShapeId,
|
|
16
14
|
createTLSchema,
|
|
17
15
|
} from '@tldraw/tlschema'
|
|
18
|
-
import { IndexKey, ZERO_INDEX_KEY, mockUniqueId,
|
|
16
|
+
import { IndexKey, ZERO_INDEX_KEY, mockUniqueId, sortById } from '@tldraw/utils'
|
|
19
17
|
import { vi } from 'vitest'
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
RoomSnapshot,
|
|
23
|
-
TLRoomSocket,
|
|
24
|
-
TLSyncRoom,
|
|
25
|
-
TOMBSTONE_PRUNE_BUFFER_SIZE,
|
|
26
|
-
} from '../lib/TLSyncRoom'
|
|
18
|
+
import { InMemorySyncStorage } from '../lib/InMemorySyncStorage'
|
|
19
|
+
import { RoomSnapshot, TLRoomSocket, TLSyncRoom } from '../lib/TLSyncRoom'
|
|
27
20
|
import {
|
|
28
21
|
TLConnectRequest,
|
|
29
22
|
TLPushRequest,
|
|
@@ -47,6 +40,7 @@ const makeSnapshot = (records: TLRecord[], others: Partial<RoomSnapshot> = {}) =
|
|
|
47
40
|
documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
|
|
48
41
|
clock: 0,
|
|
49
42
|
documentClock: 0,
|
|
43
|
+
schema: schema.serialize(),
|
|
50
44
|
...others,
|
|
51
45
|
})
|
|
52
46
|
|
|
@@ -98,63 +92,21 @@ const oldArrow: TLBaseShape<'arrow', Omit<TLArrowShapeProps, 'labelColor'>> = {
|
|
|
98
92
|
}
|
|
99
93
|
|
|
100
94
|
describe('TLSyncRoom', () => {
|
|
101
|
-
it('can be constructed with a
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
// we populate the store with a default document if none is given
|
|
105
|
-
expect(room.getSnapshot().documents.length).toBeGreaterThan(0)
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
it('can be constructed with a snapshot', () => {
|
|
109
|
-
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
95
|
+
it('can be constructed with a storage', () => {
|
|
96
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
97
|
+
const _room = new TLSyncRoom<TLRecord, undefined>({
|
|
110
98
|
schema,
|
|
111
|
-
|
|
99
|
+
storage,
|
|
112
100
|
})
|
|
113
101
|
|
|
114
102
|
expect(
|
|
115
|
-
|
|
103
|
+
storage
|
|
116
104
|
.getSnapshot()
|
|
117
105
|
.documents.map((r) => r.state)
|
|
118
106
|
.sort(sortById)
|
|
119
107
|
).toEqual(records)
|
|
120
108
|
|
|
121
|
-
expect(
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
it('trims tombstones down if you pass too many in the snapshot', () => {
|
|
125
|
-
const room = new TLSyncRoom({
|
|
126
|
-
schema,
|
|
127
|
-
snapshot: {
|
|
128
|
-
documents: [],
|
|
129
|
-
clock: MAX_TOMBSTONES + 100,
|
|
130
|
-
tombstones: Object.fromEntries(
|
|
131
|
-
Array.from({ length: MAX_TOMBSTONES + 100 }, (_, i) => [PageRecordType.createId(), i])
|
|
132
|
-
),
|
|
133
|
-
},
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
expect(Object.keys(room.getSnapshot().tombstones ?? {})).toHaveLength(
|
|
137
|
-
MAX_TOMBSTONES - TOMBSTONE_PRUNE_BUFFER_SIZE
|
|
138
|
-
)
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
it('updates tombstoneHistoryStartsAtClock when pruning tombstones', () => {
|
|
142
|
-
const room = new TLSyncRoom({
|
|
143
|
-
schema,
|
|
144
|
-
snapshot: {
|
|
145
|
-
documents: [],
|
|
146
|
-
clock: MAX_TOMBSTONES + 100,
|
|
147
|
-
tombstones: Object.fromEntries(
|
|
148
|
-
Array.from({ length: MAX_TOMBSTONES + 100 }, (_, i) => [PageRecordType.createId(), i])
|
|
149
|
-
),
|
|
150
|
-
},
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
// After pruning, tombstoneHistoryStartsAtClock should be updated to the clock value
|
|
154
|
-
// of the oldest remaining tombstone
|
|
155
|
-
const remainingTombstones = Object.values(room.getSnapshot().tombstones ?? {})
|
|
156
|
-
const oldestRemainingClock = Math.min(...remainingTombstones)
|
|
157
|
-
expect(room.tombstoneHistoryStartsAtClock).toBe(oldestRemainingClock)
|
|
109
|
+
expect(storage.getSnapshot().documents.map((r) => r.lastChangedClock)).toEqual([0, 0])
|
|
158
110
|
})
|
|
159
111
|
|
|
160
112
|
it('migrates the snapshot if it is dealing with old data', () => {
|
|
@@ -167,40 +119,50 @@ describe('TLSyncRoom', () => {
|
|
|
167
119
|
},
|
|
168
120
|
}
|
|
169
121
|
|
|
170
|
-
const
|
|
171
|
-
schema,
|
|
122
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
172
123
|
snapshot: makeSnapshot([...records, oldArrow as any], {
|
|
173
124
|
schema: oldSerializedSchema,
|
|
174
125
|
}),
|
|
175
126
|
})
|
|
127
|
+
const _room = new TLSyncRoom<TLRecord, undefined>({
|
|
128
|
+
schema,
|
|
129
|
+
storage,
|
|
130
|
+
})
|
|
176
131
|
|
|
177
|
-
const arrow =
|
|
132
|
+
const arrow = storage.getSnapshot().documents.find((r) => r.state.id === oldArrow.id)
|
|
178
133
|
?.state as TLArrowShape
|
|
179
134
|
expect(arrow.props.labelColor).toBe('black')
|
|
180
135
|
})
|
|
181
136
|
|
|
182
|
-
it('filters out instance state records', () => {
|
|
183
|
-
const schema = createTLSchema(
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
137
|
+
it('filters out instance state records if a migration occurs, for legacy reasons', () => {
|
|
138
|
+
const schema = createTLSchema()
|
|
139
|
+
const oldSchema = structuredClone(schema.serialize())
|
|
140
|
+
oldSchema.sequences['com.tldraw.shape.arrow'] = 0
|
|
141
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
142
|
+
snapshot: makeSnapshot(
|
|
143
|
+
[
|
|
144
|
+
...records,
|
|
145
|
+
schema.types.instance.create({
|
|
146
|
+
currentPageId: PageRecordType.createId('page_1'),
|
|
147
|
+
id: schema.types.instance.createId('instance_1'),
|
|
148
|
+
}),
|
|
149
|
+
InstancePageStateRecordType.create({
|
|
150
|
+
id: InstancePageStateRecordType.createId(PageRecordType.createId('page_1')),
|
|
151
|
+
pageId: PageRecordType.createId('page_1'),
|
|
152
|
+
}),
|
|
153
|
+
CameraRecordType.create({
|
|
154
|
+
id: CameraRecordType.createId('camera_1'),
|
|
155
|
+
}),
|
|
156
|
+
],
|
|
157
|
+
{
|
|
158
|
+
schema: oldSchema,
|
|
159
|
+
}
|
|
160
|
+
),
|
|
200
161
|
})
|
|
162
|
+
const _room = new TLSyncRoom({ schema, storage })
|
|
201
163
|
|
|
202
164
|
expect(
|
|
203
|
-
|
|
165
|
+
storage
|
|
204
166
|
.getSnapshot()
|
|
205
167
|
.documents.map((r) => r.state)
|
|
206
168
|
.sort(sortById)
|
|
@@ -223,349 +185,17 @@ function makeSocket(): MockSocket {
|
|
|
223
185
|
return socket
|
|
224
186
|
}
|
|
225
187
|
|
|
226
|
-
describe('TLSyncRoom.updateStore', () => {
|
|
227
|
-
const sessionAId = 'sessionA'
|
|
228
|
-
const sessionBId = 'sessionB'
|
|
229
|
-
let room = new TLSyncRoom<TLRecord, undefined>({ schema, snapshot: makeSnapshot(records) })
|
|
230
|
-
let socketA = makeSocket()
|
|
231
|
-
let socketB = makeSocket()
|
|
232
|
-
function init(snapshot?: RoomSnapshot) {
|
|
233
|
-
room = new TLSyncRoom<TLRecord, undefined>({
|
|
234
|
-
schema,
|
|
235
|
-
snapshot: snapshot ?? makeSnapshot(records),
|
|
236
|
-
})
|
|
237
|
-
socketA = makeSocket()
|
|
238
|
-
socketB = makeSocket()
|
|
239
|
-
room.handleNewSession({
|
|
240
|
-
sessionId: sessionAId,
|
|
241
|
-
socket: socketA,
|
|
242
|
-
meta: null as any,
|
|
243
|
-
isReadonly: false,
|
|
244
|
-
})
|
|
245
|
-
room.handleNewSession({
|
|
246
|
-
sessionId: sessionBId,
|
|
247
|
-
socket: socketB,
|
|
248
|
-
meta: null as any,
|
|
249
|
-
isReadonly: false,
|
|
250
|
-
})
|
|
251
|
-
room.handleMessage(sessionAId, {
|
|
252
|
-
connectRequestId: 'connectRequestId' + sessionAId,
|
|
253
|
-
lastServerClock: 0,
|
|
254
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
255
|
-
schema: room.serializedSchema,
|
|
256
|
-
type: 'connect',
|
|
257
|
-
} satisfies TLConnectRequest)
|
|
258
|
-
room.handleMessage(sessionBId, {
|
|
259
|
-
connectRequestId: 'connectRequestId' + sessionBId,
|
|
260
|
-
lastServerClock: 0,
|
|
261
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
262
|
-
schema: room.serializedSchema,
|
|
263
|
-
type: 'connect',
|
|
264
|
-
} satisfies TLConnectRequest)
|
|
265
|
-
expect(room.sessions.get(sessionAId)?.state).toBe('connected')
|
|
266
|
-
expect(room.sessions.get(sessionBId)?.state).toBe('connected')
|
|
267
|
-
socketA.__lastMessage = null
|
|
268
|
-
socketB.__lastMessage = null
|
|
269
|
-
}
|
|
270
|
-
beforeEach(() => {
|
|
271
|
-
init()
|
|
272
|
-
})
|
|
273
|
-
|
|
274
|
-
test('it allows updating records', async () => {
|
|
275
|
-
const clock = room.clock
|
|
276
|
-
const documentClock = room.documentClock
|
|
277
|
-
await room.updateStore((store) => {
|
|
278
|
-
const document = store.get('document:document') as TLDocument
|
|
279
|
-
document.name = 'My lovely document'
|
|
280
|
-
store.put(document)
|
|
281
|
-
})
|
|
282
|
-
expect(
|
|
283
|
-
(room.getSnapshot().documents.find((r) => r.state.id === 'document:document')?.state as any)
|
|
284
|
-
.name
|
|
285
|
-
).toBe('My lovely document')
|
|
286
|
-
expect(clock).toBeLessThan(room.clock)
|
|
287
|
-
expect(documentClock).toBeLessThan(room.documentClock)
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
test('it does not update unless you call .set', () => {
|
|
291
|
-
const documentClock = room.documentClock
|
|
292
|
-
room.updateStore((store) => {
|
|
293
|
-
const document = store.get('document:document') as TLDocument
|
|
294
|
-
document.name = 'My lovely document'
|
|
295
|
-
})
|
|
296
|
-
expect(
|
|
297
|
-
(room.getSnapshot().documents.find((r) => r.state.id === 'document:document')?.state as any)
|
|
298
|
-
.name
|
|
299
|
-
).toBe('')
|
|
300
|
-
expect(documentClock).toBe(room.documentClock)
|
|
301
|
-
})
|
|
302
|
-
|
|
303
|
-
test('after the change it sends a patch to all clients', async () => {
|
|
304
|
-
const clock = room.clock
|
|
305
|
-
const documentClock = room.documentClock
|
|
306
|
-
await room.updateStore((store) => {
|
|
307
|
-
const document = store.get('document:document') as TLDocument
|
|
308
|
-
document.name = 'My lovely document'
|
|
309
|
-
store.put(document)
|
|
310
|
-
})
|
|
311
|
-
|
|
312
|
-
expect(clock).toBeLessThan(room.clock)
|
|
313
|
-
expect(documentClock).toBeLessThan(room.documentClock)
|
|
314
|
-
|
|
315
|
-
expect(socketA.__lastMessage).toMatchInlineSnapshot(`
|
|
316
|
-
{
|
|
317
|
-
"data": [
|
|
318
|
-
{
|
|
319
|
-
"diff": {
|
|
320
|
-
"document:document": [
|
|
321
|
-
"patch",
|
|
322
|
-
{
|
|
323
|
-
"name": [
|
|
324
|
-
"append",
|
|
325
|
-
"My lovely document",
|
|
326
|
-
0,
|
|
327
|
-
],
|
|
328
|
-
},
|
|
329
|
-
],
|
|
330
|
-
},
|
|
331
|
-
"serverClock": 1,
|
|
332
|
-
"type": "patch",
|
|
333
|
-
},
|
|
334
|
-
],
|
|
335
|
-
"type": "data",
|
|
336
|
-
}
|
|
337
|
-
`)
|
|
338
|
-
expect(socketB.__lastMessage).toEqual(socketA.__lastMessage)
|
|
339
|
-
})
|
|
340
|
-
|
|
341
|
-
test('it allows adding new records', async () => {
|
|
342
|
-
const id = PageRecordType.createId('page_3')
|
|
343
|
-
await room.updateStore((store) => {
|
|
344
|
-
const page = PageRecordType.create({ id, name: 'page 3', index: 'a0' as IndexKey })
|
|
345
|
-
store.put(page)
|
|
346
|
-
})
|
|
347
|
-
|
|
348
|
-
expect(socketA.__lastMessage).toMatchInlineSnapshot(`
|
|
349
|
-
{
|
|
350
|
-
"data": [
|
|
351
|
-
{
|
|
352
|
-
"diff": {
|
|
353
|
-
"page:page_3": [
|
|
354
|
-
"put",
|
|
355
|
-
{
|
|
356
|
-
"id": "page:page_3",
|
|
357
|
-
"index": "a0",
|
|
358
|
-
"meta": {},
|
|
359
|
-
"name": "page 3",
|
|
360
|
-
"typeName": "page",
|
|
361
|
-
},
|
|
362
|
-
],
|
|
363
|
-
},
|
|
364
|
-
"serverClock": 1,
|
|
365
|
-
"type": "patch",
|
|
366
|
-
},
|
|
367
|
-
],
|
|
368
|
-
"type": "data",
|
|
369
|
-
}
|
|
370
|
-
`)
|
|
371
|
-
expect(socketB.__lastMessage).toEqual(socketA.__lastMessage)
|
|
372
|
-
expect(room.getSnapshot().documents.find((r) => r.state.id === id)?.state).toBeTruthy()
|
|
373
|
-
})
|
|
374
|
-
|
|
375
|
-
test('it allows deleting records', async () => {
|
|
376
|
-
await room.updateStore((store) => {
|
|
377
|
-
store.delete('page:page_2')
|
|
378
|
-
})
|
|
379
|
-
|
|
380
|
-
expect(socketA.__lastMessage).toMatchInlineSnapshot(`
|
|
381
|
-
{
|
|
382
|
-
"data": [
|
|
383
|
-
{
|
|
384
|
-
"diff": {
|
|
385
|
-
"page:page_2": [
|
|
386
|
-
"remove",
|
|
387
|
-
],
|
|
388
|
-
},
|
|
389
|
-
"serverClock": 1,
|
|
390
|
-
"type": "patch",
|
|
391
|
-
},
|
|
392
|
-
],
|
|
393
|
-
"type": "data",
|
|
394
|
-
}
|
|
395
|
-
`)
|
|
396
|
-
expect(socketB.__lastMessage).toEqual(socketA.__lastMessage)
|
|
397
|
-
expect(room.getSnapshot().documents.find((r) => r.state.id === 'page:page_2')).toBeFalsy()
|
|
398
|
-
})
|
|
399
|
-
|
|
400
|
-
test('it wont do anything if your changes are no-ops', async () => {
|
|
401
|
-
const documentClock = room.documentClock
|
|
402
|
-
await room.updateStore((store) => {
|
|
403
|
-
const newPage = PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey })
|
|
404
|
-
store.put(newPage)
|
|
405
|
-
store.delete(newPage.id)
|
|
406
|
-
})
|
|
407
|
-
|
|
408
|
-
expect(room.documentClock).toBe(documentClock)
|
|
409
|
-
expect(socketA.__lastMessage).toBeNull()
|
|
410
|
-
expect(socketB.__lastMessage).toBeNull()
|
|
411
|
-
|
|
412
|
-
await room.updateStore((store) => {
|
|
413
|
-
const page = store.get('page:page_2')!
|
|
414
|
-
store.delete(page)
|
|
415
|
-
store.put(page)
|
|
416
|
-
})
|
|
417
|
-
|
|
418
|
-
expect(room.documentClock).toBe(documentClock)
|
|
419
|
-
expect(socketA.__lastMessage).toBeNull()
|
|
420
|
-
expect(socketB.__lastMessage).toBeNull()
|
|
421
|
-
|
|
422
|
-
await room.updateStore((store) => {
|
|
423
|
-
let page = store.get('page:page_2') as TLPage
|
|
424
|
-
page.name = 'my lovely page'
|
|
425
|
-
store.put(page)
|
|
426
|
-
page = store.get('page:page_2') as TLPage
|
|
427
|
-
store.delete(page)
|
|
428
|
-
page.name = 'page 2'
|
|
429
|
-
store.put(page)
|
|
430
|
-
})
|
|
431
|
-
|
|
432
|
-
expect(room.documentClock).toBe(documentClock)
|
|
433
|
-
expect(socketA.__lastMessage).toBeNull()
|
|
434
|
-
expect(socketB.__lastMessage).toBeNull()
|
|
435
|
-
})
|
|
436
|
-
|
|
437
|
-
test('it returns all records if you ask for them', async () => {
|
|
438
|
-
let allRecords
|
|
439
|
-
await room.updateStore((store) => {
|
|
440
|
-
allRecords = store.getAll()
|
|
441
|
-
})
|
|
442
|
-
expect(allRecords!.sort(compareById)).toEqual(records)
|
|
443
|
-
await room.updateStore((store) => {
|
|
444
|
-
const page3 = PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey })
|
|
445
|
-
store.put(page3)
|
|
446
|
-
allRecords = store.getAll()
|
|
447
|
-
expect(allRecords.sort(compareById)).toEqual([...records, page3].sort(compareById))
|
|
448
|
-
store.delete(page3)
|
|
449
|
-
allRecords = store.getAll()
|
|
450
|
-
})
|
|
451
|
-
expect(allRecords!.sort(compareById)).toEqual(records)
|
|
452
|
-
})
|
|
453
|
-
|
|
454
|
-
test('all operations fail after the store is closed', async () => {
|
|
455
|
-
let store
|
|
456
|
-
await room.updateStore((s) => {
|
|
457
|
-
store = s
|
|
458
|
-
})
|
|
459
|
-
expect(() => {
|
|
460
|
-
store!.put(PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey }))
|
|
461
|
-
}).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
|
|
462
|
-
expect(() => {
|
|
463
|
-
store!.delete('page:page_2')
|
|
464
|
-
}).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
|
|
465
|
-
expect(() => {
|
|
466
|
-
store!.getAll()
|
|
467
|
-
}).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
|
|
468
|
-
expect(() => {
|
|
469
|
-
store!.get('page:page_2')
|
|
470
|
-
}).toThrowErrorMatchingInlineSnapshot(`[Error: StoreUpdateContext is closed]`)
|
|
471
|
-
})
|
|
472
|
-
|
|
473
|
-
test('it fails if the room is closed', async () => {
|
|
474
|
-
room.close()
|
|
475
|
-
await expect(
|
|
476
|
-
room.updateStore(() => {
|
|
477
|
-
// noop
|
|
478
|
-
})
|
|
479
|
-
).rejects.toMatchInlineSnapshot(`[Error: Cannot update store on a closed room]`)
|
|
480
|
-
})
|
|
481
|
-
|
|
482
|
-
test('it fails if you try to add bad data', async () => {
|
|
483
|
-
await expect(
|
|
484
|
-
room.updateStore((store) => {
|
|
485
|
-
const page = store.get('page:page_2') as TLPage
|
|
486
|
-
page.index = 34 as any
|
|
487
|
-
store.put(page)
|
|
488
|
-
})
|
|
489
|
-
).rejects.toMatchInlineSnapshot(`[Error: failed to apply changes: INVALID_RECORD]`)
|
|
490
|
-
})
|
|
491
|
-
|
|
492
|
-
test('changes in multiple transaction are isolated from one another', async () => {
|
|
493
|
-
const page3 = PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey })
|
|
494
|
-
const didDelete = promiseWithResolve()
|
|
495
|
-
const didPut = promiseWithResolve()
|
|
496
|
-
const doneA = room.updateStore(async (store) => {
|
|
497
|
-
store.put(page3)
|
|
498
|
-
didPut.resolve(null)
|
|
499
|
-
await didDelete
|
|
500
|
-
expect(store.get(page3.id)).toBeTruthy()
|
|
501
|
-
})
|
|
502
|
-
const doneB = room.updateStore(async (store) => {
|
|
503
|
-
await didPut
|
|
504
|
-
expect(store.get(page3.id)).toBeFalsy()
|
|
505
|
-
store.delete(page3.id)
|
|
506
|
-
didDelete.resolve(null)
|
|
507
|
-
})
|
|
508
|
-
await Promise.all([doneA, doneB])
|
|
509
|
-
})
|
|
510
|
-
|
|
511
|
-
test('getting something that was deleted in the same transaction returns null', async () => {
|
|
512
|
-
await room.updateStore((store) => {
|
|
513
|
-
expect(store.get('page:page_2')).toBeTruthy()
|
|
514
|
-
store.delete('page:page_2')
|
|
515
|
-
expect(store.get('page:page_2')).toBe(null)
|
|
516
|
-
})
|
|
517
|
-
})
|
|
518
|
-
|
|
519
|
-
test('getting something that never existed in the first place returns null', async () => {
|
|
520
|
-
await room.updateStore((store) => {
|
|
521
|
-
expect(store.get('page:page_3')).toBe(null)
|
|
522
|
-
})
|
|
523
|
-
})
|
|
524
|
-
|
|
525
|
-
test('mutations to shapes gotten via .get are not committed unless you .put', async () => {
|
|
526
|
-
const page3 = PageRecordType.create({ name: 'page 3', index: 'a0' as IndexKey })
|
|
527
|
-
let page4 = PageRecordType.create({ name: 'page 4', index: 'a1' as IndexKey })
|
|
528
|
-
let page2
|
|
529
|
-
await room.updateStore((store) => {
|
|
530
|
-
page2 = store.get('page:page_2') as TLPage
|
|
531
|
-
page2.name = 'my lovely page 2'
|
|
532
|
-
store.put(page3)
|
|
533
|
-
page3.name = 'my lovely page 3'
|
|
534
|
-
store.put(page4)
|
|
535
|
-
page4 = store.get(page4.id) as TLPage
|
|
536
|
-
page4.name = 'my lovely page 4'
|
|
537
|
-
})
|
|
538
|
-
|
|
539
|
-
const getPageNames = () =>
|
|
540
|
-
room
|
|
541
|
-
.getSnapshot()
|
|
542
|
-
.documents.filter((r) => r.state.typeName === 'page')
|
|
543
|
-
.map((r) => (r.state as any).name)
|
|
544
|
-
.sort()
|
|
545
|
-
|
|
546
|
-
expect(getPageNames()).toEqual(['page 2', 'page 3', 'page 4'])
|
|
547
|
-
|
|
548
|
-
await room.updateStore((store) => {
|
|
549
|
-
store.put(page2!)
|
|
550
|
-
store.put(page3)
|
|
551
|
-
store.put(page4)
|
|
552
|
-
})
|
|
553
|
-
|
|
554
|
-
expect(getPageNames()).toEqual(['my lovely page 2', 'my lovely page 3', 'my lovely page 4'])
|
|
555
|
-
})
|
|
556
|
-
})
|
|
557
|
-
|
|
558
188
|
describe('isReadonly', () => {
|
|
559
189
|
const sessionAId = 'sessionA'
|
|
560
190
|
const sessionBId = 'sessionB'
|
|
561
|
-
let
|
|
191
|
+
let storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
192
|
+
let room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
562
193
|
let socketA = makeSocket()
|
|
563
194
|
let socketB = makeSocket()
|
|
195
|
+
const getDoc = (id: string) => storage.documents.get(id)?.state
|
|
564
196
|
function init(snapshot?: RoomSnapshot) {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
snapshot: snapshot ?? makeSnapshot(records),
|
|
568
|
-
})
|
|
197
|
+
storage = new InMemorySyncStorage<TLRecord>({ snapshot: snapshot ?? makeSnapshot(records) })
|
|
198
|
+
room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
569
199
|
socketA = makeSocket()
|
|
570
200
|
socketB = makeSocket()
|
|
571
201
|
room.handleNewSession({
|
|
@@ -623,7 +253,7 @@ describe('isReadonly', () => {
|
|
|
623
253
|
// sessionA is readonly
|
|
624
254
|
room.handleMessage(sessionAId, push)
|
|
625
255
|
|
|
626
|
-
expect(
|
|
256
|
+
expect(getDoc('page:page_3')).toBe(undefined)
|
|
627
257
|
// should tell the session to discard it
|
|
628
258
|
expect(socketA.__lastMessage).toMatchInlineSnapshot(`
|
|
629
259
|
{
|
|
@@ -631,7 +261,7 @@ describe('isReadonly', () => {
|
|
|
631
261
|
{
|
|
632
262
|
"action": "discard",
|
|
633
263
|
"clientClock": 0,
|
|
634
|
-
"serverClock":
|
|
264
|
+
"serverClock": 0,
|
|
635
265
|
"type": "push_result",
|
|
636
266
|
},
|
|
637
267
|
],
|
|
@@ -644,7 +274,7 @@ describe('isReadonly', () => {
|
|
|
644
274
|
// sessionB is not readonly
|
|
645
275
|
room.handleMessage(sessionBId, push)
|
|
646
276
|
|
|
647
|
-
expect(
|
|
277
|
+
expect(getDoc('page:page_3')).not.toBe(undefined)
|
|
648
278
|
|
|
649
279
|
// should tell the session to commit it
|
|
650
280
|
expect(socketB.__lastMessage).toMatchInlineSnapshot(`
|
|
@@ -653,7 +283,7 @@ describe('isReadonly', () => {
|
|
|
653
283
|
{
|
|
654
284
|
"action": "commit",
|
|
655
285
|
"clientClock": 0,
|
|
656
|
-
"serverClock":
|
|
286
|
+
"serverClock": 1,
|
|
657
287
|
"type": "push_result",
|
|
658
288
|
},
|
|
659
289
|
],
|
|
@@ -688,7 +318,7 @@ describe('isReadonly', () => {
|
|
|
688
318
|
{
|
|
689
319
|
"action": "commit",
|
|
690
320
|
"clientClock": 0,
|
|
691
|
-
"serverClock":
|
|
321
|
+
"serverClock": 0,
|
|
692
322
|
"type": "push_result",
|
|
693
323
|
},
|
|
694
324
|
],
|
|
@@ -724,7 +354,7 @@ describe('isReadonly', () => {
|
|
|
724
354
|
},
|
|
725
355
|
],
|
|
726
356
|
},
|
|
727
|
-
"serverClock":
|
|
357
|
+
"serverClock": 0,
|
|
728
358
|
"type": "patch",
|
|
729
359
|
},
|
|
730
360
|
],
|
|
@@ -737,16 +367,14 @@ describe('isReadonly', () => {
|
|
|
737
367
|
it('can load snapshot without documentClock field', () => {
|
|
738
368
|
const legacySnapshot = makeLegacySnapshot(records)
|
|
739
369
|
|
|
740
|
-
const
|
|
741
|
-
|
|
742
|
-
snapshot: legacySnapshot,
|
|
743
|
-
})
|
|
370
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: legacySnapshot })
|
|
371
|
+
const _room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
744
372
|
|
|
745
373
|
// Room should load successfully without errors
|
|
746
|
-
expect(
|
|
374
|
+
expect(storage.getSnapshot().documents.length).toBe(2)
|
|
747
375
|
|
|
748
376
|
// documentClock should be calculated from existing data
|
|
749
|
-
const snapshot =
|
|
377
|
+
const snapshot = storage.getSnapshot()
|
|
750
378
|
expect(snapshot.documentClock).toBe(0) // max lastChangedClock from documents
|
|
751
379
|
})
|
|
752
380
|
|
|
@@ -758,12 +386,10 @@ describe('isReadonly', () => {
|
|
|
758
386
|
],
|
|
759
387
|
})
|
|
760
388
|
|
|
761
|
-
const
|
|
762
|
-
|
|
763
|
-
snapshot: legacySnapshot,
|
|
764
|
-
})
|
|
389
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: legacySnapshot })
|
|
390
|
+
const _room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
765
391
|
|
|
766
|
-
const snapshot =
|
|
392
|
+
const snapshot = storage.getSnapshot()
|
|
767
393
|
expect(snapshot.documentClock).toBe(10) // max lastChangedClock
|
|
768
394
|
})
|
|
769
395
|
|
|
@@ -776,12 +402,10 @@ describe('isReadonly', () => {
|
|
|
776
402
|
},
|
|
777
403
|
})
|
|
778
404
|
|
|
779
|
-
const
|
|
780
|
-
|
|
781
|
-
snapshot: legacySnapshot,
|
|
782
|
-
})
|
|
405
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: legacySnapshot })
|
|
406
|
+
const _room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
783
407
|
|
|
784
|
-
const snapshot =
|
|
408
|
+
const snapshot = storage.getSnapshot()
|
|
785
409
|
expect(snapshot.documentClock).toBe(12) // max of document (3) and tombstones (7, 12)
|
|
786
410
|
})
|
|
787
411
|
|
|
@@ -791,12 +415,10 @@ describe('isReadonly', () => {
|
|
|
791
415
|
tombstones: {},
|
|
792
416
|
})
|
|
793
417
|
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
snapshot: emptyLegacySnapshot,
|
|
797
|
-
})
|
|
418
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: emptyLegacySnapshot })
|
|
419
|
+
const _room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
798
420
|
|
|
799
|
-
const snapshot =
|
|
421
|
+
const snapshot = storage.getSnapshot()
|
|
800
422
|
expect(snapshot.documentClock).toBe(0) // no documents or tombstones
|
|
801
423
|
})
|
|
802
424
|
|
|
@@ -809,12 +431,10 @@ describe('isReadonly', () => {
|
|
|
809
431
|
},
|
|
810
432
|
})
|
|
811
433
|
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
snapshot: legacySnapshot,
|
|
815
|
-
})
|
|
434
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: legacySnapshot })
|
|
435
|
+
const _room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
816
436
|
|
|
817
|
-
const snapshot =
|
|
437
|
+
const snapshot = storage.getSnapshot()
|
|
818
438
|
expect(snapshot.documentClock).toBe(8) // max tombstone clock
|
|
819
439
|
})
|
|
820
440
|
|
|
@@ -823,96 +443,964 @@ describe('isReadonly', () => {
|
|
|
823
443
|
documentClock: 15,
|
|
824
444
|
})
|
|
825
445
|
|
|
826
|
-
const
|
|
827
|
-
|
|
828
|
-
snapshot: snapshotWithDocumentClock,
|
|
829
|
-
})
|
|
446
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: snapshotWithDocumentClock })
|
|
447
|
+
const _room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
830
448
|
|
|
831
|
-
const snapshot =
|
|
449
|
+
const snapshot = storage.getSnapshot()
|
|
832
450
|
expect(snapshot.documentClock).toBe(15) // should preserve explicit value
|
|
833
451
|
})
|
|
452
|
+
})
|
|
453
|
+
})
|
|
834
454
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
migrations: [
|
|
840
|
-
{
|
|
841
|
-
sequenceId: 'test-migration',
|
|
842
|
-
retroactive: false,
|
|
843
|
-
sequence: [
|
|
844
|
-
{
|
|
845
|
-
id: 'test-migration/1',
|
|
846
|
-
scope: 'record',
|
|
847
|
-
filter: (record: any) => record.typeName === 'document',
|
|
848
|
-
up: (record: any) => {
|
|
849
|
-
// Modify the record to trigger clock increment
|
|
850
|
-
return { ...record, meta: { ...record.meta, migrated: true } }
|
|
851
|
-
},
|
|
852
|
-
},
|
|
853
|
-
],
|
|
854
|
-
},
|
|
855
|
-
],
|
|
856
|
-
})
|
|
857
|
-
|
|
858
|
-
const snapshotWithDocumentClock = makeSnapshot(records, {
|
|
859
|
-
documentClock: 5,
|
|
860
|
-
clock: 10,
|
|
861
|
-
})
|
|
862
|
-
|
|
863
|
-
const onDataChange = vi.fn()
|
|
864
|
-
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
865
|
-
schema: schemaWithMigration,
|
|
866
|
-
snapshot: snapshotWithDocumentClock,
|
|
867
|
-
onDataChange,
|
|
868
|
-
})
|
|
869
|
-
|
|
870
|
-
// Migration should have run, incrementing the clock
|
|
871
|
-
expect(room.getSnapshot().clock).toBe(11)
|
|
872
|
-
expect(room.getSnapshot().documentClock).toBe(11)
|
|
873
|
-
expect(onDataChange).toHaveBeenCalled()
|
|
874
|
-
})
|
|
455
|
+
describe('External storage changes', () => {
|
|
456
|
+
it('broadcasts external storage changes to connected clients', async () => {
|
|
457
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
458
|
+
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
875
459
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
documentClock: 15,
|
|
879
|
-
clock: 20,
|
|
880
|
-
})
|
|
881
|
-
|
|
882
|
-
const onDataChange = vi.fn()
|
|
883
|
-
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
884
|
-
schema,
|
|
885
|
-
snapshot: snapshotWithDocumentClock,
|
|
886
|
-
onDataChange,
|
|
887
|
-
})
|
|
888
|
-
|
|
889
|
-
// No migrations should have run
|
|
890
|
-
expect(room.getSnapshot().documentClock).toBe(15)
|
|
891
|
-
expect(room.getSnapshot().clock).toBe(20)
|
|
892
|
-
expect(onDataChange).not.toHaveBeenCalled()
|
|
893
|
-
})
|
|
460
|
+
const socket = makeSocket()
|
|
461
|
+
const sessionId = 'test-session'
|
|
894
462
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
463
|
+
room.handleNewSession({
|
|
464
|
+
sessionId,
|
|
465
|
+
socket,
|
|
466
|
+
meta: undefined,
|
|
467
|
+
isReadonly: false,
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
room.handleMessage(sessionId, {
|
|
471
|
+
connectRequestId: 'connect-1',
|
|
472
|
+
lastServerClock: 0,
|
|
473
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
474
|
+
schema: room.serializedSchema,
|
|
475
|
+
type: 'connect',
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
expect(room.sessions.get(sessionId)?.state).toBe('connected')
|
|
479
|
+
socket.__lastMessage = null
|
|
480
|
+
|
|
481
|
+
// Simulate external storage change (as if another room instance modified it)
|
|
482
|
+
const newPage = PageRecordType.create({
|
|
483
|
+
id: PageRecordType.createId('external_page'),
|
|
484
|
+
name: 'External Page',
|
|
485
|
+
index: 'a2' as IndexKey,
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
storage.transaction((txn) => {
|
|
489
|
+
txn.set(newPage.id, newPage)
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
// Wait for onChange microtask to fire
|
|
493
|
+
await Promise.resolve()
|
|
494
|
+
// Wait for the room to process the change
|
|
495
|
+
await Promise.resolve()
|
|
496
|
+
|
|
497
|
+
// The client should have received a patch with the new page
|
|
498
|
+
expect(socket.sendMessage).toHaveBeenCalled()
|
|
499
|
+
const calls = (socket.sendMessage as any).mock.calls
|
|
500
|
+
const lastCall = calls[calls.length - 1][0]
|
|
501
|
+
|
|
502
|
+
// Should be a data message containing a patch
|
|
503
|
+
expect(lastCall.type).toBe('data')
|
|
504
|
+
expect(lastCall.data).toEqual(
|
|
505
|
+
expect.arrayContaining([
|
|
506
|
+
expect.objectContaining({
|
|
507
|
+
type: 'patch',
|
|
508
|
+
diff: expect.objectContaining({
|
|
509
|
+
[newPage.id]: ['put', expect.objectContaining({ id: newPage.id })],
|
|
510
|
+
}),
|
|
511
|
+
}),
|
|
512
|
+
])
|
|
513
|
+
)
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it('does not broadcast changes from its own transactions (via internalTxnId)', async () => {
|
|
517
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
518
|
+
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
519
|
+
|
|
520
|
+
const socketA = makeSocket()
|
|
521
|
+
const socketB = makeSocket()
|
|
522
|
+
const sessionAId = 'session-a'
|
|
523
|
+
const sessionBId = 'session-b'
|
|
524
|
+
|
|
525
|
+
// Connect two clients
|
|
526
|
+
room.handleNewSession({
|
|
527
|
+
sessionId: sessionAId,
|
|
528
|
+
socket: socketA,
|
|
529
|
+
meta: undefined,
|
|
530
|
+
isReadonly: false,
|
|
531
|
+
})
|
|
532
|
+
room.handleNewSession({
|
|
533
|
+
sessionId: sessionBId,
|
|
534
|
+
socket: socketB,
|
|
535
|
+
meta: undefined,
|
|
536
|
+
isReadonly: false,
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
room.handleMessage(sessionAId, {
|
|
540
|
+
connectRequestId: 'connect-a',
|
|
541
|
+
lastServerClock: 0,
|
|
542
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
543
|
+
schema: room.serializedSchema,
|
|
544
|
+
type: 'connect',
|
|
545
|
+
})
|
|
546
|
+
room.handleMessage(sessionBId, {
|
|
547
|
+
connectRequestId: 'connect-b',
|
|
548
|
+
lastServerClock: 0,
|
|
549
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
550
|
+
schema: room.serializedSchema,
|
|
551
|
+
type: 'connect',
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
// Clear message history
|
|
555
|
+
;(socketA.sendMessage as any).mockClear()
|
|
556
|
+
;(socketB.sendMessage as any).mockClear()
|
|
557
|
+
|
|
558
|
+
// Client A pushes a change through the room
|
|
559
|
+
const newPage = PageRecordType.create({
|
|
560
|
+
id: PageRecordType.createId('client_page'),
|
|
561
|
+
name: 'Client Page',
|
|
562
|
+
index: 'a2' as IndexKey,
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
room.handleMessage(sessionAId, {
|
|
566
|
+
type: 'push',
|
|
567
|
+
clientClock: 1,
|
|
568
|
+
diff: {
|
|
569
|
+
[newPage.id]: ['put', newPage],
|
|
570
|
+
},
|
|
571
|
+
} as TLPushRequest<TLRecord>)
|
|
572
|
+
|
|
573
|
+
// Wait for any microtasks
|
|
574
|
+
await Promise.resolve()
|
|
575
|
+
|
|
576
|
+
// Client A should get push_result
|
|
577
|
+
const clientACalls = (socketA.sendMessage as any).mock.calls
|
|
578
|
+
expect(clientACalls.length).toBeGreaterThan(0)
|
|
579
|
+
const lastAMessage = clientACalls[clientACalls.length - 1][0]
|
|
580
|
+
expect(lastAMessage.type).toBe('data')
|
|
581
|
+
expect(lastAMessage.data[0].type).toBe('push_result')
|
|
582
|
+
|
|
583
|
+
// Client B should get exactly one patch (from the push), not a duplicate from external change detection
|
|
584
|
+
const clientBCalls = (socketB.sendMessage as any).mock.calls
|
|
585
|
+
expect(clientBCalls.length).toBe(1)
|
|
586
|
+
expect(clientBCalls[0][0].type).toBe('data')
|
|
587
|
+
expect(clientBCalls[0][0].data[0].type).toBe('patch')
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
it('handles multiple rapid external changes', async () => {
|
|
591
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
592
|
+
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
593
|
+
|
|
594
|
+
const socket = makeSocket()
|
|
595
|
+
const sessionId = 'test-session'
|
|
596
|
+
|
|
597
|
+
room.handleNewSession({
|
|
598
|
+
sessionId,
|
|
599
|
+
socket,
|
|
600
|
+
meta: undefined,
|
|
601
|
+
isReadonly: false,
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
room.handleMessage(sessionId, {
|
|
605
|
+
connectRequestId: 'connect-1',
|
|
606
|
+
lastServerClock: 0,
|
|
607
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
608
|
+
schema: room.serializedSchema,
|
|
609
|
+
type: 'connect',
|
|
610
|
+
})
|
|
611
|
+
;(socket.sendMessage as any).mockClear()
|
|
612
|
+
|
|
613
|
+
// Make multiple rapid external changes
|
|
614
|
+
const page1 = PageRecordType.create({
|
|
615
|
+
id: PageRecordType.createId('rapid_page_1'),
|
|
616
|
+
name: 'Rapid Page 1',
|
|
617
|
+
index: 'a2' as IndexKey,
|
|
618
|
+
})
|
|
619
|
+
const page2 = PageRecordType.create({
|
|
620
|
+
id: PageRecordType.createId('rapid_page_2'),
|
|
621
|
+
name: 'Rapid Page 2',
|
|
622
|
+
index: 'a3' as IndexKey,
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
storage.transaction((txn) => {
|
|
626
|
+
txn.set(page1.id, page1)
|
|
916
627
|
})
|
|
628
|
+
storage.transaction((txn) => {
|
|
629
|
+
txn.set(page2.id, page2)
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
// Wait for all microtasks to settle
|
|
633
|
+
await Promise.resolve()
|
|
634
|
+
|
|
635
|
+
// Client should have received patches for both changes
|
|
636
|
+
const calls = (socket.sendMessage as any).mock.calls
|
|
637
|
+
expect(calls.length).toBeGreaterThan(0)
|
|
638
|
+
|
|
639
|
+
// Collect all patches received
|
|
640
|
+
const allPatches: any[] = []
|
|
641
|
+
for (const call of calls) {
|
|
642
|
+
const msg = call[0]
|
|
643
|
+
if (msg.type === 'data') {
|
|
644
|
+
for (const item of msg.data) {
|
|
645
|
+
if (item.type === 'patch') {
|
|
646
|
+
allPatches.push(item)
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Should have received both pages in patches
|
|
653
|
+
const allDiffs = allPatches.reduce((acc, patch) => ({ ...acc, ...patch.diff }), {})
|
|
654
|
+
expect(allDiffs[page1.id]).toBeDefined()
|
|
655
|
+
expect(allDiffs[page2.id]).toBeDefined()
|
|
656
|
+
})
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
describe('Migration idempotency', () => {
|
|
660
|
+
it('running migrations twice does not cause issues', () => {
|
|
661
|
+
const serializedSchema = schema.serialize()
|
|
662
|
+
const oldSerializedSchema: SerializedSchemaV2 = {
|
|
663
|
+
schemaVersion: 2,
|
|
664
|
+
sequences: {
|
|
665
|
+
...serializedSchema.sequences,
|
|
666
|
+
'com.tldraw.shape.arrow': 0,
|
|
667
|
+
},
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
671
|
+
snapshot: makeSnapshot([...records, oldArrow as any], {
|
|
672
|
+
schema: oldSerializedSchema,
|
|
673
|
+
}),
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
// First migration (simulating what TLSyncRoom constructor does)
|
|
677
|
+
const _room1 = new TLSyncRoom<TLRecord, undefined>({
|
|
678
|
+
schema,
|
|
679
|
+
storage,
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
// Get state after first migration
|
|
683
|
+
const snapshotAfterFirst = storage.getSnapshot()
|
|
684
|
+
const arrowAfterFirst = snapshotAfterFirst.documents.find((r) => r.state.id === oldArrow.id)
|
|
685
|
+
?.state as TLArrowShape
|
|
686
|
+
|
|
687
|
+
// Verify migration happened
|
|
688
|
+
expect(arrowAfterFirst.props.labelColor).toBe('black')
|
|
689
|
+
|
|
690
|
+
// Create another room with the same storage (simulating a second migration attempt)
|
|
691
|
+
// This would happen if TestServer ran migrations before TLSyncRoom constructor
|
|
692
|
+
const _room2 = new TLSyncRoom<TLRecord, undefined>({
|
|
693
|
+
schema,
|
|
694
|
+
storage,
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
// Get state after second "migration"
|
|
698
|
+
const snapshotAfterSecond = storage.getSnapshot()
|
|
699
|
+
const arrowAfterSecond = snapshotAfterSecond.documents.find((r) => r.state.id === oldArrow.id)
|
|
700
|
+
?.state as TLArrowShape
|
|
701
|
+
|
|
702
|
+
// Should still have the migrated value
|
|
703
|
+
expect(arrowAfterSecond.props.labelColor).toBe('black')
|
|
704
|
+
|
|
705
|
+
// Document count should be the same
|
|
706
|
+
expect(snapshotAfterSecond.documents.length).toBe(snapshotAfterFirst.documents.length)
|
|
707
|
+
|
|
708
|
+
// Schema should be up to date
|
|
709
|
+
expect(snapshotAfterSecond.schema).toEqual(schema.serialize())
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
it('already migrated data is not modified again', () => {
|
|
713
|
+
// Start with current schema (no migration needed)
|
|
714
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
715
|
+
snapshot: makeSnapshot(records, {
|
|
716
|
+
documentClock: 10,
|
|
717
|
+
}),
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
const clockBefore = storage.getClock()
|
|
721
|
+
|
|
722
|
+
// Create room - should not modify anything since schema is current
|
|
723
|
+
const _room = new TLSyncRoom<TLRecord, undefined>({
|
|
724
|
+
schema,
|
|
725
|
+
storage,
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
// Clock should not have changed since no migration was needed
|
|
729
|
+
expect(storage.getClock()).toBe(clockBefore)
|
|
730
|
+
|
|
731
|
+
// Documents should be unchanged
|
|
732
|
+
expect(storage.getSnapshot().documents.length).toBe(2)
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
it('migration updates schema version in storage', () => {
|
|
736
|
+
const serializedSchema = schema.serialize()
|
|
737
|
+
const oldSerializedSchema: SerializedSchemaV2 = {
|
|
738
|
+
schemaVersion: 2,
|
|
739
|
+
sequences: {
|
|
740
|
+
...serializedSchema.sequences,
|
|
741
|
+
'com.tldraw.shape.arrow': 0,
|
|
742
|
+
},
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
746
|
+
snapshot: makeSnapshot([...records, oldArrow as any], {
|
|
747
|
+
schema: oldSerializedSchema,
|
|
748
|
+
}),
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
// Verify old schema before room creation
|
|
752
|
+
expect(storage.schema.get()).toEqual(oldSerializedSchema)
|
|
753
|
+
|
|
754
|
+
// Create room which triggers migration
|
|
755
|
+
const _room = new TLSyncRoom<TLRecord, undefined>({
|
|
756
|
+
schema,
|
|
757
|
+
storage,
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
// Schema should now be updated to current version
|
|
761
|
+
expect(storage.schema.get()).toEqual(schema.serialize())
|
|
762
|
+
})
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
describe('Protocol version handling', () => {
|
|
766
|
+
it('sets supportsStringAppend to false for protocol version 7', () => {
|
|
767
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
768
|
+
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
769
|
+
|
|
770
|
+
const socket = makeSocket()
|
|
771
|
+
const sessionId = 'v7-session'
|
|
772
|
+
|
|
773
|
+
room.handleNewSession({
|
|
774
|
+
sessionId,
|
|
775
|
+
socket,
|
|
776
|
+
meta: undefined,
|
|
777
|
+
isReadonly: false,
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
// Connect with protocol version 7 (which gets upgraded to 8 but with supportsStringAppend = false)
|
|
781
|
+
room.handleMessage(sessionId, {
|
|
782
|
+
connectRequestId: 'connect-1',
|
|
783
|
+
lastServerClock: 0,
|
|
784
|
+
protocolVersion: 7,
|
|
785
|
+
schema: room.serializedSchema,
|
|
786
|
+
type: 'connect',
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
const session = room.sessions.get(sessionId)
|
|
790
|
+
expect(session?.state).toBe('connected')
|
|
791
|
+
if (session?.state === 'connected') {
|
|
792
|
+
expect(session.supportsStringAppend).toBe(false)
|
|
793
|
+
}
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
it('sets supportsStringAppend to true for protocol version 8', () => {
|
|
797
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
798
|
+
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
799
|
+
|
|
800
|
+
const socket = makeSocket()
|
|
801
|
+
const sessionId = 'v8-session'
|
|
802
|
+
|
|
803
|
+
room.handleNewSession({
|
|
804
|
+
sessionId,
|
|
805
|
+
socket,
|
|
806
|
+
meta: undefined,
|
|
807
|
+
isReadonly: false,
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
room.handleMessage(sessionId, {
|
|
811
|
+
connectRequestId: 'connect-1',
|
|
812
|
+
lastServerClock: 0,
|
|
813
|
+
protocolVersion: getTlsyncProtocolVersion(), // version 8
|
|
814
|
+
schema: room.serializedSchema,
|
|
815
|
+
type: 'connect',
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
const session = room.sessions.get(sessionId)
|
|
819
|
+
expect(session?.state).toBe('connected')
|
|
820
|
+
if (session?.state === 'connected') {
|
|
821
|
+
expect(session.supportsStringAppend).toBe(true)
|
|
822
|
+
}
|
|
823
|
+
})
|
|
824
|
+
|
|
825
|
+
it('getCanEmitStringAppend returns false when any client lacks string append support', () => {
|
|
826
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
827
|
+
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
828
|
+
|
|
829
|
+
const socketV7 = makeSocket()
|
|
830
|
+
const socketV8 = makeSocket()
|
|
831
|
+
|
|
832
|
+
// Connect v8 client first
|
|
833
|
+
room.handleNewSession({
|
|
834
|
+
sessionId: 'v8-client',
|
|
835
|
+
socket: socketV8,
|
|
836
|
+
meta: undefined,
|
|
837
|
+
isReadonly: false,
|
|
838
|
+
})
|
|
839
|
+
room.handleMessage('v8-client', {
|
|
840
|
+
connectRequestId: 'connect-v8',
|
|
841
|
+
lastServerClock: 0,
|
|
842
|
+
protocolVersion: 8,
|
|
843
|
+
schema: room.serializedSchema,
|
|
844
|
+
type: 'connect',
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
// With only v8 client, should be true
|
|
848
|
+
expect(room.getCanEmitStringAppend()).toBe(true)
|
|
849
|
+
|
|
850
|
+
// Connect v7 client
|
|
851
|
+
room.handleNewSession({
|
|
852
|
+
sessionId: 'v7-client',
|
|
853
|
+
socket: socketV7,
|
|
854
|
+
meta: undefined,
|
|
855
|
+
isReadonly: false,
|
|
856
|
+
})
|
|
857
|
+
room.handleMessage('v7-client', {
|
|
858
|
+
connectRequestId: 'connect-v7',
|
|
859
|
+
lastServerClock: 0,
|
|
860
|
+
protocolVersion: 7,
|
|
861
|
+
schema: room.serializedSchema,
|
|
862
|
+
type: 'connect',
|
|
863
|
+
})
|
|
864
|
+
|
|
865
|
+
// With mixed clients, should be false
|
|
866
|
+
expect(room.getCanEmitStringAppend()).toBe(false)
|
|
867
|
+
})
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
describe('Presence store isolation', () => {
|
|
871
|
+
it('presence changes do not affect document clock', () => {
|
|
872
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
873
|
+
snapshot: makeSnapshot(records, { documentClock: 10 }),
|
|
874
|
+
})
|
|
875
|
+
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
876
|
+
|
|
877
|
+
const socket = makeSocket()
|
|
878
|
+
const sessionId = 'presence-test'
|
|
879
|
+
|
|
880
|
+
room.handleNewSession({
|
|
881
|
+
sessionId,
|
|
882
|
+
socket,
|
|
883
|
+
meta: undefined,
|
|
884
|
+
isReadonly: false,
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
room.handleMessage(sessionId, {
|
|
888
|
+
connectRequestId: 'connect-1',
|
|
889
|
+
lastServerClock: 10,
|
|
890
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
891
|
+
schema: room.serializedSchema,
|
|
892
|
+
type: 'connect',
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
const clockBefore = storage.getClock()
|
|
896
|
+
|
|
897
|
+
// Send presence update
|
|
898
|
+
room.handleMessage(sessionId, {
|
|
899
|
+
type: 'push',
|
|
900
|
+
clientClock: 1,
|
|
901
|
+
diff: undefined,
|
|
902
|
+
presence: [
|
|
903
|
+
'put',
|
|
904
|
+
InstancePresenceRecordType.create({
|
|
905
|
+
id: InstancePresenceRecordType.createId('presence-1'),
|
|
906
|
+
currentPageId: PageRecordType.createId('page_2'),
|
|
907
|
+
userId: 'user-1',
|
|
908
|
+
userName: 'Test User',
|
|
909
|
+
}),
|
|
910
|
+
],
|
|
911
|
+
} as TLPushRequest<TLRecord>)
|
|
912
|
+
|
|
913
|
+
// Document clock should not have changed
|
|
914
|
+
expect(storage.getClock()).toBe(clockBefore)
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
it('presence is stored in presenceStore, not document storage', () => {
|
|
918
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
919
|
+
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
920
|
+
|
|
921
|
+
const socket = makeSocket()
|
|
922
|
+
const sessionId = 'presence-test'
|
|
923
|
+
|
|
924
|
+
room.handleNewSession({
|
|
925
|
+
sessionId,
|
|
926
|
+
socket,
|
|
927
|
+
meta: undefined,
|
|
928
|
+
isReadonly: false,
|
|
929
|
+
})
|
|
930
|
+
|
|
931
|
+
room.handleMessage(sessionId, {
|
|
932
|
+
connectRequestId: 'connect-1',
|
|
933
|
+
lastServerClock: 0,
|
|
934
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
935
|
+
schema: room.serializedSchema,
|
|
936
|
+
type: 'connect',
|
|
937
|
+
})
|
|
938
|
+
|
|
939
|
+
const session = room.sessions.get(sessionId)
|
|
940
|
+
const presenceId = session?.presenceId
|
|
941
|
+
|
|
942
|
+
// Send presence update
|
|
943
|
+
room.handleMessage(sessionId, {
|
|
944
|
+
type: 'push',
|
|
945
|
+
clientClock: 1,
|
|
946
|
+
diff: undefined,
|
|
947
|
+
presence: [
|
|
948
|
+
'put',
|
|
949
|
+
InstancePresenceRecordType.create({
|
|
950
|
+
id: InstancePresenceRecordType.createId('any'),
|
|
951
|
+
currentPageId: PageRecordType.createId('page_2'),
|
|
952
|
+
userId: 'user-1',
|
|
953
|
+
userName: 'Test User',
|
|
954
|
+
}),
|
|
955
|
+
],
|
|
956
|
+
} as TLPushRequest<TLRecord>)
|
|
957
|
+
|
|
958
|
+
// Presence should be in presenceStore
|
|
959
|
+
expect(room.presenceStore.get(presenceId!)).toBeDefined()
|
|
960
|
+
|
|
961
|
+
// Presence should NOT be in document storage
|
|
962
|
+
storage.transaction((txn) => {
|
|
963
|
+
expect(txn.get(presenceId!)).toBeUndefined()
|
|
964
|
+
})
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
it('presence is removed from presenceStore when session is removed', async () => {
|
|
968
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
969
|
+
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
970
|
+
|
|
971
|
+
const socket = makeSocket()
|
|
972
|
+
const sessionId = 'presence-test'
|
|
973
|
+
|
|
974
|
+
room.handleNewSession({
|
|
975
|
+
sessionId,
|
|
976
|
+
socket,
|
|
977
|
+
meta: undefined,
|
|
978
|
+
isReadonly: false,
|
|
979
|
+
})
|
|
980
|
+
|
|
981
|
+
room.handleMessage(sessionId, {
|
|
982
|
+
connectRequestId: 'connect-1',
|
|
983
|
+
lastServerClock: 0,
|
|
984
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
985
|
+
schema: room.serializedSchema,
|
|
986
|
+
type: 'connect',
|
|
987
|
+
})
|
|
988
|
+
|
|
989
|
+
const session = room.sessions.get(sessionId)!
|
|
990
|
+
const presenceId = session.presenceId!
|
|
991
|
+
|
|
992
|
+
// Send presence
|
|
993
|
+
room.handleMessage(sessionId, {
|
|
994
|
+
type: 'push',
|
|
995
|
+
clientClock: 1,
|
|
996
|
+
diff: undefined,
|
|
997
|
+
presence: [
|
|
998
|
+
'put',
|
|
999
|
+
InstancePresenceRecordType.create({
|
|
1000
|
+
id: InstancePresenceRecordType.createId('any'),
|
|
1001
|
+
currentPageId: PageRecordType.createId('page_2'),
|
|
1002
|
+
userId: 'user-1',
|
|
1003
|
+
userName: 'Test User',
|
|
1004
|
+
}),
|
|
1005
|
+
],
|
|
1006
|
+
} as TLPushRequest<TLRecord>)
|
|
1007
|
+
|
|
1008
|
+
expect(room.presenceStore.get(presenceId)).toBeDefined()
|
|
1009
|
+
|
|
1010
|
+
// Close the session
|
|
1011
|
+
room.rejectSession(sessionId)
|
|
1012
|
+
|
|
1013
|
+
// Presence should be removed
|
|
1014
|
+
expect(room.presenceStore.get(presenceId)).toBeUndefined()
|
|
1015
|
+
})
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
describe('Client with future clock', () => {
|
|
1019
|
+
it('handles client with lastServerClock greater than current documentClock', () => {
|
|
1020
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1021
|
+
snapshot: makeSnapshot(records, { documentClock: 10 }),
|
|
1022
|
+
})
|
|
1023
|
+
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
1024
|
+
|
|
1025
|
+
const socket = makeSocket()
|
|
1026
|
+
const sessionId = 'future-clock-client'
|
|
1027
|
+
|
|
1028
|
+
room.handleNewSession({
|
|
1029
|
+
sessionId,
|
|
1030
|
+
socket,
|
|
1031
|
+
meta: undefined,
|
|
1032
|
+
isReadonly: false,
|
|
1033
|
+
})
|
|
1034
|
+
|
|
1035
|
+
// Client claims to have clock 100, but server is only at 10
|
|
1036
|
+
room.handleMessage(sessionId, {
|
|
1037
|
+
connectRequestId: 'connect-1',
|
|
1038
|
+
lastServerClock: 100, // Future clock!
|
|
1039
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
1040
|
+
schema: room.serializedSchema,
|
|
1041
|
+
type: 'connect',
|
|
1042
|
+
})
|
|
1043
|
+
|
|
1044
|
+
// Session should still connect successfully
|
|
1045
|
+
expect(room.sessions.get(sessionId)?.state).toBe('connected')
|
|
1046
|
+
|
|
1047
|
+
// Check the connect response
|
|
1048
|
+
const connectResponse = socket.__lastMessage
|
|
1049
|
+
expect(connectResponse?.type).toBe('connect')
|
|
1050
|
+
if (connectResponse?.type === 'connect') {
|
|
1051
|
+
// Should receive wipe_all to reset client state
|
|
1052
|
+
expect(connectResponse.hydrationType).toBe('wipe_all')
|
|
1053
|
+
// Should receive all documents
|
|
1054
|
+
expect(Object.keys(connectResponse.diff).length).toBe(2)
|
|
1055
|
+
}
|
|
1056
|
+
})
|
|
1057
|
+
|
|
1058
|
+
it('provides all documents when client has future clock', () => {
|
|
1059
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1060
|
+
snapshot: makeSnapshot(records, {
|
|
1061
|
+
documentClock: 5,
|
|
1062
|
+
documents: [
|
|
1063
|
+
{ state: records[0], lastChangedClock: 3 },
|
|
1064
|
+
{ state: records[1], lastChangedClock: 5 },
|
|
1065
|
+
],
|
|
1066
|
+
}),
|
|
1067
|
+
})
|
|
1068
|
+
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
1069
|
+
|
|
1070
|
+
const socket = makeSocket()
|
|
1071
|
+
const sessionId = 'future-client'
|
|
1072
|
+
|
|
1073
|
+
room.handleNewSession({
|
|
1074
|
+
sessionId,
|
|
1075
|
+
socket,
|
|
1076
|
+
meta: undefined,
|
|
1077
|
+
isReadonly: false,
|
|
1078
|
+
})
|
|
1079
|
+
|
|
1080
|
+
room.handleMessage(sessionId, {
|
|
1081
|
+
connectRequestId: 'connect-1',
|
|
1082
|
+
lastServerClock: 999,
|
|
1083
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
1084
|
+
schema: room.serializedSchema,
|
|
1085
|
+
type: 'connect',
|
|
1086
|
+
})
|
|
1087
|
+
|
|
1088
|
+
const connectResponse = socket.__lastMessage
|
|
1089
|
+
expect(connectResponse?.type).toBe('connect')
|
|
1090
|
+
if (connectResponse?.type === 'connect') {
|
|
1091
|
+
// Both documents should be included regardless of their lastChangedClock
|
|
1092
|
+
expect(connectResponse.diff[records[0].id]).toBeDefined()
|
|
1093
|
+
expect(connectResponse.diff[records[1].id]).toBeDefined()
|
|
1094
|
+
}
|
|
1095
|
+
})
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
describe('Loading snapshot during active session', () => {
|
|
1099
|
+
it('broadcasts changes when snapshot is loaded via storage transaction', async () => {
|
|
1100
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
1101
|
+
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
1102
|
+
|
|
1103
|
+
const socket = makeSocket()
|
|
1104
|
+
const sessionId = 'active-session'
|
|
1105
|
+
|
|
1106
|
+
room.handleNewSession({
|
|
1107
|
+
sessionId,
|
|
1108
|
+
socket,
|
|
1109
|
+
meta: undefined,
|
|
1110
|
+
isReadonly: false,
|
|
1111
|
+
})
|
|
1112
|
+
|
|
1113
|
+
room.handleMessage(sessionId, {
|
|
1114
|
+
connectRequestId: 'connect-1',
|
|
1115
|
+
lastServerClock: 0,
|
|
1116
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
1117
|
+
schema: room.serializedSchema,
|
|
1118
|
+
type: 'connect',
|
|
1119
|
+
})
|
|
1120
|
+
;(socket.sendMessage as any).mockClear()
|
|
1121
|
+
|
|
1122
|
+
// Load a new snapshot with additional page
|
|
1123
|
+
const newPage = PageRecordType.create({
|
|
1124
|
+
id: PageRecordType.createId('new_page'),
|
|
1125
|
+
name: 'New Page',
|
|
1126
|
+
index: 'a2' as IndexKey,
|
|
1127
|
+
})
|
|
1128
|
+
|
|
1129
|
+
storage.transaction((txn) => {
|
|
1130
|
+
txn.set(newPage.id, newPage)
|
|
1131
|
+
})
|
|
1132
|
+
|
|
1133
|
+
// Wait for onChange to fire
|
|
1134
|
+
await Promise.resolve()
|
|
1135
|
+
|
|
1136
|
+
// Client should have received the new page
|
|
1137
|
+
const calls = (socket.sendMessage as any).mock.calls
|
|
1138
|
+
expect(calls.length).toBeGreaterThan(0)
|
|
1139
|
+
|
|
1140
|
+
const allPatches: any[] = []
|
|
1141
|
+
for (const call of calls) {
|
|
1142
|
+
const msg = call[0]
|
|
1143
|
+
if (msg.type === 'data') {
|
|
1144
|
+
for (const item of msg.data) {
|
|
1145
|
+
if (item.type === 'patch') {
|
|
1146
|
+
allPatches.push(item)
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const allDiffs = allPatches.reduce((acc, patch) => ({ ...acc, ...patch.diff }), {})
|
|
1153
|
+
expect(allDiffs[newPage.id]).toBeDefined()
|
|
1154
|
+
})
|
|
1155
|
+
|
|
1156
|
+
it('handles document deletion during active session', async () => {
|
|
1157
|
+
const extraPage = PageRecordType.create({
|
|
1158
|
+
id: PageRecordType.createId('extra_page'),
|
|
1159
|
+
name: 'Extra Page',
|
|
1160
|
+
index: 'a2' as IndexKey,
|
|
1161
|
+
})
|
|
1162
|
+
|
|
1163
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1164
|
+
snapshot: makeSnapshot([...records, extraPage]),
|
|
1165
|
+
})
|
|
1166
|
+
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
1167
|
+
|
|
1168
|
+
const socket = makeSocket()
|
|
1169
|
+
const sessionId = 'active-session'
|
|
1170
|
+
|
|
1171
|
+
room.handleNewSession({
|
|
1172
|
+
sessionId,
|
|
1173
|
+
socket,
|
|
1174
|
+
meta: undefined,
|
|
1175
|
+
isReadonly: false,
|
|
1176
|
+
})
|
|
1177
|
+
|
|
1178
|
+
room.handleMessage(sessionId, {
|
|
1179
|
+
connectRequestId: 'connect-1',
|
|
1180
|
+
lastServerClock: 0,
|
|
1181
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
1182
|
+
schema: room.serializedSchema,
|
|
1183
|
+
type: 'connect',
|
|
1184
|
+
})
|
|
1185
|
+
;(socket.sendMessage as any).mockClear()
|
|
1186
|
+
|
|
1187
|
+
// Delete the extra page via storage
|
|
1188
|
+
storage.transaction((txn) => {
|
|
1189
|
+
txn.delete(extraPage.id)
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1192
|
+
await Promise.resolve()
|
|
1193
|
+
|
|
1194
|
+
// Client should have received the delete
|
|
1195
|
+
const calls = (socket.sendMessage as any).mock.calls
|
|
1196
|
+
expect(calls.length).toBeGreaterThan(0)
|
|
1197
|
+
|
|
1198
|
+
const allPatches: any[] = []
|
|
1199
|
+
for (const call of calls) {
|
|
1200
|
+
const msg = call[0]
|
|
1201
|
+
if (msg.type === 'data') {
|
|
1202
|
+
for (const item of msg.data) {
|
|
1203
|
+
if (item.type === 'patch') {
|
|
1204
|
+
allPatches.push(item)
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const allDiffs = allPatches.reduce((acc, patch) => ({ ...acc, ...patch.diff }), {})
|
|
1211
|
+
expect(allDiffs[extraPage.id]).toEqual(['remove'])
|
|
1212
|
+
})
|
|
1213
|
+
})
|
|
1214
|
+
|
|
1215
|
+
describe('Invalid record handling', () => {
|
|
1216
|
+
it('rejects session when push contains record with unknown type', () => {
|
|
1217
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
1218
|
+
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
1219
|
+
|
|
1220
|
+
const socket = makeSocket()
|
|
1221
|
+
const sessionId = 'invalid-record-session'
|
|
1222
|
+
|
|
1223
|
+
room.handleNewSession({
|
|
1224
|
+
sessionId,
|
|
1225
|
+
socket,
|
|
1226
|
+
meta: undefined,
|
|
1227
|
+
isReadonly: false,
|
|
1228
|
+
})
|
|
1229
|
+
|
|
1230
|
+
room.handleMessage(sessionId, {
|
|
1231
|
+
connectRequestId: 'connect-1',
|
|
1232
|
+
lastServerClock: 0,
|
|
1233
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
1234
|
+
schema: room.serializedSchema,
|
|
1235
|
+
type: 'connect',
|
|
1236
|
+
})
|
|
1237
|
+
|
|
1238
|
+
expect(room.sessions.get(sessionId)?.state).toBe('connected')
|
|
1239
|
+
|
|
1240
|
+
// Try to push a record with an unknown type
|
|
1241
|
+
room.handleMessage(sessionId, {
|
|
1242
|
+
type: 'push',
|
|
1243
|
+
clientClock: 1,
|
|
1244
|
+
diff: {
|
|
1245
|
+
'unknown:record': [
|
|
1246
|
+
'put',
|
|
1247
|
+
{
|
|
1248
|
+
id: 'unknown:record',
|
|
1249
|
+
typeName: 'unknown_type_that_does_not_exist',
|
|
1250
|
+
} as any,
|
|
1251
|
+
],
|
|
1252
|
+
},
|
|
1253
|
+
} as TLPushRequest<TLRecord>)
|
|
1254
|
+
|
|
1255
|
+
// Session should be rejected/removed
|
|
1256
|
+
const session = room.sessions.get(sessionId)
|
|
1257
|
+
expect(session?.state === 'connected').toBe(false)
|
|
1258
|
+
})
|
|
1259
|
+
})
|
|
1260
|
+
|
|
1261
|
+
describe('Migration and patch handling', () => {
|
|
1262
|
+
it('successfully patches arrow shape with current schema', () => {
|
|
1263
|
+
// Create a document in the room first
|
|
1264
|
+
const existingArrow: TLArrowShape = {
|
|
1265
|
+
typeName: 'shape',
|
|
1266
|
+
type: 'arrow',
|
|
1267
|
+
id: 'shape:existing_arrow' as TLShapeId,
|
|
1268
|
+
index: ZERO_INDEX_KEY,
|
|
1269
|
+
isLocked: false,
|
|
1270
|
+
parentId: PageRecordType.createId(),
|
|
1271
|
+
rotation: 0,
|
|
1272
|
+
x: 0,
|
|
1273
|
+
y: 0,
|
|
1274
|
+
opacity: 1,
|
|
1275
|
+
props: {
|
|
1276
|
+
kind: 'arc',
|
|
1277
|
+
elbowMidPoint: 0.5,
|
|
1278
|
+
dash: 'draw',
|
|
1279
|
+
size: 'm',
|
|
1280
|
+
fill: 'none',
|
|
1281
|
+
color: 'black',
|
|
1282
|
+
labelColor: 'black',
|
|
1283
|
+
bend: 0,
|
|
1284
|
+
start: { x: 0, y: 0 },
|
|
1285
|
+
end: { x: 0, y: 0 },
|
|
1286
|
+
arrowheadStart: 'none',
|
|
1287
|
+
arrowheadEnd: 'arrow',
|
|
1288
|
+
richText: { type: 'doc', content: [] },
|
|
1289
|
+
font: 'draw',
|
|
1290
|
+
labelPosition: 0.5,
|
|
1291
|
+
scale: 1,
|
|
1292
|
+
},
|
|
1293
|
+
meta: {},
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1297
|
+
snapshot: makeSnapshot([...records, existingArrow]),
|
|
1298
|
+
})
|
|
1299
|
+
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
1300
|
+
|
|
1301
|
+
const socket = makeSocket()
|
|
1302
|
+
const sessionId = 'patch-migration-session'
|
|
1303
|
+
|
|
1304
|
+
room.handleNewSession({
|
|
1305
|
+
sessionId,
|
|
1306
|
+
socket,
|
|
1307
|
+
meta: undefined,
|
|
1308
|
+
isReadonly: false,
|
|
1309
|
+
})
|
|
1310
|
+
|
|
1311
|
+
// Connect with current schema
|
|
1312
|
+
room.handleMessage(sessionId, {
|
|
1313
|
+
connectRequestId: 'connect-1',
|
|
1314
|
+
lastServerClock: 0,
|
|
1315
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
1316
|
+
schema: room.serializedSchema,
|
|
1317
|
+
type: 'connect',
|
|
1318
|
+
})
|
|
1319
|
+
|
|
1320
|
+
expect(room.sessions.get(sessionId)?.state).toBe('connected')
|
|
1321
|
+
|
|
1322
|
+
// Patch the arrow - this should work normally
|
|
1323
|
+
room.handleMessage(sessionId, {
|
|
1324
|
+
type: 'push',
|
|
1325
|
+
clientClock: 1,
|
|
1326
|
+
diff: {
|
|
1327
|
+
[existingArrow.id]: ['patch', { props: ['patch', { color: ['put', 'red'] }] }],
|
|
1328
|
+
},
|
|
1329
|
+
} as TLPushRequest<TLRecord>)
|
|
1330
|
+
|
|
1331
|
+
// Session should still be connected after valid patch
|
|
1332
|
+
expect(room.sessions.get(sessionId)?.state).toBe('connected')
|
|
1333
|
+
|
|
1334
|
+
// Verify the patch was applied
|
|
1335
|
+
const patchedArrow = storage.documents.get(existingArrow.id)?.state as TLArrowShape
|
|
1336
|
+
expect(patchedArrow.props.color).toBe('red')
|
|
1337
|
+
})
|
|
1338
|
+
|
|
1339
|
+
it('rejects client with incompatible schema version that lacks down migrations', () => {
|
|
1340
|
+
// Use an old schema version that can't connect (arrow v0 has no down migration)
|
|
1341
|
+
const serializedSchema = schema.serialize()
|
|
1342
|
+
const oldSerializedSchema: SerializedSchemaV2 = {
|
|
1343
|
+
schemaVersion: 2,
|
|
1344
|
+
sequences: {
|
|
1345
|
+
...serializedSchema.sequences,
|
|
1346
|
+
// Set arrow shape to version 0, which has retired down migrations
|
|
1347
|
+
'com.tldraw.shape.arrow': 0,
|
|
1348
|
+
},
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
1352
|
+
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
1353
|
+
|
|
1354
|
+
const socket = makeSocket()
|
|
1355
|
+
const sessionId = 'old-client-session'
|
|
1356
|
+
|
|
1357
|
+
room.handleNewSession({
|
|
1358
|
+
sessionId,
|
|
1359
|
+
socket,
|
|
1360
|
+
meta: undefined,
|
|
1361
|
+
isReadonly: false,
|
|
1362
|
+
})
|
|
1363
|
+
|
|
1364
|
+
// Connect with old schema - this should fail because arrow v1 migration has no down function
|
|
1365
|
+
room.handleMessage(sessionId, {
|
|
1366
|
+
connectRequestId: 'connect-1',
|
|
1367
|
+
lastServerClock: 0,
|
|
1368
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
1369
|
+
schema: oldSerializedSchema,
|
|
1370
|
+
type: 'connect',
|
|
1371
|
+
})
|
|
1372
|
+
|
|
1373
|
+
// Session should not be connected - it was rejected due to incompatible schema
|
|
1374
|
+
expect(room.sessions.get(sessionId)?.state).not.toBe('connected')
|
|
1375
|
+
|
|
1376
|
+
// Socket should be closed with CLIENT_TOO_OLD
|
|
1377
|
+
expect(socket.isOpen).toBe(false)
|
|
1378
|
+
})
|
|
1379
|
+
|
|
1380
|
+
it('accepts client with same schema version', () => {
|
|
1381
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
1382
|
+
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
1383
|
+
|
|
1384
|
+
const socket = makeSocket()
|
|
1385
|
+
const sessionId = 'current-client-session'
|
|
1386
|
+
|
|
1387
|
+
room.handleNewSession({
|
|
1388
|
+
sessionId,
|
|
1389
|
+
socket,
|
|
1390
|
+
meta: undefined,
|
|
1391
|
+
isReadonly: false,
|
|
1392
|
+
})
|
|
1393
|
+
|
|
1394
|
+
// Connect with same schema
|
|
1395
|
+
room.handleMessage(sessionId, {
|
|
1396
|
+
connectRequestId: 'connect-1',
|
|
1397
|
+
lastServerClock: 0,
|
|
1398
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
1399
|
+
schema: room.serializedSchema,
|
|
1400
|
+
type: 'connect',
|
|
1401
|
+
})
|
|
1402
|
+
|
|
1403
|
+
// Session should be connected
|
|
1404
|
+
expect(room.sessions.get(sessionId)?.state).toBe('connected')
|
|
917
1405
|
})
|
|
918
1406
|
})
|