@tldraw/sync-core 4.2.2 → 4.2.3
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 +58 -483
- package/dist-cjs/index.js +3 -13
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/TLSocketRoom.js +69 -117
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +0 -7
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +688 -357
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-cjs/lib/chunk.js +2 -2
- package/dist-cjs/lib/chunk.js.map +1 -1
- package/dist-esm/index.d.mts +58 -483
- package/dist-esm/index.mjs +5 -20
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/TLSocketRoom.mjs +70 -121
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +0 -7
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +702 -370
- package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
- package/dist-esm/lib/chunk.mjs +2 -2
- package/dist-esm/lib/chunk.mjs.map +1 -1
- package/package.json +11 -12
- package/src/index.ts +3 -32
- package/src/lib/ClientWebSocketAdapter.test.ts +0 -3
- package/src/lib/RoomSession.test.ts +0 -1
- package/src/lib/RoomSession.ts +0 -2
- package/src/lib/TLSocketRoom.ts +114 -228
- package/src/lib/TLSyncClient.ts +0 -12
- package/src/lib/TLSyncRoom.ts +913 -473
- package/src/lib/chunk.ts +2 -2
- package/src/test/FuzzEditor.ts +5 -4
- package/src/test/TLSocketRoom.test.ts +49 -255
- package/src/test/TLSyncRoom.test.ts +534 -1024
- package/src/test/TestServer.ts +1 -12
- package/src/test/customMessages.test.ts +1 -1
- package/src/test/presenceMode.test.ts +6 -6
- package/src/test/pruneTombstones.test.ts +178 -0
- package/src/test/syncFuzz.test.ts +4 -2
- package/src/test/upgradeDowngrade.test.ts +8 -290
- package/src/test/validation.test.ts +10 -15
- package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js +0 -55
- package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js.map +0 -7
- package/dist-cjs/lib/InMemorySyncStorage.js +0 -287
- package/dist-cjs/lib/InMemorySyncStorage.js.map +0 -7
- package/dist-cjs/lib/MicrotaskNotifier.js +0 -50
- package/dist-cjs/lib/MicrotaskNotifier.js.map +0 -7
- package/dist-cjs/lib/NodeSqliteWrapper.js +0 -48
- package/dist-cjs/lib/NodeSqliteWrapper.js.map +0 -7
- package/dist-cjs/lib/SQLiteSyncStorage.js +0 -428
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +0 -7
- package/dist-cjs/lib/TLSyncStorage.js +0 -76
- package/dist-cjs/lib/TLSyncStorage.js.map +0 -7
- package/dist-cjs/lib/recordDiff.js +0 -52
- package/dist-cjs/lib/recordDiff.js.map +0 -7
- package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs +0 -35
- package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs.map +0 -7
- package/dist-esm/lib/InMemorySyncStorage.mjs +0 -272
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +0 -7
- package/dist-esm/lib/MicrotaskNotifier.mjs +0 -30
- package/dist-esm/lib/MicrotaskNotifier.mjs.map +0 -7
- package/dist-esm/lib/NodeSqliteWrapper.mjs +0 -28
- package/dist-esm/lib/NodeSqliteWrapper.mjs.map +0 -7
- package/dist-esm/lib/SQLiteSyncStorage.mjs +0 -414
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +0 -7
- package/dist-esm/lib/TLSyncStorage.mjs +0 -56
- package/dist-esm/lib/TLSyncStorage.mjs.map +0 -7
- package/dist-esm/lib/recordDiff.mjs +0 -32
- package/dist-esm/lib/recordDiff.mjs.map +0 -7
- package/src/lib/DurableObjectSqliteSyncWrapper.ts +0 -95
- package/src/lib/InMemorySyncStorage.ts +0 -387
- package/src/lib/MicrotaskNotifier.test.ts +0 -429
- package/src/lib/MicrotaskNotifier.ts +0 -38
- package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
- package/src/lib/NodeSqliteSyncWrapper.test.ts +0 -272
- package/src/lib/NodeSqliteWrapper.ts +0 -99
- package/src/lib/SQLiteSyncStorage.ts +0 -627
- package/src/lib/TLSyncStorage.ts +0 -216
- package/src/lib/computeTombstonePruning.test.ts +0 -352
- package/src/lib/recordDiff.ts +0 -73
- package/src/test/InMemorySyncStorage.test.ts +0 -1684
- package/src/test/SQLiteSyncStorage.test.ts +0 -1378
|
@@ -9,14 +9,21 @@ import {
|
|
|
9
9
|
TLArrowShapeProps,
|
|
10
10
|
TLBaseShape,
|
|
11
11
|
TLDOCUMENT_ID,
|
|
12
|
+
TLDocument,
|
|
13
|
+
TLPage,
|
|
12
14
|
TLRecord,
|
|
13
15
|
TLShapeId,
|
|
14
16
|
createTLSchema,
|
|
15
17
|
} from '@tldraw/tlschema'
|
|
16
|
-
import { IndexKey, ZERO_INDEX_KEY, mockUniqueId, sortById } from '@tldraw/utils'
|
|
18
|
+
import { IndexKey, ZERO_INDEX_KEY, mockUniqueId, promiseWithResolve, sortById } from '@tldraw/utils'
|
|
17
19
|
import { vi } from 'vitest'
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
+
import {
|
|
21
|
+
MAX_TOMBSTONES,
|
|
22
|
+
RoomSnapshot,
|
|
23
|
+
TLRoomSocket,
|
|
24
|
+
TLSyncRoom,
|
|
25
|
+
TOMBSTONE_PRUNE_BUFFER_SIZE,
|
|
26
|
+
} from '../lib/TLSyncRoom'
|
|
20
27
|
import {
|
|
21
28
|
TLConnectRequest,
|
|
22
29
|
TLPushRequest,
|
|
@@ -40,7 +47,6 @@ const makeSnapshot = (records: TLRecord[], others: Partial<RoomSnapshot> = {}) =
|
|
|
40
47
|
documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
|
|
41
48
|
clock: 0,
|
|
42
49
|
documentClock: 0,
|
|
43
|
-
schema: schema.serialize(),
|
|
44
50
|
...others,
|
|
45
51
|
})
|
|
46
52
|
|
|
@@ -92,21 +98,63 @@ const oldArrow: TLBaseShape<'arrow', Omit<TLArrowShapeProps, 'labelColor'>> = {
|
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
describe('TLSyncRoom', () => {
|
|
95
|
-
it('can be constructed with a
|
|
96
|
-
const
|
|
97
|
-
|
|
101
|
+
it('can be constructed with a schema alone', () => {
|
|
102
|
+
const room = new TLSyncRoom<any, undefined>({ schema })
|
|
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>({
|
|
98
110
|
schema,
|
|
99
|
-
|
|
111
|
+
snapshot: makeSnapshot(records),
|
|
100
112
|
})
|
|
101
113
|
|
|
102
114
|
expect(
|
|
103
|
-
|
|
115
|
+
room
|
|
104
116
|
.getSnapshot()
|
|
105
117
|
.documents.map((r) => r.state)
|
|
106
118
|
.sort(sortById)
|
|
107
119
|
).toEqual(records)
|
|
108
120
|
|
|
109
|
-
expect(
|
|
121
|
+
expect(room.getSnapshot().documents.map((r) => r.lastChangedClock)).toEqual([0, 0])
|
|
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)
|
|
110
158
|
})
|
|
111
159
|
|
|
112
160
|
it('migrates the snapshot if it is dealing with old data', () => {
|
|
@@ -119,50 +167,40 @@ describe('TLSyncRoom', () => {
|
|
|
119
167
|
},
|
|
120
168
|
}
|
|
121
169
|
|
|
122
|
-
const
|
|
123
|
-
|
|
170
|
+
const room = new TLSyncRoom({
|
|
171
|
+
schema,
|
|
172
|
+
snapshot: makeSnapshot([...records, oldArrow], {
|
|
124
173
|
schema: oldSerializedSchema,
|
|
125
174
|
}),
|
|
126
175
|
})
|
|
127
|
-
const _room = new TLSyncRoom<TLRecord, undefined>({
|
|
128
|
-
schema,
|
|
129
|
-
storage,
|
|
130
|
-
})
|
|
131
176
|
|
|
132
|
-
const arrow =
|
|
177
|
+
const arrow = room.getSnapshot().documents.find((r) => r.state.id === oldArrow.id)
|
|
133
178
|
?.state as TLArrowShape
|
|
134
179
|
expect(arrow.props.labelColor).toBe('black')
|
|
135
180
|
})
|
|
136
181
|
|
|
137
|
-
it('filters out instance state records
|
|
138
|
-
const schema = createTLSchema()
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
schema.types.instance.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}),
|
|
156
|
-
],
|
|
157
|
-
{
|
|
158
|
-
schema: oldSchema,
|
|
159
|
-
}
|
|
160
|
-
),
|
|
182
|
+
it('filters out instance state records', () => {
|
|
183
|
+
const schema = createTLSchema({ shapes: {}, bindings: {} })
|
|
184
|
+
const room = new TLSyncRoom({
|
|
185
|
+
schema,
|
|
186
|
+
snapshot: makeSnapshot([
|
|
187
|
+
...records,
|
|
188
|
+
schema.types.instance.create({
|
|
189
|
+
currentPageId: PageRecordType.createId('page_1'),
|
|
190
|
+
id: schema.types.instance.createId('instance_1'),
|
|
191
|
+
}),
|
|
192
|
+
InstancePageStateRecordType.create({
|
|
193
|
+
id: InstancePageStateRecordType.createId(PageRecordType.createId('page_1')),
|
|
194
|
+
pageId: PageRecordType.createId('page_1'),
|
|
195
|
+
}),
|
|
196
|
+
CameraRecordType.create({
|
|
197
|
+
id: CameraRecordType.createId('camera_1'),
|
|
198
|
+
}),
|
|
199
|
+
]),
|
|
161
200
|
})
|
|
162
|
-
const _room = new TLSyncRoom({ schema, storage })
|
|
163
201
|
|
|
164
202
|
expect(
|
|
165
|
-
|
|
203
|
+
room
|
|
166
204
|
.getSnapshot()
|
|
167
205
|
.documents.map((r) => r.state)
|
|
168
206
|
.sort(sortById)
|
|
@@ -185,17 +223,349 @@ function makeSocket(): MockSocket {
|
|
|
185
223
|
return socket
|
|
186
224
|
}
|
|
187
225
|
|
|
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
|
+
|
|
188
558
|
describe('isReadonly', () => {
|
|
189
559
|
const sessionAId = 'sessionA'
|
|
190
560
|
const sessionBId = 'sessionB'
|
|
191
|
-
let
|
|
192
|
-
let room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
561
|
+
let room = new TLSyncRoom<TLRecord, undefined>({ schema, snapshot: makeSnapshot(records) })
|
|
193
562
|
let socketA = makeSocket()
|
|
194
563
|
let socketB = makeSocket()
|
|
195
|
-
const getDoc = (id: string) => storage.documents.get(id)?.state
|
|
196
564
|
function init(snapshot?: RoomSnapshot) {
|
|
197
|
-
|
|
198
|
-
|
|
565
|
+
room = new TLSyncRoom<TLRecord, undefined>({
|
|
566
|
+
schema,
|
|
567
|
+
snapshot: snapshot ?? makeSnapshot(records),
|
|
568
|
+
})
|
|
199
569
|
socketA = makeSocket()
|
|
200
570
|
socketB = makeSocket()
|
|
201
571
|
room.handleNewSession({
|
|
@@ -253,7 +623,7 @@ describe('isReadonly', () => {
|
|
|
253
623
|
// sessionA is readonly
|
|
254
624
|
room.handleMessage(sessionAId, push)
|
|
255
625
|
|
|
256
|
-
expect(
|
|
626
|
+
expect(room.documents.get('page:page_3')?.state).toBe(undefined)
|
|
257
627
|
// should tell the session to discard it
|
|
258
628
|
expect(socketA.__lastMessage).toMatchInlineSnapshot(`
|
|
259
629
|
{
|
|
@@ -261,7 +631,7 @@ describe('isReadonly', () => {
|
|
|
261
631
|
{
|
|
262
632
|
"action": "discard",
|
|
263
633
|
"clientClock": 0,
|
|
264
|
-
"serverClock":
|
|
634
|
+
"serverClock": 1,
|
|
265
635
|
"type": "push_result",
|
|
266
636
|
},
|
|
267
637
|
],
|
|
@@ -274,7 +644,7 @@ describe('isReadonly', () => {
|
|
|
274
644
|
// sessionB is not readonly
|
|
275
645
|
room.handleMessage(sessionBId, push)
|
|
276
646
|
|
|
277
|
-
expect(
|
|
647
|
+
expect(room.documents.get('page:page_3')?.state).not.toBe(undefined)
|
|
278
648
|
|
|
279
649
|
// should tell the session to commit it
|
|
280
650
|
expect(socketB.__lastMessage).toMatchInlineSnapshot(`
|
|
@@ -283,7 +653,7 @@ describe('isReadonly', () => {
|
|
|
283
653
|
{
|
|
284
654
|
"action": "commit",
|
|
285
655
|
"clientClock": 0,
|
|
286
|
-
"serverClock":
|
|
656
|
+
"serverClock": 2,
|
|
287
657
|
"type": "push_result",
|
|
288
658
|
},
|
|
289
659
|
],
|
|
@@ -318,7 +688,7 @@ describe('isReadonly', () => {
|
|
|
318
688
|
{
|
|
319
689
|
"action": "commit",
|
|
320
690
|
"clientClock": 0,
|
|
321
|
-
"serverClock":
|
|
691
|
+
"serverClock": 1,
|
|
322
692
|
"type": "push_result",
|
|
323
693
|
},
|
|
324
694
|
],
|
|
@@ -354,7 +724,7 @@ describe('isReadonly', () => {
|
|
|
354
724
|
},
|
|
355
725
|
],
|
|
356
726
|
},
|
|
357
|
-
"serverClock":
|
|
727
|
+
"serverClock": 1,
|
|
358
728
|
"type": "patch",
|
|
359
729
|
},
|
|
360
730
|
],
|
|
@@ -367,14 +737,16 @@ describe('isReadonly', () => {
|
|
|
367
737
|
it('can load snapshot without documentClock field', () => {
|
|
368
738
|
const legacySnapshot = makeLegacySnapshot(records)
|
|
369
739
|
|
|
370
|
-
const
|
|
371
|
-
|
|
740
|
+
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
741
|
+
schema,
|
|
742
|
+
snapshot: legacySnapshot,
|
|
743
|
+
})
|
|
372
744
|
|
|
373
745
|
// Room should load successfully without errors
|
|
374
|
-
expect(
|
|
746
|
+
expect(room.getSnapshot().documents.length).toBe(2)
|
|
375
747
|
|
|
376
748
|
// documentClock should be calculated from existing data
|
|
377
|
-
const snapshot =
|
|
749
|
+
const snapshot = room.getSnapshot()
|
|
378
750
|
expect(snapshot.documentClock).toBe(0) // max lastChangedClock from documents
|
|
379
751
|
})
|
|
380
752
|
|
|
@@ -386,10 +758,12 @@ describe('isReadonly', () => {
|
|
|
386
758
|
],
|
|
387
759
|
})
|
|
388
760
|
|
|
389
|
-
const
|
|
390
|
-
|
|
761
|
+
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
762
|
+
schema,
|
|
763
|
+
snapshot: legacySnapshot,
|
|
764
|
+
})
|
|
391
765
|
|
|
392
|
-
const snapshot =
|
|
766
|
+
const snapshot = room.getSnapshot()
|
|
393
767
|
expect(snapshot.documentClock).toBe(10) // max lastChangedClock
|
|
394
768
|
})
|
|
395
769
|
|
|
@@ -402,10 +776,12 @@ describe('isReadonly', () => {
|
|
|
402
776
|
},
|
|
403
777
|
})
|
|
404
778
|
|
|
405
|
-
const
|
|
406
|
-
|
|
779
|
+
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
780
|
+
schema,
|
|
781
|
+
snapshot: legacySnapshot,
|
|
782
|
+
})
|
|
407
783
|
|
|
408
|
-
const snapshot =
|
|
784
|
+
const snapshot = room.getSnapshot()
|
|
409
785
|
expect(snapshot.documentClock).toBe(12) // max of document (3) and tombstones (7, 12)
|
|
410
786
|
})
|
|
411
787
|
|
|
@@ -415,10 +791,12 @@ describe('isReadonly', () => {
|
|
|
415
791
|
tombstones: {},
|
|
416
792
|
})
|
|
417
793
|
|
|
418
|
-
const
|
|
419
|
-
|
|
794
|
+
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
795
|
+
schema,
|
|
796
|
+
snapshot: emptyLegacySnapshot,
|
|
797
|
+
})
|
|
420
798
|
|
|
421
|
-
const snapshot =
|
|
799
|
+
const snapshot = room.getSnapshot()
|
|
422
800
|
expect(snapshot.documentClock).toBe(0) // no documents or tombstones
|
|
423
801
|
})
|
|
424
802
|
|
|
@@ -431,10 +809,12 @@ describe('isReadonly', () => {
|
|
|
431
809
|
},
|
|
432
810
|
})
|
|
433
811
|
|
|
434
|
-
const
|
|
435
|
-
|
|
812
|
+
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
813
|
+
schema,
|
|
814
|
+
snapshot: legacySnapshot,
|
|
815
|
+
})
|
|
436
816
|
|
|
437
|
-
const snapshot =
|
|
817
|
+
const snapshot = room.getSnapshot()
|
|
438
818
|
expect(snapshot.documentClock).toBe(8) // max tombstone clock
|
|
439
819
|
})
|
|
440
820
|
|
|
@@ -443,966 +823,96 @@ describe('isReadonly', () => {
|
|
|
443
823
|
documentClock: 15,
|
|
444
824
|
})
|
|
445
825
|
|
|
446
|
-
const
|
|
447
|
-
|
|
826
|
+
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
827
|
+
schema,
|
|
828
|
+
snapshot: snapshotWithDocumentClock,
|
|
829
|
+
})
|
|
448
830
|
|
|
449
|
-
const snapshot =
|
|
831
|
+
const snapshot = room.getSnapshot()
|
|
450
832
|
expect(snapshot.documentClock).toBe(15) // should preserve explicit value
|
|
451
833
|
})
|
|
452
|
-
})
|
|
453
|
-
})
|
|
454
|
-
|
|
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 })
|
|
459
|
-
|
|
460
|
-
const socket = makeSocket()
|
|
461
|
-
const sessionId = 'test-session'
|
|
462
|
-
|
|
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
834
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
835
|
+
describe('Document clock initialization logic', () => {
|
|
836
|
+
it('sets documentClock to room clock when migrations run (didIncrementClock = true)', () => {
|
|
837
|
+
// Create a schema with a migration that will update documents
|
|
838
|
+
const schemaWithMigration = createTLSchema({
|
|
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
|
+
})
|
|
612
875
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
876
|
+
it('preserves documentClock from snapshot when no migrations run (didIncrementClock = false)', () => {
|
|
877
|
+
const snapshotWithDocumentClock = makeSnapshot(records, {
|
|
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
|
+
})
|
|
624
894
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
895
|
+
it('calculates documentClock when snapshot lacks documentClock field (didIncrementClock = false)', () => {
|
|
896
|
+
const legacySnapshot = makeLegacySnapshot(records, {
|
|
897
|
+
documents: [
|
|
898
|
+
{ state: records[0], lastChangedClock: 7 },
|
|
899
|
+
{ state: records[1], lastChangedClock: 12 },
|
|
900
|
+
],
|
|
901
|
+
clock: 15,
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
const onDataChange = vi.fn()
|
|
905
|
+
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
906
|
+
schema,
|
|
907
|
+
snapshot: legacySnapshot,
|
|
908
|
+
onDataChange,
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
// Should calculate from existing data
|
|
912
|
+
expect(room.getSnapshot().documentClock).toBe(12) // max lastChangedClock
|
|
913
|
+
expect(room.getSnapshot().clock).toBe(15) // clock from snapshot
|
|
914
|
+
expect(onDataChange).not.toHaveBeenCalled()
|
|
915
|
+
})
|
|
630
916
|
})
|
|
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 consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
1218
|
-
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
1219
|
-
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
1220
|
-
|
|
1221
|
-
const socket = makeSocket()
|
|
1222
|
-
const sessionId = 'invalid-record-session'
|
|
1223
|
-
|
|
1224
|
-
room.handleNewSession({
|
|
1225
|
-
sessionId,
|
|
1226
|
-
socket,
|
|
1227
|
-
meta: undefined,
|
|
1228
|
-
isReadonly: false,
|
|
1229
|
-
})
|
|
1230
|
-
|
|
1231
|
-
room.handleMessage(sessionId, {
|
|
1232
|
-
connectRequestId: 'connect-1',
|
|
1233
|
-
lastServerClock: 0,
|
|
1234
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
1235
|
-
schema: room.serializedSchema,
|
|
1236
|
-
type: 'connect',
|
|
1237
|
-
})
|
|
1238
|
-
|
|
1239
|
-
expect(room.sessions.get(sessionId)?.state).toBe('connected')
|
|
1240
|
-
|
|
1241
|
-
// Try to push a record with an unknown type
|
|
1242
|
-
room.handleMessage(sessionId, {
|
|
1243
|
-
type: 'push',
|
|
1244
|
-
clientClock: 1,
|
|
1245
|
-
diff: {
|
|
1246
|
-
'unknown:record': [
|
|
1247
|
-
'put',
|
|
1248
|
-
{
|
|
1249
|
-
id: 'unknown:record',
|
|
1250
|
-
typeName: 'unknown_type_that_does_not_exist',
|
|
1251
|
-
} as any,
|
|
1252
|
-
],
|
|
1253
|
-
},
|
|
1254
|
-
} as TLPushRequest<TLRecord>)
|
|
1255
|
-
|
|
1256
|
-
// Session should be rejected/removed
|
|
1257
|
-
const session = room.sessions.get(sessionId)
|
|
1258
|
-
expect(session?.state === 'connected').toBe(false)
|
|
1259
|
-
consoleSpy.mockRestore()
|
|
1260
|
-
})
|
|
1261
|
-
})
|
|
1262
|
-
|
|
1263
|
-
describe('Migration and patch handling', () => {
|
|
1264
|
-
it('successfully patches arrow shape with current schema', () => {
|
|
1265
|
-
// Create a document in the room first
|
|
1266
|
-
const existingArrow: TLArrowShape = {
|
|
1267
|
-
typeName: 'shape',
|
|
1268
|
-
type: 'arrow',
|
|
1269
|
-
id: 'shape:existing_arrow' as TLShapeId,
|
|
1270
|
-
index: ZERO_INDEX_KEY,
|
|
1271
|
-
isLocked: false,
|
|
1272
|
-
parentId: PageRecordType.createId(),
|
|
1273
|
-
rotation: 0,
|
|
1274
|
-
x: 0,
|
|
1275
|
-
y: 0,
|
|
1276
|
-
opacity: 1,
|
|
1277
|
-
props: {
|
|
1278
|
-
kind: 'arc',
|
|
1279
|
-
elbowMidPoint: 0.5,
|
|
1280
|
-
dash: 'draw',
|
|
1281
|
-
size: 'm',
|
|
1282
|
-
fill: 'none',
|
|
1283
|
-
color: 'black',
|
|
1284
|
-
labelColor: 'black',
|
|
1285
|
-
bend: 0,
|
|
1286
|
-
start: { x: 0, y: 0 },
|
|
1287
|
-
end: { x: 0, y: 0 },
|
|
1288
|
-
arrowheadStart: 'none',
|
|
1289
|
-
arrowheadEnd: 'arrow',
|
|
1290
|
-
richText: { type: 'doc', content: [] },
|
|
1291
|
-
font: 'draw',
|
|
1292
|
-
labelPosition: 0.5,
|
|
1293
|
-
scale: 1,
|
|
1294
|
-
},
|
|
1295
|
-
meta: {},
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1299
|
-
snapshot: makeSnapshot([...records, existingArrow]),
|
|
1300
|
-
})
|
|
1301
|
-
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
1302
|
-
|
|
1303
|
-
const socket = makeSocket()
|
|
1304
|
-
const sessionId = 'patch-migration-session'
|
|
1305
|
-
|
|
1306
|
-
room.handleNewSession({
|
|
1307
|
-
sessionId,
|
|
1308
|
-
socket,
|
|
1309
|
-
meta: undefined,
|
|
1310
|
-
isReadonly: false,
|
|
1311
|
-
})
|
|
1312
|
-
|
|
1313
|
-
// Connect with current schema
|
|
1314
|
-
room.handleMessage(sessionId, {
|
|
1315
|
-
connectRequestId: 'connect-1',
|
|
1316
|
-
lastServerClock: 0,
|
|
1317
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
1318
|
-
schema: room.serializedSchema,
|
|
1319
|
-
type: 'connect',
|
|
1320
|
-
})
|
|
1321
|
-
|
|
1322
|
-
expect(room.sessions.get(sessionId)?.state).toBe('connected')
|
|
1323
|
-
|
|
1324
|
-
// Patch the arrow - this should work normally
|
|
1325
|
-
room.handleMessage(sessionId, {
|
|
1326
|
-
type: 'push',
|
|
1327
|
-
clientClock: 1,
|
|
1328
|
-
diff: {
|
|
1329
|
-
[existingArrow.id]: ['patch', { props: ['patch', { color: ['put', 'red'] }] }],
|
|
1330
|
-
},
|
|
1331
|
-
} as TLPushRequest<TLRecord>)
|
|
1332
|
-
|
|
1333
|
-
// Session should still be connected after valid patch
|
|
1334
|
-
expect(room.sessions.get(sessionId)?.state).toBe('connected')
|
|
1335
|
-
|
|
1336
|
-
// Verify the patch was applied
|
|
1337
|
-
const patchedArrow = storage.documents.get(existingArrow.id)?.state as TLArrowShape
|
|
1338
|
-
expect(patchedArrow.props.color).toBe('red')
|
|
1339
|
-
})
|
|
1340
|
-
|
|
1341
|
-
it('rejects client with incompatible schema version that lacks down migrations', () => {
|
|
1342
|
-
// Use an old schema version that can't connect (arrow v0 has no down migration)
|
|
1343
|
-
const serializedSchema = schema.serialize()
|
|
1344
|
-
const oldSerializedSchema: SerializedSchemaV2 = {
|
|
1345
|
-
schemaVersion: 2,
|
|
1346
|
-
sequences: {
|
|
1347
|
-
...serializedSchema.sequences,
|
|
1348
|
-
// Set arrow shape to version 0, which has retired down migrations
|
|
1349
|
-
'com.tldraw.shape.arrow': 0,
|
|
1350
|
-
},
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
1354
|
-
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
1355
|
-
|
|
1356
|
-
const socket = makeSocket()
|
|
1357
|
-
const sessionId = 'old-client-session'
|
|
1358
|
-
|
|
1359
|
-
room.handleNewSession({
|
|
1360
|
-
sessionId,
|
|
1361
|
-
socket,
|
|
1362
|
-
meta: undefined,
|
|
1363
|
-
isReadonly: false,
|
|
1364
|
-
})
|
|
1365
|
-
|
|
1366
|
-
// Connect with old schema - this should fail because arrow v1 migration has no down function
|
|
1367
|
-
room.handleMessage(sessionId, {
|
|
1368
|
-
connectRequestId: 'connect-1',
|
|
1369
|
-
lastServerClock: 0,
|
|
1370
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
1371
|
-
schema: oldSerializedSchema,
|
|
1372
|
-
type: 'connect',
|
|
1373
|
-
})
|
|
1374
|
-
|
|
1375
|
-
// Session should not be connected - it was rejected due to incompatible schema
|
|
1376
|
-
expect(room.sessions.get(sessionId)?.state).not.toBe('connected')
|
|
1377
|
-
|
|
1378
|
-
// Socket should be closed with CLIENT_TOO_OLD
|
|
1379
|
-
expect(socket.isOpen).toBe(false)
|
|
1380
|
-
})
|
|
1381
|
-
|
|
1382
|
-
it('accepts client with same schema version', () => {
|
|
1383
|
-
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
1384
|
-
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
1385
|
-
|
|
1386
|
-
const socket = makeSocket()
|
|
1387
|
-
const sessionId = 'current-client-session'
|
|
1388
|
-
|
|
1389
|
-
room.handleNewSession({
|
|
1390
|
-
sessionId,
|
|
1391
|
-
socket,
|
|
1392
|
-
meta: undefined,
|
|
1393
|
-
isReadonly: false,
|
|
1394
|
-
})
|
|
1395
|
-
|
|
1396
|
-
// Connect with same schema
|
|
1397
|
-
room.handleMessage(sessionId, {
|
|
1398
|
-
connectRequestId: 'connect-1',
|
|
1399
|
-
lastServerClock: 0,
|
|
1400
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
1401
|
-
schema: room.serializedSchema,
|
|
1402
|
-
type: 'connect',
|
|
1403
|
-
})
|
|
1404
|
-
|
|
1405
|
-
// Session should be connected
|
|
1406
|
-
expect(room.sessions.get(sessionId)?.state).toBe('connected')
|
|
1407
917
|
})
|
|
1408
918
|
})
|