@tldraw/sync-core 5.2.0-next.355d68cf24e8 → 5.2.0-next.547772a8f51a
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/DOCS.md +662 -0
- package/README.md +9 -1
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/TLSocketRoom.js +4 -1
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +2 -3
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +27 -3
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/TLSocketRoom.mjs +4 -1
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +2 -4
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +27 -3
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/package.json +9 -8
- package/src/lib/ClientWebSocketAdapter.test.ts +486 -508
- package/src/lib/DurableObjectSqliteSyncWrapper.test.ts +102 -0
- package/src/lib/InMemorySyncStorage.test.ts +294 -0
- package/src/lib/MicrotaskNotifier.test.ts +79 -254
- package/src/lib/{NodeSqliteSyncWrapper.test.ts → NodeSqliteWrapper.test.ts} +29 -25
- package/src/lib/SQLiteSyncStorage.test.ts +239 -0
- package/src/lib/ServerSocketAdapter.test.ts +60 -199
- package/src/lib/TLSocketRoom.ts +6 -1
- package/src/lib/TLSyncClient.test.ts +1127 -846
- package/src/lib/TLSyncClient.ts +4 -4
- package/src/lib/TLSyncRoom.ts +34 -4
- package/src/lib/TLSyncStorage.test.ts +225 -0
- package/src/lib/chunk.test.ts +372 -0
- package/src/lib/diff.test.ts +885 -0
- package/src/lib/interval.test.ts +43 -0
- package/src/lib/protocol.test.ts +43 -0
- package/src/lib/recordDiff.test.ts +159 -0
- package/src/test/TLSocketRoom.test.ts +1109 -894
- package/src/test/TLSyncRoom.test.ts +1754 -870
- package/src/test/storageContractSuite.ts +618 -0
- package/src/test/upgradeDowngrade.test.ts +140 -40
- package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
- package/src/lib/RoomSession.test.ts +0 -101
- package/src/lib/computeTombstonePruning.test.ts +0 -352
- package/src/lib/server-types.test.ts +0 -44
- package/src/test/InMemorySyncStorage.test.ts +0 -1780
- package/src/test/SQLiteSyncStorage.test.ts +0 -1485
- package/src/test/chunk.test.ts +0 -385
- package/src/test/customMessages.test.ts +0 -36
- package/src/test/diff.test.ts +0 -784
- package/src/test/presenceMode.test.ts +0 -149
- package/src/test/validation.test.ts +0 -186
|
@@ -1,4 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { atom, computed } from '@tldraw/state'
|
|
2
|
+
import {
|
|
3
|
+
BaseRecord,
|
|
4
|
+
RecordId,
|
|
5
|
+
SerializedSchema,
|
|
6
|
+
SerializedSchemaV2,
|
|
7
|
+
Store,
|
|
8
|
+
StoreSchema,
|
|
9
|
+
UnknownRecord,
|
|
10
|
+
createMigrationIds,
|
|
11
|
+
createMigrationSequence,
|
|
12
|
+
createRecordMigrationSequence,
|
|
13
|
+
createRecordType,
|
|
14
|
+
} from '@tldraw/store'
|
|
2
15
|
import {
|
|
3
16
|
CameraRecordType,
|
|
4
17
|
DocumentRecordType,
|
|
@@ -12,9 +25,11 @@ import {
|
|
|
12
25
|
TLRecord,
|
|
13
26
|
TLShapeId,
|
|
14
27
|
createTLSchema,
|
|
28
|
+
createUserId,
|
|
15
29
|
} from '@tldraw/tlschema'
|
|
16
30
|
import { IndexKey, ZERO_INDEX_KEY, mockUniqueId, sortById } from '@tldraw/utils'
|
|
17
|
-
import { vi } from 'vitest'
|
|
31
|
+
import { vi, type Mock } from 'vitest'
|
|
32
|
+
import { RecordOpType } from '../lib/diff'
|
|
18
33
|
import { InMemorySyncStorage } from '../lib/InMemorySyncStorage'
|
|
19
34
|
import {
|
|
20
35
|
TLConnectRequest,
|
|
@@ -22,19 +37,52 @@ import {
|
|
|
22
37
|
TLSocketServerSentEvent,
|
|
23
38
|
getTlsyncProtocolVersion,
|
|
24
39
|
} from '../lib/protocol'
|
|
25
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
RoomSessionState,
|
|
42
|
+
SESSION_IDLE_TIMEOUT,
|
|
43
|
+
SESSION_REMOVAL_WAIT_TIME,
|
|
44
|
+
SESSION_START_WAIT_TIME,
|
|
45
|
+
} from '../lib/RoomSession'
|
|
46
|
+
import {
|
|
47
|
+
TLSyncClient,
|
|
48
|
+
TLSyncErrorCloseEventCode,
|
|
49
|
+
TLSyncErrorCloseEventReason,
|
|
50
|
+
} from '../lib/TLSyncClient'
|
|
51
|
+
import {
|
|
52
|
+
DATA_MESSAGE_DEBOUNCE_INTERVAL,
|
|
53
|
+
RoomSnapshot,
|
|
54
|
+
TLRoomSocket,
|
|
55
|
+
TLSyncRoom,
|
|
56
|
+
} from '../lib/TLSyncRoom'
|
|
57
|
+
import {
|
|
58
|
+
TLSyncStorage,
|
|
59
|
+
TLSyncStorageOnChangeCallbackProps,
|
|
60
|
+
TLSyncStorageTransaction,
|
|
61
|
+
TLSyncStorageTransactionCallback,
|
|
62
|
+
TLSyncStorageTransactionOptions,
|
|
63
|
+
TLSyncStorageTransactionResult,
|
|
64
|
+
} from '../lib/TLSyncStorage'
|
|
65
|
+
import { TestServer } from './TestServer'
|
|
66
|
+
import { TestSocketPair } from './TestSocketPair'
|
|
67
|
+
|
|
68
|
+
// Some tests drive a real TLSyncClient, which schedules its pushes on
|
|
69
|
+
// requestAnimationFrame. Make that synchronous so flushing is deterministic.
|
|
70
|
+
// @ts-expect-error
|
|
71
|
+
global.requestAnimationFrame = (cb: () => any) => {
|
|
72
|
+
cb()
|
|
73
|
+
}
|
|
26
74
|
|
|
27
75
|
const schema = createTLSchema()
|
|
28
76
|
const compareById = (a: { id: string }, b: { id: string }) => a.id.localeCompare(b.id)
|
|
29
77
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
].sort(compareById)
|
|
78
|
+
const documentRecord = DocumentRecordType.create({ id: TLDOCUMENT_ID })
|
|
79
|
+
const pageRecord = PageRecordType.create({
|
|
80
|
+
index: ZERO_INDEX_KEY,
|
|
81
|
+
name: 'page 2',
|
|
82
|
+
id: PageRecordType.createId('page_2'),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const records: TLRecord[] = [documentRecord, pageRecord].sort(compareById)
|
|
38
86
|
|
|
39
87
|
const makeSnapshot = (records: TLRecord[], others: Partial<RoomSnapshot> = {}) => ({
|
|
40
88
|
documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
|
|
@@ -44,7 +92,7 @@ const makeSnapshot = (records: TLRecord[], others: Partial<RoomSnapshot> = {}) =
|
|
|
44
92
|
...others,
|
|
45
93
|
})
|
|
46
94
|
|
|
47
|
-
// Helper to create legacy snapshots without documentClock field
|
|
95
|
+
// Helper to create legacy snapshots without a documentClock field
|
|
48
96
|
const makeLegacySnapshot = (
|
|
49
97
|
records: TLRecord[],
|
|
50
98
|
others: Partial<Omit<RoomSnapshot, 'documentClock'>> = {}
|
|
@@ -54,11 +102,6 @@ const makeLegacySnapshot = (
|
|
|
54
102
|
...others,
|
|
55
103
|
})
|
|
56
104
|
|
|
57
|
-
beforeEach(() => {
|
|
58
|
-
let id = 0
|
|
59
|
-
mockUniqueId(() => `id_${id++}`)
|
|
60
|
-
})
|
|
61
|
-
|
|
62
105
|
const oldArrow: TLBaseShape<'arrow', Omit<TLArrowShapeProps, 'labelColor'>> = {
|
|
63
106
|
typeName: 'shape',
|
|
64
107
|
type: 'arrow',
|
|
@@ -91,8 +134,138 @@ const oldArrow: TLBaseShape<'arrow', Omit<TLArrowShapeProps, 'labelColor'>> = {
|
|
|
91
134
|
meta: {},
|
|
92
135
|
}
|
|
93
136
|
|
|
94
|
-
|
|
95
|
-
|
|
137
|
+
type MockSocket = TLRoomSocket<any> & {
|
|
138
|
+
__lastMessage: null | TLSocketServerSentEvent<any>
|
|
139
|
+
__messages: TLSocketServerSentEvent<any>[]
|
|
140
|
+
sendMessage: Mock
|
|
141
|
+
close: Mock
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function makeSocket(): MockSocket {
|
|
145
|
+
const socket: MockSocket = {
|
|
146
|
+
__lastMessage: null,
|
|
147
|
+
__messages: [],
|
|
148
|
+
// cloning because the room reuses/clears message objects after sending
|
|
149
|
+
// (a real socket serializes the message immediately)
|
|
150
|
+
sendMessage: vi.fn((msg: TLSocketServerSentEvent<any>) => {
|
|
151
|
+
const clone = structuredClone(msg)
|
|
152
|
+
socket.__lastMessage = clone
|
|
153
|
+
socket.__messages.push(clone)
|
|
154
|
+
}),
|
|
155
|
+
close: vi.fn(() => {
|
|
156
|
+
socket.isOpen = false
|
|
157
|
+
}),
|
|
158
|
+
isOpen: true,
|
|
159
|
+
}
|
|
160
|
+
return socket
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function sentMessages(socket: MockSocket): TLSocketServerSentEvent<any>[] {
|
|
164
|
+
return socket.__messages
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function sentDataMessages(socket: MockSocket): any[] {
|
|
168
|
+
return sentMessages(socket).flatMap((msg: any) => (msg.type === 'data' ? msg.data : []))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function clearSocket(socket: MockSocket) {
|
|
172
|
+
socket.sendMessage.mockClear()
|
|
173
|
+
socket.__messages.length = 0
|
|
174
|
+
socket.__lastMessage = null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function combinedPatchDiff(socket: MockSocket): Record<string, unknown> {
|
|
178
|
+
return sentDataMessages(socket)
|
|
179
|
+
.filter((msg) => msg.type === 'patch')
|
|
180
|
+
.reduce((acc, patch) => ({ ...acc, ...patch.diff }), {})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const disposables: Array<() => void> = []
|
|
184
|
+
|
|
185
|
+
function makeRoom(
|
|
186
|
+
opts: {
|
|
187
|
+
snapshot?: RoomSnapshot
|
|
188
|
+
clientTimeout?: number
|
|
189
|
+
log?: { warn?: Mock; error?: Mock }
|
|
190
|
+
onPresenceChange?(): void
|
|
191
|
+
} = {}
|
|
192
|
+
) {
|
|
193
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
194
|
+
snapshot: opts.snapshot ?? makeSnapshot(records),
|
|
195
|
+
})
|
|
196
|
+
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
197
|
+
schema,
|
|
198
|
+
storage,
|
|
199
|
+
clientTimeout: opts.clientTimeout,
|
|
200
|
+
log: opts.log,
|
|
201
|
+
onPresenceChange: opts.onPresenceChange,
|
|
202
|
+
})
|
|
203
|
+
disposables.push(() => room.close())
|
|
204
|
+
return { storage, room }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function connectSession(
|
|
208
|
+
room: TLSyncRoom<any, any>,
|
|
209
|
+
sessionId: string,
|
|
210
|
+
opts: {
|
|
211
|
+
isReadonly?: boolean
|
|
212
|
+
protocolVersion?: number
|
|
213
|
+
lastServerClock?: number
|
|
214
|
+
schema?: SerializedSchema
|
|
215
|
+
clear?: boolean
|
|
216
|
+
} = {}
|
|
217
|
+
): MockSocket {
|
|
218
|
+
const socket = makeSocket()
|
|
219
|
+
room.handleNewSession({
|
|
220
|
+
sessionId,
|
|
221
|
+
socket,
|
|
222
|
+
meta: undefined,
|
|
223
|
+
isReadonly: opts.isReadonly ?? false,
|
|
224
|
+
})
|
|
225
|
+
room.handleMessage(sessionId, {
|
|
226
|
+
type: 'connect',
|
|
227
|
+
connectRequestId: 'connect-' + sessionId,
|
|
228
|
+
lastServerClock: opts.lastServerClock ?? 0,
|
|
229
|
+
protocolVersion: opts.protocolVersion ?? getTlsyncProtocolVersion(),
|
|
230
|
+
schema: opts.schema ?? room.serializedSchema,
|
|
231
|
+
} satisfies TLConnectRequest)
|
|
232
|
+
if (opts.clear !== false) {
|
|
233
|
+
clearSocket(socket)
|
|
234
|
+
}
|
|
235
|
+
return socket
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function makePage(suffix: string, name: string, index = 'a2' as IndexKey) {
|
|
239
|
+
return PageRecordType.create({ id: PageRecordType.createId(suffix), name, index })
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function presencePut(userName = 'New User'): TLPushRequest<TLRecord>['presence'] {
|
|
243
|
+
return [
|
|
244
|
+
'put',
|
|
245
|
+
InstancePresenceRecordType.create({
|
|
246
|
+
id: InstancePresenceRecordType.createId('client-chosen'),
|
|
247
|
+
currentPageId: pageRecord.id,
|
|
248
|
+
userId: createUserId('user'),
|
|
249
|
+
userName,
|
|
250
|
+
}) as any,
|
|
251
|
+
]
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
beforeEach(() => {
|
|
255
|
+
let id = 0
|
|
256
|
+
mockUniqueId(() => `id_${id++}`)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
afterEach(() => {
|
|
260
|
+
for (const dispose of disposables) {
|
|
261
|
+
dispose()
|
|
262
|
+
}
|
|
263
|
+
disposables.length = 0
|
|
264
|
+
vi.useRealTimers()
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
describe('22. Room construction (RC)', () => {
|
|
268
|
+
it('[RC3] can be constructed with a storage, leaving current data unchanged', () => {
|
|
96
269
|
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
97
270
|
const _room = new TLSyncRoom<TLRecord, undefined>({
|
|
98
271
|
schema,
|
|
@@ -109,7 +282,7 @@ describe('TLSyncRoom', () => {
|
|
|
109
282
|
expect(storage.getSnapshot().documents.map((r) => r.lastChangedClock)).toEqual([0, 0])
|
|
110
283
|
})
|
|
111
284
|
|
|
112
|
-
it('migrates the snapshot if it is dealing with old data', () => {
|
|
285
|
+
it('[RC3] migrates the snapshot if it is dealing with old data', () => {
|
|
113
286
|
const serializedSchema = schema.serialize()
|
|
114
287
|
const oldSerializedSchema: SerializedSchemaV2 = {
|
|
115
288
|
schemaVersion: 2,
|
|
@@ -134,7 +307,7 @@ describe('TLSyncRoom', () => {
|
|
|
134
307
|
expect(arrow.props.labelColor).toBe('black')
|
|
135
308
|
})
|
|
136
309
|
|
|
137
|
-
it('filters out instance state records if a migration occurs, for legacy reasons', () => {
|
|
310
|
+
it('[RC3] filters out instance state records if a migration occurs, for legacy reasons', () => {
|
|
138
311
|
const schema = createTLSchema()
|
|
139
312
|
const oldSchema = structuredClone(schema.serialize())
|
|
140
313
|
oldSchema.sequences['com.tldraw.shape.arrow'] = 0
|
|
@@ -168,203 +341,112 @@ describe('TLSyncRoom', () => {
|
|
|
168
341
|
.sort(sortById)
|
|
169
342
|
).toEqual(records)
|
|
170
343
|
})
|
|
171
|
-
})
|
|
172
344
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
},
|
|
183
|
-
isOpen: true,
|
|
184
|
-
}
|
|
185
|
-
return socket
|
|
186
|
-
}
|
|
345
|
+
it('[RC3] running migrations twice does not cause issues', () => {
|
|
346
|
+
const serializedSchema = schema.serialize()
|
|
347
|
+
const oldSerializedSchema: SerializedSchemaV2 = {
|
|
348
|
+
schemaVersion: 2,
|
|
349
|
+
sequences: {
|
|
350
|
+
...serializedSchema.sequences,
|
|
351
|
+
'com.tldraw.shape.arrow': 0,
|
|
352
|
+
},
|
|
353
|
+
}
|
|
187
354
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
let room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
193
|
-
let socketA = makeSocket()
|
|
194
|
-
let socketB = makeSocket()
|
|
195
|
-
const getDoc = (id: string) => storage.documents.get(id)?.state
|
|
196
|
-
function init(snapshot?: RoomSnapshot) {
|
|
197
|
-
storage = new InMemorySyncStorage<TLRecord>({ snapshot: snapshot ?? makeSnapshot(records) })
|
|
198
|
-
room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
199
|
-
socketA = makeSocket()
|
|
200
|
-
socketB = makeSocket()
|
|
201
|
-
room.handleNewSession({
|
|
202
|
-
sessionId: sessionAId,
|
|
203
|
-
socket: socketA,
|
|
204
|
-
meta: null as any,
|
|
205
|
-
isReadonly: true,
|
|
355
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
356
|
+
snapshot: makeSnapshot([...records, oldArrow as any], {
|
|
357
|
+
schema: oldSerializedSchema,
|
|
358
|
+
}),
|
|
206
359
|
})
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
360
|
+
|
|
361
|
+
// First migration (simulating what TLSyncRoom constructor does)
|
|
362
|
+
const _room1 = new TLSyncRoom<TLRecord, undefined>({
|
|
363
|
+
schema,
|
|
364
|
+
storage,
|
|
212
365
|
})
|
|
213
|
-
room.handleMessage(sessionAId, {
|
|
214
|
-
connectRequestId: 'connectRequestId' + sessionAId,
|
|
215
|
-
lastServerClock: 0,
|
|
216
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
217
|
-
schema: room.serializedSchema,
|
|
218
|
-
type: 'connect',
|
|
219
|
-
} satisfies TLConnectRequest)
|
|
220
|
-
room.handleMessage(sessionBId, {
|
|
221
|
-
connectRequestId: 'connectRequestId' + sessionBId,
|
|
222
|
-
lastServerClock: 0,
|
|
223
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
224
|
-
schema: room.serializedSchema,
|
|
225
|
-
type: 'connect',
|
|
226
|
-
} satisfies TLConnectRequest)
|
|
227
|
-
expect(room.sessions.get(sessionAId)?.state).toBe('connected')
|
|
228
|
-
expect(room.sessions.get(sessionBId)?.state).toBe('connected')
|
|
229
|
-
socketA.__lastMessage = null
|
|
230
|
-
socketB.__lastMessage = null
|
|
231
|
-
}
|
|
232
|
-
beforeEach(() => {
|
|
233
|
-
init()
|
|
234
|
-
})
|
|
235
366
|
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
'page:page_3': [
|
|
241
|
-
'put',
|
|
242
|
-
PageRecordType.create({
|
|
243
|
-
id: 'page:page_3' as any,
|
|
244
|
-
name: 'my lovely page 3',
|
|
245
|
-
index: 'ab34' as IndexKey,
|
|
246
|
-
}),
|
|
247
|
-
],
|
|
248
|
-
},
|
|
249
|
-
presence: undefined,
|
|
250
|
-
type: 'push',
|
|
251
|
-
}
|
|
367
|
+
// Get state after first migration
|
|
368
|
+
const snapshotAfterFirst = storage.getSnapshot()
|
|
369
|
+
const arrowAfterFirst = snapshotAfterFirst.documents.find((r) => r.state.id === oldArrow.id)
|
|
370
|
+
?.state as TLArrowShape
|
|
252
371
|
|
|
253
|
-
//
|
|
254
|
-
|
|
372
|
+
// Verify migration happened
|
|
373
|
+
expect(arrowAfterFirst.props.labelColor).toBe('black')
|
|
255
374
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
{
|
|
262
|
-
"action": "discard",
|
|
263
|
-
"clientClock": 0,
|
|
264
|
-
"serverClock": 0,
|
|
265
|
-
"type": "push_result",
|
|
266
|
-
},
|
|
267
|
-
],
|
|
268
|
-
"type": "data",
|
|
269
|
-
}
|
|
270
|
-
`)
|
|
271
|
-
// should not have sent anything to sessionB
|
|
272
|
-
expect(socketB.__lastMessage).toBe(null)
|
|
375
|
+
// Create another room with the same storage (simulating a second migration attempt)
|
|
376
|
+
const _room2 = new TLSyncRoom<TLRecord, undefined>({
|
|
377
|
+
schema,
|
|
378
|
+
storage,
|
|
379
|
+
})
|
|
273
380
|
|
|
274
|
-
//
|
|
275
|
-
|
|
381
|
+
// Get state after second "migration"
|
|
382
|
+
const snapshotAfterSecond = storage.getSnapshot()
|
|
383
|
+
const arrowAfterSecond = snapshotAfterSecond.documents.find((r) => r.state.id === oldArrow.id)
|
|
384
|
+
?.state as TLArrowShape
|
|
276
385
|
|
|
277
|
-
|
|
386
|
+
// Should still have the migrated value
|
|
387
|
+
expect(arrowAfterSecond.props.labelColor).toBe('black')
|
|
278
388
|
|
|
279
|
-
// should
|
|
280
|
-
expect(
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
"action": "commit",
|
|
285
|
-
"clientClock": 0,
|
|
286
|
-
"serverClock": 1,
|
|
287
|
-
"type": "push_result",
|
|
288
|
-
},
|
|
289
|
-
],
|
|
290
|
-
"type": "data",
|
|
291
|
-
}
|
|
292
|
-
`)
|
|
389
|
+
// Document count should be the same
|
|
390
|
+
expect(snapshotAfterSecond.documents.length).toBe(snapshotAfterFirst.documents.length)
|
|
391
|
+
|
|
392
|
+
// Schema should be up to date
|
|
393
|
+
expect(snapshotAfterSecond.schema).toEqual(schema.serialize())
|
|
293
394
|
})
|
|
294
395
|
|
|
295
|
-
it('
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
id: InstancePresenceRecordType.createId('foo'),
|
|
303
|
-
currentPageId: 'page:page_2' as any,
|
|
304
|
-
userId: 'foo',
|
|
305
|
-
userName: 'Jimbo',
|
|
306
|
-
}),
|
|
307
|
-
],
|
|
308
|
-
type: 'push',
|
|
309
|
-
}
|
|
396
|
+
it('[RC3] already migrated data is not modified again', () => {
|
|
397
|
+
// Start with current schema (no migration needed)
|
|
398
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
399
|
+
snapshot: makeSnapshot(records, {
|
|
400
|
+
documentClock: 10,
|
|
401
|
+
}),
|
|
402
|
+
})
|
|
310
403
|
|
|
311
|
-
|
|
312
|
-
room.handleMessage(sessionAId, presencePush)
|
|
404
|
+
const clockBefore = storage.getClock()
|
|
313
405
|
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
"instance_presence:id_0": [
|
|
336
|
-
"put",
|
|
337
|
-
{
|
|
338
|
-
"brush": null,
|
|
339
|
-
"camera": null,
|
|
340
|
-
"chatMessage": "",
|
|
341
|
-
"color": "#FF0000",
|
|
342
|
-
"currentPageId": "page:page_2",
|
|
343
|
-
"cursor": null,
|
|
344
|
-
"followingUserId": null,
|
|
345
|
-
"id": "instance_presence:id_0",
|
|
346
|
-
"lastActivityTimestamp": null,
|
|
347
|
-
"meta": {},
|
|
348
|
-
"screenBounds": null,
|
|
349
|
-
"scribbles": [],
|
|
350
|
-
"selectedShapeIds": [],
|
|
351
|
-
"typeName": "instance_presence",
|
|
352
|
-
"userId": "foo",
|
|
353
|
-
"userName": "Jimbo",
|
|
354
|
-
},
|
|
355
|
-
],
|
|
356
|
-
},
|
|
357
|
-
"serverClock": 0,
|
|
358
|
-
"type": "patch",
|
|
359
|
-
},
|
|
360
|
-
],
|
|
361
|
-
"type": "data",
|
|
406
|
+
// Create room - should not modify anything since schema is current
|
|
407
|
+
const _room = new TLSyncRoom<TLRecord, undefined>({
|
|
408
|
+
schema,
|
|
409
|
+
storage,
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// Clock should not have changed since no migration was needed
|
|
413
|
+
expect(storage.getClock()).toBe(clockBefore)
|
|
414
|
+
|
|
415
|
+
// Documents should be unchanged
|
|
416
|
+
expect(storage.getSnapshot().documents.length).toBe(2)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('[RC3] migration updates the schema version in storage', () => {
|
|
420
|
+
const serializedSchema = schema.serialize()
|
|
421
|
+
const oldSerializedSchema: SerializedSchemaV2 = {
|
|
422
|
+
schemaVersion: 2,
|
|
423
|
+
sequences: {
|
|
424
|
+
...serializedSchema.sequences,
|
|
425
|
+
'com.tldraw.shape.arrow': 0,
|
|
426
|
+
},
|
|
362
427
|
}
|
|
363
|
-
|
|
428
|
+
|
|
429
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
430
|
+
snapshot: makeSnapshot([...records, oldArrow as any], {
|
|
431
|
+
schema: oldSerializedSchema,
|
|
432
|
+
}),
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
// Verify old schema before room creation
|
|
436
|
+
expect(storage.schema.get()).toEqual(oldSerializedSchema)
|
|
437
|
+
|
|
438
|
+
// Create room which triggers migration
|
|
439
|
+
const _room = new TLSyncRoom<TLRecord, undefined>({
|
|
440
|
+
schema,
|
|
441
|
+
storage,
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
// Schema should now be updated to current version
|
|
445
|
+
expect(storage.schema.get()).toEqual(schema.serialize())
|
|
364
446
|
})
|
|
365
447
|
|
|
366
|
-
describe('
|
|
367
|
-
it('can load snapshot without documentClock field', () => {
|
|
448
|
+
describe('legacy snapshots without a documentClock field', () => {
|
|
449
|
+
it('[SS17] can load a snapshot without a documentClock field', () => {
|
|
368
450
|
const legacySnapshot = makeLegacySnapshot(records)
|
|
369
451
|
|
|
370
452
|
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: legacySnapshot })
|
|
@@ -378,7 +460,7 @@ describe('isReadonly', () => {
|
|
|
378
460
|
expect(snapshot.documentClock).toBe(0) // max lastChangedClock from documents
|
|
379
461
|
})
|
|
380
462
|
|
|
381
|
-
it('calculates documentClock correctly from documents with different lastChangedClock values', () => {
|
|
463
|
+
it('[IM1] calculates documentClock correctly from documents with different lastChangedClock values', () => {
|
|
382
464
|
const legacySnapshot = makeLegacySnapshot(records, {
|
|
383
465
|
documents: [
|
|
384
466
|
{ state: records[0], lastChangedClock: 5 },
|
|
@@ -393,7 +475,7 @@ describe('isReadonly', () => {
|
|
|
393
475
|
expect(snapshot.documentClock).toBe(10) // max lastChangedClock
|
|
394
476
|
})
|
|
395
477
|
|
|
396
|
-
it('calculates documentClock correctly from tombstones', () => {
|
|
478
|
+
it('[IM1] calculates documentClock correctly from tombstones', () => {
|
|
397
479
|
const legacySnapshot = makeLegacySnapshot(records, {
|
|
398
480
|
documents: [{ state: records[0], lastChangedClock: 3 }],
|
|
399
481
|
tombstones: {
|
|
@@ -409,7 +491,7 @@ describe('isReadonly', () => {
|
|
|
409
491
|
expect(snapshot.documentClock).toBe(12) // max of document (3) and tombstones (7, 12)
|
|
410
492
|
})
|
|
411
493
|
|
|
412
|
-
it('handles empty snapshot gracefully', () => {
|
|
494
|
+
it('[SS17] handles an empty snapshot gracefully', () => {
|
|
413
495
|
const emptyLegacySnapshot = makeLegacySnapshot([], {
|
|
414
496
|
documents: [],
|
|
415
497
|
tombstones: {},
|
|
@@ -422,7 +504,7 @@ describe('isReadonly', () => {
|
|
|
422
504
|
expect(snapshot.documentClock).toBe(0) // no documents or tombstones
|
|
423
505
|
})
|
|
424
506
|
|
|
425
|
-
it('handles snapshot with only tombstones', () => {
|
|
507
|
+
it('[IM1] handles a snapshot with only tombstones', () => {
|
|
426
508
|
const legacySnapshot = makeLegacySnapshot([], {
|
|
427
509
|
documents: [],
|
|
428
510
|
tombstones: {
|
|
@@ -438,7 +520,7 @@ describe('isReadonly', () => {
|
|
|
438
520
|
expect(snapshot.documentClock).toBe(8) // max tombstone clock
|
|
439
521
|
})
|
|
440
522
|
|
|
441
|
-
it('preserves explicit documentClock when present', () => {
|
|
523
|
+
it('[SS17] preserves an explicit documentClock when present', () => {
|
|
442
524
|
const snapshotWithDocumentClock = makeSnapshot(records, {
|
|
443
525
|
documentClock: 15,
|
|
444
526
|
})
|
|
@@ -450,54 +532,59 @@ describe('isReadonly', () => {
|
|
|
450
532
|
expect(snapshot.documentClock).toBe(15) // should preserve explicit value
|
|
451
533
|
})
|
|
452
534
|
})
|
|
453
|
-
})
|
|
454
535
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
458
|
-
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
536
|
+
it('[RC1] the serialized schema contains no undefined values', () => {
|
|
537
|
+
const { room } = makeRoom()
|
|
459
538
|
|
|
460
|
-
|
|
461
|
-
const sessionId = 'test-session'
|
|
539
|
+
expect(room.serializedSchema).toEqual(JSON.parse(JSON.stringify(schema.serialize())))
|
|
462
540
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
541
|
+
const hasUndefined = (value: unknown): boolean => {
|
|
542
|
+
if (value === undefined) return true
|
|
543
|
+
if (value && typeof value === 'object') {
|
|
544
|
+
return Object.values(value).some(hasUndefined)
|
|
545
|
+
}
|
|
546
|
+
return false
|
|
547
|
+
}
|
|
548
|
+
expect(hasUndefined(room.serializedSchema)).toBe(false)
|
|
549
|
+
})
|
|
469
550
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
551
|
+
it('[RC2] a schema with more than one presence type throws at construction', () => {
|
|
552
|
+
const presenceA = createRecordType<any>('presenceA', {
|
|
553
|
+
scope: 'presence',
|
|
554
|
+
validator: { validate: (r) => r },
|
|
555
|
+
})
|
|
556
|
+
const presenceB = createRecordType<any>('presenceB', {
|
|
557
|
+
scope: 'presence',
|
|
558
|
+
validator: { validate: (r) => r },
|
|
476
559
|
})
|
|
560
|
+
const badSchema = StoreSchema.create<any>({ presenceA, presenceB })
|
|
561
|
+
const storage = new InMemorySyncStorage<any>({
|
|
562
|
+
snapshot: { documents: [], clock: 0, documentClock: 0, schema: badSchema.serialize() },
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
expect(() => new TLSyncRoom<any, undefined>({ schema: badSchema, storage })).toThrow(
|
|
566
|
+
/exactly zero or one presence type/
|
|
567
|
+
)
|
|
568
|
+
})
|
|
477
569
|
|
|
478
|
-
|
|
479
|
-
|
|
570
|
+
it('[RC4] broadcasts external storage changes to connected clients', async () => {
|
|
571
|
+
const { room, storage } = makeRoom()
|
|
572
|
+
const socket = connectSession(room, 'test-session')
|
|
480
573
|
|
|
481
574
|
// Simulate external storage change (as if another room instance modified it)
|
|
482
|
-
const newPage =
|
|
483
|
-
id: PageRecordType.createId('external_page'),
|
|
484
|
-
name: 'External Page',
|
|
485
|
-
index: 'a2' as IndexKey,
|
|
486
|
-
})
|
|
575
|
+
const newPage = makePage('external_page', 'External Page')
|
|
487
576
|
|
|
488
577
|
storage.transaction((txn) => {
|
|
489
578
|
txn.set(newPage.id, newPage)
|
|
490
579
|
})
|
|
491
580
|
|
|
492
|
-
// Wait for onChange microtask to fire
|
|
581
|
+
// Wait for onChange microtask to fire and the room to process the change
|
|
493
582
|
await Promise.resolve()
|
|
494
|
-
// Wait for the room to process the change
|
|
495
583
|
await Promise.resolve()
|
|
496
584
|
|
|
497
585
|
// The client should have received a patch with the new page
|
|
498
586
|
expect(socket.sendMessage).toHaveBeenCalled()
|
|
499
|
-
const
|
|
500
|
-
const lastCall = calls[calls.length - 1][0]
|
|
587
|
+
const lastCall = sentMessages(socket).at(-1)! as any
|
|
501
588
|
|
|
502
589
|
// Should be a data message containing a patch
|
|
503
590
|
expect(lastCall.type).toBe('data')
|
|
@@ -513,56 +600,15 @@ describe('External storage changes', () => {
|
|
|
513
600
|
)
|
|
514
601
|
})
|
|
515
602
|
|
|
516
|
-
it('does not broadcast changes from its own transactions (via internalTxnId)', async () => {
|
|
517
|
-
const
|
|
518
|
-
const
|
|
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()
|
|
603
|
+
it('[RC4] does not broadcast changes from its own transactions (via internalTxnId)', async () => {
|
|
604
|
+
const { room } = makeRoom()
|
|
605
|
+
const socketA = connectSession(room, 'session-a')
|
|
606
|
+
const socketB = connectSession(room, 'session-b')
|
|
557
607
|
|
|
558
608
|
// Client A pushes a change through the room
|
|
559
|
-
const newPage =
|
|
560
|
-
id: PageRecordType.createId('client_page'),
|
|
561
|
-
name: 'Client Page',
|
|
562
|
-
index: 'a2' as IndexKey,
|
|
563
|
-
})
|
|
609
|
+
const newPage = makePage('client_page', 'Client Page')
|
|
564
610
|
|
|
565
|
-
room.handleMessage(
|
|
611
|
+
room.handleMessage('session-a', {
|
|
566
612
|
type: 'push',
|
|
567
613
|
clientClock: 1,
|
|
568
614
|
diff: {
|
|
@@ -574,53 +620,27 @@ describe('External storage changes', () => {
|
|
|
574
620
|
await Promise.resolve()
|
|
575
621
|
|
|
576
622
|
// Client A should get push_result
|
|
577
|
-
const
|
|
578
|
-
expect(
|
|
579
|
-
const lastAMessage =
|
|
623
|
+
const clientAMessages = sentMessages(socketA)
|
|
624
|
+
expect(clientAMessages.length).toBeGreaterThan(0)
|
|
625
|
+
const lastAMessage = clientAMessages.at(-1)! as any
|
|
580
626
|
expect(lastAMessage.type).toBe('data')
|
|
581
627
|
expect(lastAMessage.data[0].type).toBe('push_result')
|
|
582
628
|
|
|
583
|
-
// Client B should get exactly one patch (from the push), not a duplicate
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
expect(
|
|
587
|
-
expect(
|
|
629
|
+
// Client B should get exactly one patch (from the push), not a duplicate
|
|
630
|
+
// from external change detection
|
|
631
|
+
const clientBMessages = sentMessages(socketB) as any[]
|
|
632
|
+
expect(clientBMessages.length).toBe(1)
|
|
633
|
+
expect(clientBMessages[0].type).toBe('data')
|
|
634
|
+
expect(clientBMessages[0].data[0].type).toBe('patch')
|
|
588
635
|
})
|
|
589
636
|
|
|
590
|
-
it('handles multiple rapid external changes', async () => {
|
|
591
|
-
const
|
|
592
|
-
const
|
|
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()
|
|
637
|
+
it('[RC4] handles multiple rapid external changes', async () => {
|
|
638
|
+
const { room, storage } = makeRoom()
|
|
639
|
+
const socket = connectSession(room, 'test-session')
|
|
612
640
|
|
|
613
641
|
// Make multiple rapid external changes
|
|
614
|
-
const page1 =
|
|
615
|
-
|
|
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
|
-
})
|
|
642
|
+
const page1 = makePage('rapid_page_1', 'Rapid Page 1', 'a2' as IndexKey)
|
|
643
|
+
const page2 = makePage('rapid_page_2', 'Rapid Page 2', 'a3' as IndexKey)
|
|
624
644
|
|
|
625
645
|
storage.transaction((txn) => {
|
|
626
646
|
txn.set(page1.id, page1)
|
|
@@ -633,416 +653,251 @@ describe('External storage changes', () => {
|
|
|
633
653
|
await Promise.resolve()
|
|
634
654
|
|
|
635
655
|
// Client should have received patches for both changes
|
|
636
|
-
|
|
637
|
-
|
|
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 }), {})
|
|
656
|
+
expect(socket.sendMessage).toHaveBeenCalled()
|
|
657
|
+
const allDiffs = combinedPatchDiff(socket)
|
|
654
658
|
expect(allDiffs[page1.id]).toBeDefined()
|
|
655
659
|
expect(allDiffs[page2.id]).toBeDefined()
|
|
656
660
|
})
|
|
657
|
-
})
|
|
658
661
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
const
|
|
662
|
-
const oldSerializedSchema: SerializedSchemaV2 = {
|
|
663
|
-
schemaVersion: 2,
|
|
664
|
-
sequences: {
|
|
665
|
-
...serializedSchema.sequences,
|
|
666
|
-
'com.tldraw.shape.arrow': 0,
|
|
667
|
-
},
|
|
668
|
-
}
|
|
662
|
+
it('[RC4] broadcasts changes when a snapshot is loaded via a storage transaction during an active session', async () => {
|
|
663
|
+
const { room, storage } = makeRoom()
|
|
664
|
+
const socket = connectSession(room, 'active-session')
|
|
669
665
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
schema: oldSerializedSchema,
|
|
673
|
-
}),
|
|
674
|
-
})
|
|
666
|
+
// Load a new snapshot with additional page
|
|
667
|
+
const newPage = makePage('new_page', 'New Page')
|
|
675
668
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
schema,
|
|
679
|
-
storage,
|
|
669
|
+
storage.transaction((txn) => {
|
|
670
|
+
txn.set(newPage.id, newPage)
|
|
680
671
|
})
|
|
681
672
|
|
|
682
|
-
//
|
|
683
|
-
|
|
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')
|
|
673
|
+
// Wait for onChange to fire
|
|
674
|
+
await Promise.resolve()
|
|
689
675
|
|
|
690
|
-
//
|
|
691
|
-
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
})
|
|
676
|
+
// Client should have received the new page
|
|
677
|
+
expect(socket.sendMessage).toHaveBeenCalled()
|
|
678
|
+
const allDiffs = combinedPatchDiff(socket)
|
|
679
|
+
expect(allDiffs[newPage.id]).toBeDefined()
|
|
680
|
+
})
|
|
696
681
|
|
|
697
|
-
|
|
698
|
-
const
|
|
699
|
-
const
|
|
700
|
-
|
|
682
|
+
it('[RC4] broadcasts document deletions made during an active session', async () => {
|
|
683
|
+
const extraPage = makePage('extra_page', 'Extra Page')
|
|
684
|
+
const { room, storage } = makeRoom({ snapshot: makeSnapshot([...records, extraPage]) })
|
|
685
|
+
const socket = connectSession(room, 'active-session')
|
|
701
686
|
|
|
702
|
-
//
|
|
703
|
-
|
|
687
|
+
// Delete the extra page via storage
|
|
688
|
+
storage.transaction((txn) => {
|
|
689
|
+
txn.delete(extraPage.id)
|
|
690
|
+
})
|
|
704
691
|
|
|
705
|
-
|
|
706
|
-
expect(snapshotAfterSecond.documents.length).toBe(snapshotAfterFirst.documents.length)
|
|
692
|
+
await Promise.resolve()
|
|
707
693
|
|
|
708
|
-
//
|
|
709
|
-
expect(
|
|
694
|
+
// Client should have received the delete
|
|
695
|
+
expect(socket.sendMessage).toHaveBeenCalled()
|
|
696
|
+
const allDiffs = combinedPatchDiff(socket)
|
|
697
|
+
expect(allDiffs[extraPage.id]).toEqual(['remove'])
|
|
710
698
|
})
|
|
711
699
|
|
|
712
|
-
it('
|
|
713
|
-
|
|
714
|
-
const
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
storage,
|
|
700
|
+
it('[RC5] closes every session when an external change cannot be diffed incrementally', async () => {
|
|
701
|
+
const { room, storage } = makeRoom()
|
|
702
|
+
const socketA = connectSession(room, 'a')
|
|
703
|
+
const socketB = connectSession(room, 'b')
|
|
704
|
+
const becameEmpty = vi.fn()
|
|
705
|
+
room.events.on('room_became_empty', becameEmpty)
|
|
706
|
+
|
|
707
|
+
// An external transaction advances the clock, and the storage simultaneously
|
|
708
|
+
// loses the tombstone history needed for an incremental diff (as happens when
|
|
709
|
+
// a snapshot is loaded over the top of existing data).
|
|
710
|
+
const newPage = makePage('wipe_page', 'Wipe Page')
|
|
711
|
+
storage.transaction((txn) => {
|
|
712
|
+
txn.set(newPage.id, newPage)
|
|
726
713
|
})
|
|
714
|
+
storage.tombstoneHistoryStartsAtClock.set(storage.getClock())
|
|
727
715
|
|
|
728
|
-
|
|
729
|
-
|
|
716
|
+
await Promise.resolve()
|
|
717
|
+
await Promise.resolve()
|
|
730
718
|
|
|
731
|
-
//
|
|
732
|
-
expect(
|
|
719
|
+
// All sessions are removed so the clients reconnect and re-hydrate
|
|
720
|
+
expect(room.sessions.size).toBe(0)
|
|
721
|
+
expect(socketA.close).toHaveBeenCalled()
|
|
722
|
+
expect(socketB.close).toHaveBeenCalled()
|
|
723
|
+
expect(becameEmpty).toHaveBeenCalledTimes(1)
|
|
733
724
|
})
|
|
734
725
|
|
|
735
|
-
it('
|
|
736
|
-
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
726
|
+
it('[RC6][SES1] the default client timeout prunes idle sessions via the periodic interval', () => {
|
|
727
|
+
vi.useFakeTimers()
|
|
728
|
+
const { room } = makeRoom()
|
|
729
|
+
const socket = connectSession(room, 'idle')
|
|
730
|
+
const removed = vi.fn()
|
|
731
|
+
const becameEmpty = vi.fn()
|
|
732
|
+
room.events.on('session_removed', removed)
|
|
733
|
+
room.events.on('room_became_empty', becameEmpty)
|
|
734
|
+
|
|
735
|
+
// Advance past the idle timeout; the periodic prune interval cancels the session
|
|
736
|
+
vi.advanceTimersByTime(SESSION_IDLE_TIMEOUT + 2001)
|
|
737
|
+
expect(room.sessions.get('idle')?.state).toBe(RoomSessionState.AwaitingRemoval)
|
|
738
|
+
expect(socket.isOpen).toBe(false)
|
|
744
739
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
740
|
+
// After the removal grace period it is removed entirely
|
|
741
|
+
vi.advanceTimersByTime(SESSION_REMOVAL_WAIT_TIME + 2001)
|
|
742
|
+
expect(room.sessions.size).toBe(0)
|
|
743
|
+
expect(removed).toHaveBeenCalledWith({ sessionId: 'idle', meta: undefined })
|
|
744
|
+
expect(becameEmpty).toHaveBeenCalledTimes(1)
|
|
745
|
+
})
|
|
750
746
|
|
|
751
|
-
|
|
752
|
-
|
|
747
|
+
it('[RC6] a clientTimeout of Infinity or 0 disables the periodic prune interval', () => {
|
|
748
|
+
vi.useFakeTimers()
|
|
749
|
+
for (const clientTimeout of [Infinity, 0]) {
|
|
750
|
+
const { room } = makeRoom({ clientTimeout })
|
|
751
|
+
connectSession(room, 'idle')
|
|
753
752
|
|
|
754
|
-
|
|
755
|
-
const _room = new TLSyncRoom<TLRecord, undefined>({
|
|
756
|
-
schema,
|
|
757
|
-
storage,
|
|
758
|
-
})
|
|
753
|
+
vi.advanceTimersByTime(10 * SESSION_IDLE_TIMEOUT)
|
|
759
754
|
|
|
760
|
-
|
|
761
|
-
|
|
755
|
+
expect(room.sessions.get('idle')?.state).toBe(RoomSessionState.Connected)
|
|
756
|
+
room.close()
|
|
757
|
+
}
|
|
762
758
|
})
|
|
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
759
|
|
|
770
|
-
|
|
771
|
-
const
|
|
760
|
+
it('[RC7] close() closes every session socket and isClosed() reports it', () => {
|
|
761
|
+
const { room } = makeRoom()
|
|
762
|
+
const socketA = connectSession(room, 'a')
|
|
763
|
+
const socketB = connectSession(room, 'b')
|
|
772
764
|
|
|
773
|
-
room.
|
|
774
|
-
sessionId,
|
|
775
|
-
socket,
|
|
776
|
-
meta: undefined,
|
|
777
|
-
isReadonly: false,
|
|
778
|
-
})
|
|
765
|
+
expect(room.isClosed()).toBe(false)
|
|
779
766
|
|
|
780
|
-
|
|
781
|
-
room.handleMessage(sessionId, {
|
|
782
|
-
connectRequestId: 'connect-1',
|
|
783
|
-
lastServerClock: 0,
|
|
784
|
-
protocolVersion: 7,
|
|
785
|
-
schema: room.serializedSchema,
|
|
786
|
-
type: 'connect',
|
|
787
|
-
})
|
|
767
|
+
room.close()
|
|
788
768
|
|
|
789
|
-
|
|
790
|
-
expect(
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
769
|
+
expect(socketA.close).toHaveBeenCalled()
|
|
770
|
+
expect(socketB.close).toHaveBeenCalled()
|
|
771
|
+
expect(socketA.isOpen).toBe(false)
|
|
772
|
+
expect(socketB.isOpen).toBe(false)
|
|
773
|
+
expect(room.isClosed()).toBe(true)
|
|
794
774
|
})
|
|
775
|
+
})
|
|
795
776
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
const room =
|
|
799
|
-
|
|
800
|
-
const socket = makeSocket()
|
|
801
|
-
const sessionId = 'v8-session'
|
|
777
|
+
describe('23. Connect handshake (HS)', () => {
|
|
778
|
+
it('[HS1] a session re-registered under the same id keeps its previous presence id', () => {
|
|
779
|
+
const { room } = makeRoom()
|
|
802
780
|
|
|
803
781
|
room.handleNewSession({
|
|
804
|
-
sessionId,
|
|
805
|
-
socket,
|
|
782
|
+
sessionId: 's1',
|
|
783
|
+
socket: makeSocket(),
|
|
806
784
|
meta: undefined,
|
|
807
785
|
isReadonly: false,
|
|
808
786
|
})
|
|
787
|
+
const presenceId = room.sessions.get('s1')!.presenceId
|
|
788
|
+
expect(presenceId).not.toBeNull()
|
|
809
789
|
|
|
810
|
-
|
|
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
|
|
790
|
+
// Re-register the same session id (e.g. a quick reconnect)
|
|
833
791
|
room.handleNewSession({
|
|
834
|
-
sessionId: '
|
|
835
|
-
socket:
|
|
792
|
+
sessionId: 's1',
|
|
793
|
+
socket: makeSocket(),
|
|
836
794
|
meta: undefined,
|
|
837
795
|
isReadonly: false,
|
|
838
796
|
})
|
|
839
|
-
room.
|
|
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)
|
|
797
|
+
expect(room.sessions.get('s1')!.presenceId).toBe(presenceId)
|
|
849
798
|
|
|
850
|
-
//
|
|
799
|
+
// A different session gets a different presence id
|
|
851
800
|
room.handleNewSession({
|
|
852
|
-
sessionId: '
|
|
853
|
-
socket:
|
|
801
|
+
sessionId: 's2',
|
|
802
|
+
socket: makeSocket(),
|
|
854
803
|
meta: undefined,
|
|
855
804
|
isReadonly: false,
|
|
856
805
|
})
|
|
857
|
-
room.
|
|
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)
|
|
806
|
+
expect(room.sessions.get('s2')!.presenceId).not.toBe(presenceId)
|
|
867
807
|
})
|
|
868
|
-
})
|
|
869
808
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
const
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
809
|
+
it('[HS3] rejects a client whose schema version lacks down migrations', () => {
|
|
810
|
+
// Use an old schema version that can't connect (arrow v0 has retired down migrations)
|
|
811
|
+
const serializedSchema = schema.serialize()
|
|
812
|
+
const oldSerializedSchema: SerializedSchemaV2 = {
|
|
813
|
+
schemaVersion: 2,
|
|
814
|
+
sequences: {
|
|
815
|
+
...serializedSchema.sequences,
|
|
816
|
+
'com.tldraw.shape.arrow': 0,
|
|
817
|
+
},
|
|
818
|
+
}
|
|
876
819
|
|
|
820
|
+
const { room } = makeRoom()
|
|
877
821
|
const socket = makeSocket()
|
|
878
|
-
const sessionId = 'presence-test'
|
|
879
822
|
|
|
880
823
|
room.handleNewSession({
|
|
881
|
-
sessionId,
|
|
824
|
+
sessionId: 'old-client-session',
|
|
882
825
|
socket,
|
|
883
826
|
meta: undefined,
|
|
884
827
|
isReadonly: false,
|
|
885
828
|
})
|
|
886
829
|
|
|
887
|
-
room.handleMessage(
|
|
830
|
+
room.handleMessage('old-client-session', {
|
|
888
831
|
connectRequestId: 'connect-1',
|
|
889
|
-
lastServerClock:
|
|
832
|
+
lastServerClock: 0,
|
|
890
833
|
protocolVersion: getTlsyncProtocolVersion(),
|
|
891
|
-
schema:
|
|
834
|
+
schema: oldSerializedSchema,
|
|
892
835
|
type: 'connect',
|
|
893
836
|
})
|
|
894
837
|
|
|
895
|
-
|
|
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>)
|
|
838
|
+
// Session should not be connected - it was rejected due to incompatible schema
|
|
839
|
+
expect(room.sessions.get('old-client-session')?.state).not.toBe(RoomSessionState.Connected)
|
|
912
840
|
|
|
913
|
-
//
|
|
914
|
-
expect(
|
|
841
|
+
// Socket should be closed with CLIENT_TOO_OLD
|
|
842
|
+
expect(socket.close).toHaveBeenCalledWith(
|
|
843
|
+
TLSyncErrorCloseEventCode,
|
|
844
|
+
TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
|
|
845
|
+
)
|
|
915
846
|
})
|
|
916
847
|
|
|
917
|
-
it('
|
|
918
|
-
|
|
919
|
-
|
|
848
|
+
it('rejects a client running a newer schema with SERVER_TOO_OLD, not CLIENT_TOO_OLD', () => {
|
|
849
|
+
// Regression test for #6169
|
|
850
|
+
// A client running a newer SDK than the server: its schema has a sequence version higher
|
|
851
|
+
// than anything this server knows about. The server can't reconcile it, but the server is
|
|
852
|
+
// the one that's behind, so it should report SERVER_TOO_OLD (not CLIENT_TOO_OLD).
|
|
853
|
+
const serializedSchema = schema.serialize()
|
|
854
|
+
const newerSerializedSchema: SerializedSchemaV2 = {
|
|
855
|
+
schemaVersion: 2,
|
|
856
|
+
sequences: {
|
|
857
|
+
...serializedSchema.sequences,
|
|
858
|
+
'com.tldraw.shape.arrow': 999,
|
|
859
|
+
},
|
|
860
|
+
}
|
|
920
861
|
|
|
862
|
+
const { room } = makeRoom()
|
|
921
863
|
const socket = makeSocket()
|
|
922
|
-
const sessionId = 'presence-test'
|
|
923
864
|
|
|
924
865
|
room.handleNewSession({
|
|
925
|
-
sessionId,
|
|
866
|
+
sessionId: 'newer-client-session',
|
|
926
867
|
socket,
|
|
927
868
|
meta: undefined,
|
|
928
869
|
isReadonly: false,
|
|
929
870
|
})
|
|
930
871
|
|
|
931
|
-
room.handleMessage(
|
|
872
|
+
room.handleMessage('newer-client-session', {
|
|
932
873
|
connectRequestId: 'connect-1',
|
|
933
874
|
lastServerClock: 0,
|
|
934
875
|
protocolVersion: getTlsyncProtocolVersion(),
|
|
935
|
-
schema:
|
|
876
|
+
schema: newerSerializedSchema,
|
|
936
877
|
type: 'connect',
|
|
937
878
|
})
|
|
938
879
|
|
|
939
|
-
|
|
940
|
-
|
|
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()
|
|
880
|
+
// Session should not be connected - it was rejected due to incompatible schema
|
|
881
|
+
expect(room.sessions.get('newer-client-session')?.state).not.toBe(RoomSessionState.Connected)
|
|
960
882
|
|
|
961
|
-
//
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
883
|
+
// Socket should be closed with SERVER_TOO_OLD, since the client is on a newer version
|
|
884
|
+
expect(socket.close).toHaveBeenCalledWith(
|
|
885
|
+
TLSyncErrorCloseEventCode,
|
|
886
|
+
TLSyncErrorCloseEventReason.SERVER_TOO_OLD
|
|
887
|
+
)
|
|
965
888
|
})
|
|
966
889
|
|
|
967
|
-
it('
|
|
968
|
-
const
|
|
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
|
-
})
|
|
890
|
+
it('[HS4] handles a client with lastServerClock greater than the current documentClock', () => {
|
|
891
|
+
const { room } = makeRoom({ snapshot: makeSnapshot(records, { documentClock: 10 }) })
|
|
980
892
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
lastServerClock:
|
|
984
|
-
|
|
985
|
-
schema: room.serializedSchema,
|
|
986
|
-
type: 'connect',
|
|
893
|
+
// Client claims to have clock 100, but server is only at 10
|
|
894
|
+
const socket = connectSession(room, 'future-clock-client', {
|
|
895
|
+
lastServerClock: 100,
|
|
896
|
+
clear: false,
|
|
987
897
|
})
|
|
988
898
|
|
|
989
|
-
|
|
990
|
-
|
|
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')
|
|
899
|
+
// Session should still connect successfully
|
|
900
|
+
expect(room.sessions.get('future-clock-client')?.state).toBe(RoomSessionState.Connected)
|
|
1046
901
|
|
|
1047
902
|
// Check the connect response
|
|
1048
903
|
const connectResponse = socket.__lastMessage
|
|
@@ -1055,8 +910,8 @@ describe('Client with future clock', () => {
|
|
|
1055
910
|
}
|
|
1056
911
|
})
|
|
1057
912
|
|
|
1058
|
-
it('provides all documents when client has future clock', () => {
|
|
1059
|
-
const
|
|
913
|
+
it('[HS4][HS5] provides all documents when the client has a future clock', () => {
|
|
914
|
+
const { room } = makeRoom({
|
|
1060
915
|
snapshot: makeSnapshot(records, {
|
|
1061
916
|
documentClock: 5,
|
|
1062
917
|
documents: [
|
|
@@ -1065,24 +920,10 @@ describe('Client with future clock', () => {
|
|
|
1065
920
|
],
|
|
1066
921
|
}),
|
|
1067
922
|
})
|
|
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
923
|
|
|
1080
|
-
room
|
|
1081
|
-
connectRequestId: 'connect-1',
|
|
924
|
+
const socket = connectSession(room, 'future-client', {
|
|
1082
925
|
lastServerClock: 999,
|
|
1083
|
-
|
|
1084
|
-
schema: room.serializedSchema,
|
|
1085
|
-
type: 'connect',
|
|
926
|
+
clear: false,
|
|
1086
927
|
})
|
|
1087
928
|
|
|
1088
929
|
const connectResponse = socket.__lastMessage
|
|
@@ -1093,175 +934,385 @@ describe('Client with future clock', () => {
|
|
|
1093
934
|
expect(connectResponse.diff[records[1].id]).toBeDefined()
|
|
1094
935
|
}
|
|
1095
936
|
})
|
|
937
|
+
|
|
938
|
+
it('[HS4][HS5] the connect response carries wipe_presence and other sessions presence, excluding the connecting session own presence', () => {
|
|
939
|
+
const { room, storage } = makeRoom()
|
|
940
|
+
|
|
941
|
+
connectSession(room, 'a')
|
|
942
|
+
room.handleMessage('a', {
|
|
943
|
+
type: 'push',
|
|
944
|
+
clientClock: 0,
|
|
945
|
+
presence: presencePut('Alice'),
|
|
946
|
+
} as TLPushRequest<TLRecord>)
|
|
947
|
+
const aPresenceId = room.sessions.get('a')!.presenceId!
|
|
948
|
+
|
|
949
|
+
// B connects and sees A's presence in the connect diff
|
|
950
|
+
const socketB = connectSession(room, 'b', {
|
|
951
|
+
isReadonly: true,
|
|
952
|
+
lastServerClock: storage.getClock(),
|
|
953
|
+
clear: false,
|
|
954
|
+
})
|
|
955
|
+
const response = socketB.__lastMessage as any
|
|
956
|
+
expect(response.type).toBe('connect')
|
|
957
|
+
expect(response.connectRequestId).toBe('connect-b')
|
|
958
|
+
expect(response.hydrationType).toBe('wipe_presence')
|
|
959
|
+
expect(response.isReadonly).toBe(true)
|
|
960
|
+
expect(response.serverClock).toBe(storage.getClock())
|
|
961
|
+
expect(response.diff).toEqual({
|
|
962
|
+
[aPresenceId]: ['put', expect.objectContaining({ id: aPresenceId, userName: 'Alice' })],
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
// B pushes presence, then A reconnects under the same session id
|
|
966
|
+
room.handleMessage('b', {
|
|
967
|
+
type: 'push',
|
|
968
|
+
clientClock: 0,
|
|
969
|
+
presence: presencePut('Bob'),
|
|
970
|
+
} as TLPushRequest<TLRecord>)
|
|
971
|
+
const bPresenceId = room.sessions.get('b')!.presenceId!
|
|
972
|
+
|
|
973
|
+
room.handleClose('a')
|
|
974
|
+
const socketA2 = connectSession(room, 'a', {
|
|
975
|
+
lastServerClock: storage.getClock(),
|
|
976
|
+
clear: false,
|
|
977
|
+
})
|
|
978
|
+
const response2 = socketA2.__lastMessage as any
|
|
979
|
+
expect(response2.type).toBe('connect')
|
|
980
|
+
|
|
981
|
+
// A's own (still stored) presence is excluded, B's presence is included
|
|
982
|
+
expect(room.presenceStore.get(aPresenceId)).toBeDefined()
|
|
983
|
+
expect(response2.diff[aPresenceId]).toBeUndefined()
|
|
984
|
+
expect(response2.diff[bPresenceId]).toEqual([
|
|
985
|
+
'put',
|
|
986
|
+
expect.objectContaining({ id: bPresenceId, userName: 'Bob' }),
|
|
987
|
+
])
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
it('[HS6] accepts a client with the same schema version and moves the session to Connected', () => {
|
|
991
|
+
const { room } = makeRoom()
|
|
992
|
+
const socket = connectSession(room, 'current-client-session', { clear: false })
|
|
993
|
+
|
|
994
|
+
expect(room.sessions.get('current-client-session')?.state).toBe(RoomSessionState.Connected)
|
|
995
|
+
expect(socket.__lastMessage?.type).toBe('connect')
|
|
996
|
+
})
|
|
1096
997
|
})
|
|
1097
998
|
|
|
1098
|
-
describe('
|
|
1099
|
-
|
|
1100
|
-
const
|
|
1101
|
-
const
|
|
999
|
+
describe('24. Push handling (RP)', () => {
|
|
1000
|
+
function setupTwoSessions() {
|
|
1001
|
+
const { room, storage } = makeRoom()
|
|
1002
|
+
const socketA = connectSession(room, 'a')
|
|
1003
|
+
const socketB = connectSession(room, 'b')
|
|
1004
|
+
return { room, storage, socketA, socketB }
|
|
1005
|
+
}
|
|
1102
1006
|
|
|
1007
|
+
it('[RP1] ignores pushes from sessions that have not completed the handshake', () => {
|
|
1008
|
+
const { room, storage } = makeRoom()
|
|
1103
1009
|
const socket = makeSocket()
|
|
1104
|
-
|
|
1010
|
+
room.handleNewSession({ sessionId: 's1', socket, meta: undefined, isReadonly: false })
|
|
1105
1011
|
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
socket,
|
|
1109
|
-
meta: undefined,
|
|
1110
|
-
isReadonly: false,
|
|
1111
|
-
})
|
|
1012
|
+
const clockBefore = storage.getClock()
|
|
1013
|
+
const newPage = makePage('early_page', 'Too early')
|
|
1112
1014
|
|
|
1113
|
-
room.handleMessage(
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1015
|
+
room.handleMessage('s1', {
|
|
1016
|
+
type: 'push',
|
|
1017
|
+
clientClock: 0,
|
|
1018
|
+
diff: { [newPage.id]: ['put', newPage] },
|
|
1019
|
+
} as TLPushRequest<TLRecord>)
|
|
1020
|
+
|
|
1021
|
+
expect(storage.getClock()).toBe(clockBefore)
|
|
1022
|
+
expect(storage.documents.get(newPage.id)).toBeUndefined()
|
|
1023
|
+
expect(socket.sendMessage).not.toHaveBeenCalled()
|
|
1024
|
+
expect(room.sessions.get('s1')?.state).toBe(RoomSessionState.AwaitingConnectMessage)
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
it('[RP2] rejects the session when a push contains a record with an unknown type', () => {
|
|
1028
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
1029
|
+
try {
|
|
1030
|
+
const { room } = makeRoom()
|
|
1031
|
+
const socket = connectSession(room, 'invalid-record-session')
|
|
1032
|
+
|
|
1033
|
+
// Try to push a record with an unknown type
|
|
1034
|
+
room.handleMessage('invalid-record-session', {
|
|
1035
|
+
type: 'push',
|
|
1036
|
+
clientClock: 1,
|
|
1037
|
+
diff: {
|
|
1038
|
+
'unknown:record': [
|
|
1039
|
+
'put',
|
|
1040
|
+
{
|
|
1041
|
+
id: 'unknown:record',
|
|
1042
|
+
typeName: 'unknown_type_that_does_not_exist',
|
|
1043
|
+
} as any,
|
|
1044
|
+
],
|
|
1045
|
+
},
|
|
1046
|
+
} as TLPushRequest<TLRecord>)
|
|
1047
|
+
|
|
1048
|
+
// Session should be rejected/removed
|
|
1049
|
+
expect(room.sessions.get('invalid-record-session')).toBeUndefined()
|
|
1050
|
+
expect(socket.close).toHaveBeenCalledWith(
|
|
1051
|
+
TLSyncErrorCloseEventCode,
|
|
1052
|
+
TLSyncErrorCloseEventReason.INVALID_RECORD
|
|
1053
|
+
)
|
|
1054
|
+
} finally {
|
|
1055
|
+
consoleSpy.mockRestore()
|
|
1056
|
+
}
|
|
1057
|
+
})
|
|
1058
|
+
|
|
1059
|
+
describe('schema validation of pushed records', () => {
|
|
1060
|
+
interface Book {
|
|
1061
|
+
typeName: 'book'
|
|
1062
|
+
id: RecordId<Book>
|
|
1063
|
+
title: string
|
|
1064
|
+
}
|
|
1065
|
+
const Book = createRecordType<Book>('book', {
|
|
1066
|
+
scope: 'document',
|
|
1067
|
+
validator: {
|
|
1068
|
+
validate: (record: unknown): Book => {
|
|
1069
|
+
if (typeof record !== 'object' || record === null) {
|
|
1070
|
+
throw new Error('Expected object')
|
|
1071
|
+
}
|
|
1072
|
+
if (!('title' in record)) {
|
|
1073
|
+
throw new Error('Expected title')
|
|
1074
|
+
}
|
|
1075
|
+
if (typeof record.title !== 'string') {
|
|
1076
|
+
throw new Error('Expected title to be a string')
|
|
1077
|
+
}
|
|
1078
|
+
return record as Book
|
|
1079
|
+
},
|
|
1080
|
+
},
|
|
1081
|
+
})
|
|
1082
|
+
const BookWithoutValidator = createRecordType<Book>('book', {
|
|
1083
|
+
scope: 'document',
|
|
1084
|
+
validator: { validate: (record) => record as Book },
|
|
1085
|
+
})
|
|
1086
|
+
type Presence = UnknownRecord & { typeName: 'presence' }
|
|
1087
|
+
const presenceType = createRecordType<Presence>('presence', {
|
|
1088
|
+
scope: 'presence',
|
|
1089
|
+
validator: { validate: (record) => record as Presence },
|
|
1119
1090
|
})
|
|
1120
|
-
;(socket.sendMessage as any).mockClear()
|
|
1121
1091
|
|
|
1122
|
-
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
index: 'a2' as IndexKey,
|
|
1092
|
+
const bookSchema = StoreSchema.create<Book | Presence>({ book: Book, presence: presenceType })
|
|
1093
|
+
const bookSchemaWithoutValidator = StoreSchema.create<Book | Presence>({
|
|
1094
|
+
book: BookWithoutValidator,
|
|
1095
|
+
presence: presenceType,
|
|
1127
1096
|
})
|
|
1128
1097
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1098
|
+
let consoleSpy: ReturnType<typeof vi.spyOn>
|
|
1099
|
+
beforeEach(() => {
|
|
1100
|
+
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
1101
|
+
})
|
|
1102
|
+
afterEach(() => {
|
|
1103
|
+
consoleSpy.mockRestore()
|
|
1131
1104
|
})
|
|
1132
1105
|
|
|
1133
|
-
|
|
1134
|
-
|
|
1106
|
+
async function makeTestInstance() {
|
|
1107
|
+
const server = new TestServer(bookSchema)
|
|
1108
|
+
const socketPair = new TestSocketPair('test', server)
|
|
1109
|
+
socketPair.connect()
|
|
1135
1110
|
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
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
|
-
}
|
|
1111
|
+
const flush = async () => {
|
|
1112
|
+
await Promise.resolve()
|
|
1113
|
+
while (socketPair.getNeedsFlushing()) {
|
|
1114
|
+
socketPair.flushClientSentEvents()
|
|
1115
|
+
socketPair.flushServerSentEvents()
|
|
1148
1116
|
}
|
|
1149
1117
|
}
|
|
1118
|
+
let onSyncError = vi.fn()
|
|
1119
|
+
const client = await new Promise<TLSyncClient<Book | Presence>>((resolve, reject) => {
|
|
1120
|
+
onSyncError = vi.fn(reject)
|
|
1121
|
+
const client = new TLSyncClient({
|
|
1122
|
+
store: new Store<Book | Presence, unknown>({
|
|
1123
|
+
schema: bookSchemaWithoutValidator,
|
|
1124
|
+
props: {},
|
|
1125
|
+
}),
|
|
1126
|
+
socket: socketPair.clientSocket as any,
|
|
1127
|
+
onLoad: resolve,
|
|
1128
|
+
onSyncError,
|
|
1129
|
+
presence: computed('', () => null),
|
|
1130
|
+
})
|
|
1131
|
+
disposables.push(() => client.close())
|
|
1132
|
+
flush()
|
|
1133
|
+
})
|
|
1134
|
+
|
|
1135
|
+
return {
|
|
1136
|
+
server,
|
|
1137
|
+
socketPair,
|
|
1138
|
+
client,
|
|
1139
|
+
flush,
|
|
1140
|
+
onSyncError,
|
|
1141
|
+
}
|
|
1150
1142
|
}
|
|
1151
1143
|
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
})
|
|
1144
|
+
it('[RP2] rejects invalid put operations that create a new document', async () => {
|
|
1145
|
+
const { client, flush, onSyncError, server } = await makeTestInstance()
|
|
1155
1146
|
|
|
1156
|
-
|
|
1157
|
-
const extraPage = PageRecordType.create({
|
|
1158
|
-
id: PageRecordType.createId('extra_page'),
|
|
1159
|
-
name: 'Extra Page',
|
|
1160
|
-
index: 'a2' as IndexKey,
|
|
1161
|
-
})
|
|
1147
|
+
const prevServerDocs = server.storage.getSnapshot().documents
|
|
1162
1148
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1149
|
+
client.store.put([
|
|
1150
|
+
{
|
|
1151
|
+
typeName: 'book',
|
|
1152
|
+
id: Book.createId('1'),
|
|
1153
|
+
// @ts-expect-error - deliberate invalid data
|
|
1154
|
+
title: 123 as string,
|
|
1155
|
+
},
|
|
1156
|
+
])
|
|
1157
|
+
await flush()
|
|
1158
|
+
|
|
1159
|
+
expect(onSyncError).toHaveBeenCalledTimes(1)
|
|
1160
|
+
expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
1161
|
+
expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
|
|
1162
|
+
})
|
|
1163
|
+
|
|
1164
|
+
it('[RP2] rejects invalid put operations that replace an existing document', async () => {
|
|
1165
|
+
const { client, flush, onSyncError, server } = await makeTestInstance()
|
|
1166
|
+
|
|
1167
|
+
let prevServerDocs = server.storage.getSnapshot().documents
|
|
1168
|
+
const book: Book = { typeName: 'book', id: Book.createId('1'), title: 'Annihilation' }
|
|
1169
|
+
client.store.put([book])
|
|
1170
|
+
await flush()
|
|
1171
|
+
|
|
1172
|
+
expect(onSyncError).toHaveBeenCalledTimes(0)
|
|
1173
|
+
expect(server.storage.getSnapshot().documents).not.toStrictEqual(prevServerDocs)
|
|
1174
|
+
prevServerDocs = server.storage.getSnapshot().documents
|
|
1175
|
+
|
|
1176
|
+
client.socket.sendMessage({
|
|
1177
|
+
type: 'push',
|
|
1178
|
+
// @ts-expect-error clientClock is private
|
|
1179
|
+
clientClock: client.clientClock++,
|
|
1180
|
+
diff: {
|
|
1181
|
+
[book.id]: [
|
|
1182
|
+
RecordOpType.Put,
|
|
1183
|
+
{
|
|
1184
|
+
...book,
|
|
1185
|
+
// @ts-expect-error - deliberate invalid data
|
|
1186
|
+
title: 123 as string,
|
|
1187
|
+
},
|
|
1188
|
+
],
|
|
1189
|
+
},
|
|
1190
|
+
})
|
|
1191
|
+
await flush()
|
|
1192
|
+
|
|
1193
|
+
expect(onSyncError).toHaveBeenCalledTimes(1)
|
|
1194
|
+
expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
1195
|
+
expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
|
|
1165
1196
|
})
|
|
1166
|
-
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
1167
1197
|
|
|
1168
|
-
|
|
1169
|
-
|
|
1198
|
+
it('[RP2] rejects invalid patch operations', async () => {
|
|
1199
|
+
const { client, flush, onSyncError, server } = await makeTestInstance()
|
|
1170
1200
|
|
|
1171
|
-
|
|
1172
|
-
sessionId,
|
|
1173
|
-
socket,
|
|
1174
|
-
meta: undefined,
|
|
1175
|
-
isReadonly: false,
|
|
1176
|
-
})
|
|
1201
|
+
let prevServerDocs = server.storage.getSnapshot().documents
|
|
1177
1202
|
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1203
|
+
// create the book
|
|
1204
|
+
client.store.put([
|
|
1205
|
+
{
|
|
1206
|
+
typeName: 'book',
|
|
1207
|
+
id: Book.createId('1'),
|
|
1208
|
+
title: 'The silence of the girls',
|
|
1209
|
+
},
|
|
1210
|
+
])
|
|
1211
|
+
await flush()
|
|
1186
1212
|
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1213
|
+
expect(onSyncError).toHaveBeenCalledTimes(0)
|
|
1214
|
+
expect(server.storage.getSnapshot().documents).not.toStrictEqual(prevServerDocs)
|
|
1215
|
+
prevServerDocs = server.storage.getSnapshot().documents
|
|
1216
|
+
|
|
1217
|
+
// update the title to be wrong
|
|
1218
|
+
client.store.put([
|
|
1219
|
+
{
|
|
1220
|
+
typeName: 'book',
|
|
1221
|
+
id: Book.createId('1'),
|
|
1222
|
+
// @ts-expect-error - deliberate invalid data
|
|
1223
|
+
title: 123 as string,
|
|
1224
|
+
},
|
|
1225
|
+
])
|
|
1226
|
+
await flush()
|
|
1227
|
+
expect(onSyncError).toHaveBeenCalledTimes(1)
|
|
1228
|
+
expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
1229
|
+
expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
|
|
1190
1230
|
})
|
|
1231
|
+
})
|
|
1191
1232
|
|
|
1192
|
-
|
|
1233
|
+
it('[RP3][RP7][RP8] a put of a new id stores the record, broadcasts a put, and commits', () => {
|
|
1234
|
+
const { storage, room, socketA, socketB } = setupTwoSessions()
|
|
1235
|
+
const newPage = makePage('page_3', 'my lovely page 3')
|
|
1193
1236
|
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
}
|
|
1237
|
+
room.handleMessage('a', {
|
|
1238
|
+
type: 'push',
|
|
1239
|
+
clientClock: 7,
|
|
1240
|
+
diff: { [newPage.id]: ['put', newPage] },
|
|
1241
|
+
} as TLPushRequest<TLRecord>)
|
|
1209
1242
|
|
|
1210
|
-
|
|
1211
|
-
|
|
1243
|
+
expect(storage.documents.get(newPage.id)?.state).toEqual(newPage)
|
|
1244
|
+
|
|
1245
|
+
// The pusher gets a commit and never its own patch back
|
|
1246
|
+
expect(sentDataMessages(socketA)).toEqual([
|
|
1247
|
+
{ type: 'push_result', clientClock: 7, serverClock: 1, action: 'commit' },
|
|
1248
|
+
])
|
|
1249
|
+
|
|
1250
|
+
// The other session gets the put broadcast
|
|
1251
|
+
expect(sentDataMessages(socketB)).toEqual([
|
|
1252
|
+
{ type: 'patch', diff: { [newPage.id]: ['put', newPage] }, serverClock: 1 },
|
|
1253
|
+
])
|
|
1212
1254
|
})
|
|
1213
|
-
})
|
|
1214
1255
|
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
const
|
|
1218
|
-
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
1219
|
-
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
1256
|
+
it('[RP3][RP7] a put over an existing record broadcasts only the difference and rebases the pusher', () => {
|
|
1257
|
+
const { storage, room, socketA, socketB } = setupTwoSessions()
|
|
1258
|
+
const renamed = { ...pageRecord, name: 'renamed' }
|
|
1220
1259
|
|
|
1221
|
-
|
|
1222
|
-
|
|
1260
|
+
room.handleMessage('a', {
|
|
1261
|
+
type: 'push',
|
|
1262
|
+
clientClock: 1,
|
|
1263
|
+
diff: { [pageRecord.id]: ['put', renamed] },
|
|
1264
|
+
} as TLPushRequest<TLRecord>)
|
|
1223
1265
|
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1266
|
+
expect(storage.documents.get(pageRecord.id)?.state).toEqual(renamed)
|
|
1267
|
+
|
|
1268
|
+
const expectedDiff = { [pageRecord.id]: ['patch', { name: ['put', 'renamed'] }] }
|
|
1269
|
+
expect(sentDataMessages(socketB)).toEqual([
|
|
1270
|
+
{ type: 'patch', diff: expectedDiff, serverClock: 1 },
|
|
1271
|
+
])
|
|
1272
|
+
expect(sentDataMessages(socketA)).toEqual([
|
|
1273
|
+
{
|
|
1274
|
+
type: 'push_result',
|
|
1275
|
+
clientClock: 1,
|
|
1276
|
+
serverClock: 1,
|
|
1277
|
+
action: { rebaseWithDiff: expectedDiff },
|
|
1278
|
+
},
|
|
1279
|
+
])
|
|
1280
|
+
})
|
|
1230
1281
|
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1282
|
+
it('[RP3][RP7] a put equal to the stored record changes nothing and is discarded', () => {
|
|
1283
|
+
const { storage, room, socketA, socketB } = setupTwoSessions()
|
|
1284
|
+
|
|
1285
|
+
room.handleMessage('a', {
|
|
1286
|
+
type: 'push',
|
|
1287
|
+
clientClock: 1,
|
|
1288
|
+
diff: { [pageRecord.id]: ['put', { ...pageRecord }] },
|
|
1289
|
+
} as TLPushRequest<TLRecord>)
|
|
1290
|
+
|
|
1291
|
+
expect(storage.getClock()).toBe(0)
|
|
1292
|
+
expect(sentDataMessages(socketA)).toEqual([
|
|
1293
|
+
{ type: 'push_result', clientClock: 1, serverClock: 0, action: 'discard' },
|
|
1294
|
+
])
|
|
1295
|
+
expect(socketB.sendMessage).not.toHaveBeenCalled()
|
|
1296
|
+
})
|
|
1238
1297
|
|
|
1239
|
-
|
|
1298
|
+
it('[RP4][RP7] a patch for a missing record is silently ignored and the push discarded', () => {
|
|
1299
|
+
const { storage, room, socketA, socketB } = setupTwoSessions()
|
|
1240
1300
|
|
|
1241
|
-
|
|
1242
|
-
room.handleMessage(sessionId, {
|
|
1301
|
+
room.handleMessage('a', {
|
|
1243
1302
|
type: 'push',
|
|
1244
1303
|
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
|
-
},
|
|
1304
|
+
diff: { 'page:does_not_exist': ['patch', { name: ['put', 'whatever'] }] },
|
|
1254
1305
|
} as TLPushRequest<TLRecord>)
|
|
1255
1306
|
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
expect(
|
|
1259
|
-
|
|
1307
|
+
expect(storage.getClock()).toBe(0)
|
|
1308
|
+
expect(room.sessions.get('a')?.state).toBe(RoomSessionState.Connected)
|
|
1309
|
+
expect(sentDataMessages(socketA)).toEqual([
|
|
1310
|
+
{ type: 'push_result', clientClock: 1, serverClock: 0, action: 'discard' },
|
|
1311
|
+
])
|
|
1312
|
+
expect(socketB.sendMessage).not.toHaveBeenCalled()
|
|
1260
1313
|
})
|
|
1261
|
-
})
|
|
1262
1314
|
|
|
1263
|
-
|
|
1264
|
-
it('successfully patches arrow shape with current schema', () => {
|
|
1315
|
+
it('[RP5] successfully patches an arrow shape with the current schema', () => {
|
|
1265
1316
|
// Create a document in the room first
|
|
1266
1317
|
const existingArrow: TLArrowShape = {
|
|
1267
1318
|
typeName: 'shape',
|
|
@@ -1295,34 +1346,11 @@ describe('Migration and patch handling', () => {
|
|
|
1295
1346
|
meta: {},
|
|
1296
1347
|
}
|
|
1297
1348
|
|
|
1298
|
-
const storage =
|
|
1299
|
-
|
|
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')
|
|
1349
|
+
const { room, storage } = makeRoom({ snapshot: makeSnapshot([...records, existingArrow]) })
|
|
1350
|
+
connectSession(room, 'patch-migration-session')
|
|
1323
1351
|
|
|
1324
1352
|
// Patch the arrow - this should work normally
|
|
1325
|
-
room.handleMessage(
|
|
1353
|
+
room.handleMessage('patch-migration-session', {
|
|
1326
1354
|
type: 'push',
|
|
1327
1355
|
clientClock: 1,
|
|
1328
1356
|
diff: {
|
|
@@ -1331,78 +1359,934 @@ describe('Migration and patch handling', () => {
|
|
|
1331
1359
|
} as TLPushRequest<TLRecord>)
|
|
1332
1360
|
|
|
1333
1361
|
// Session should still be connected after valid patch
|
|
1334
|
-
expect(room.sessions.get(
|
|
1362
|
+
expect(room.sessions.get('patch-migration-session')?.state).toBe(RoomSessionState.Connected)
|
|
1335
1363
|
|
|
1336
1364
|
// Verify the patch was applied
|
|
1337
1365
|
const patchedArrow = storage.documents.get(existingArrow.id)?.state as TLArrowShape
|
|
1338
1366
|
expect(patchedArrow.props.color).toBe('red')
|
|
1339
1367
|
})
|
|
1340
1368
|
|
|
1341
|
-
it('
|
|
1342
|
-
|
|
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
|
-
}
|
|
1369
|
+
it('[RP6][RP7] a remove deletes the record, writes a tombstone, and broadcasts the removal', () => {
|
|
1370
|
+
const { storage, room, socketA, socketB } = setupTwoSessions()
|
|
1352
1371
|
|
|
1353
|
-
|
|
1354
|
-
|
|
1372
|
+
room.handleMessage('a', {
|
|
1373
|
+
type: 'push',
|
|
1374
|
+
clientClock: 1,
|
|
1375
|
+
diff: { [pageRecord.id]: ['remove'] },
|
|
1376
|
+
} as TLPushRequest<TLRecord>)
|
|
1355
1377
|
|
|
1356
|
-
|
|
1357
|
-
|
|
1378
|
+
expect(storage.documents.get(pageRecord.id)).toBeUndefined()
|
|
1379
|
+
expect(storage.tombstones.get(pageRecord.id)).toBe(1)
|
|
1358
1380
|
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1381
|
+
expect(sentDataMessages(socketA)).toEqual([
|
|
1382
|
+
{ type: 'push_result', clientClock: 1, serverClock: 1, action: 'commit' },
|
|
1383
|
+
])
|
|
1384
|
+
expect(sentDataMessages(socketB)).toEqual([
|
|
1385
|
+
{ type: 'patch', diff: { [pageRecord.id]: ['remove'] }, serverClock: 1 },
|
|
1386
|
+
])
|
|
1387
|
+
})
|
|
1365
1388
|
|
|
1366
|
-
|
|
1367
|
-
room
|
|
1368
|
-
connectRequestId: 'connect-1',
|
|
1369
|
-
lastServerClock: 0,
|
|
1370
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
1371
|
-
schema: oldSerializedSchema,
|
|
1372
|
-
type: 'connect',
|
|
1373
|
-
})
|
|
1389
|
+
it('[RP6][RP7] a remove of a missing id is ignored', () => {
|
|
1390
|
+
const { storage, room, socketA, socketB } = setupTwoSessions()
|
|
1374
1391
|
|
|
1375
|
-
|
|
1376
|
-
|
|
1392
|
+
room.handleMessage('a', {
|
|
1393
|
+
type: 'push',
|
|
1394
|
+
clientClock: 1,
|
|
1395
|
+
diff: { 'page:does_not_exist': ['remove'] },
|
|
1396
|
+
} as TLPushRequest<TLRecord>)
|
|
1377
1397
|
|
|
1378
|
-
|
|
1379
|
-
expect(
|
|
1398
|
+
expect(storage.getClock()).toBe(0)
|
|
1399
|
+
expect(storage.tombstones.get('page:does_not_exist')).toBeUndefined()
|
|
1400
|
+
expect(sentDataMessages(socketA)).toEqual([
|
|
1401
|
+
{ type: 'push_result', clientClock: 1, serverClock: 0, action: 'discard' },
|
|
1402
|
+
])
|
|
1403
|
+
expect(socketB.sendMessage).not.toHaveBeenCalled()
|
|
1380
1404
|
})
|
|
1381
1405
|
|
|
1382
|
-
it('
|
|
1383
|
-
const
|
|
1384
|
-
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
1406
|
+
it('[RP7] a patch that only partially applies gets rebaseWithDiff carrying the effective diff', () => {
|
|
1407
|
+
const { room, socketA, socketB } = setupTwoSessions()
|
|
1385
1408
|
|
|
1386
|
-
|
|
1387
|
-
|
|
1409
|
+
// The name op has an effect, the index op is a no-op
|
|
1410
|
+
room.handleMessage('a', {
|
|
1411
|
+
type: 'push',
|
|
1412
|
+
clientClock: 1,
|
|
1413
|
+
diff: {
|
|
1414
|
+
[pageRecord.id]: ['patch', { name: ['put', 'renamed'], index: ['put', ZERO_INDEX_KEY] }],
|
|
1415
|
+
},
|
|
1416
|
+
} as TLPushRequest<TLRecord>)
|
|
1388
1417
|
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1418
|
+
const effectiveDiff = { [pageRecord.id]: ['patch', { name: ['put', 'renamed'] }] }
|
|
1419
|
+
expect(sentDataMessages(socketA)).toEqual([
|
|
1420
|
+
{
|
|
1421
|
+
type: 'push_result',
|
|
1422
|
+
clientClock: 1,
|
|
1423
|
+
serverClock: 1,
|
|
1424
|
+
action: { rebaseWithDiff: effectiveDiff },
|
|
1425
|
+
},
|
|
1426
|
+
])
|
|
1427
|
+
expect(sentDataMessages(socketB)).toEqual([
|
|
1428
|
+
{ type: 'patch', diff: effectiveDiff, serverClock: 1 },
|
|
1429
|
+
])
|
|
1430
|
+
})
|
|
1395
1431
|
|
|
1396
|
-
|
|
1397
|
-
room
|
|
1398
|
-
|
|
1432
|
+
it('[RP7] a presence-only push gets a commit', () => {
|
|
1433
|
+
const { room, socketA } = setupTwoSessions()
|
|
1434
|
+
|
|
1435
|
+
room.handleMessage('a', {
|
|
1436
|
+
type: 'push',
|
|
1437
|
+
clientClock: 9,
|
|
1438
|
+
presence: presencePut(),
|
|
1439
|
+
} as TLPushRequest<TLRecord>)
|
|
1440
|
+
|
|
1441
|
+
expect(sentDataMessages(socketA)).toEqual([
|
|
1442
|
+
{ type: 'push_result', clientClock: 9, serverClock: 0, action: 'commit' },
|
|
1443
|
+
])
|
|
1444
|
+
})
|
|
1445
|
+
|
|
1446
|
+
it('[RP9] does not allow updates from users who are marked as readonly', () => {
|
|
1447
|
+
const { room, storage } = makeRoom()
|
|
1448
|
+
const socketA = connectSession(room, 'sessionA', { isReadonly: true })
|
|
1449
|
+
const socketB = connectSession(room, 'sessionB', { isReadonly: false })
|
|
1450
|
+
const getDoc = (id: string) => storage.documents.get(id)?.state
|
|
1451
|
+
|
|
1452
|
+
const push: TLPushRequest<any> = {
|
|
1453
|
+
clientClock: 0,
|
|
1454
|
+
diff: {
|
|
1455
|
+
'page:page_3': [
|
|
1456
|
+
'put',
|
|
1457
|
+
PageRecordType.create({
|
|
1458
|
+
id: 'page:page_3' as any,
|
|
1459
|
+
name: 'my lovely page 3',
|
|
1460
|
+
index: 'ab34' as IndexKey,
|
|
1461
|
+
}),
|
|
1462
|
+
],
|
|
1463
|
+
},
|
|
1464
|
+
presence: undefined,
|
|
1465
|
+
type: 'push',
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// sessionA is readonly
|
|
1469
|
+
room.handleMessage('sessionA', push)
|
|
1470
|
+
|
|
1471
|
+
expect(getDoc('page:page_3')).toBe(undefined)
|
|
1472
|
+
// should tell the session to discard it
|
|
1473
|
+
expect(socketA.__lastMessage).toEqual({
|
|
1474
|
+
type: 'data',
|
|
1475
|
+
data: [{ type: 'push_result', clientClock: 0, serverClock: 0, action: 'discard' }],
|
|
1476
|
+
})
|
|
1477
|
+
// should not have sent anything to sessionB
|
|
1478
|
+
expect(socketB.__lastMessage).toBe(null)
|
|
1479
|
+
|
|
1480
|
+
// sessionB is not readonly
|
|
1481
|
+
room.handleMessage('sessionB', push)
|
|
1482
|
+
|
|
1483
|
+
expect(getDoc('page:page_3')).not.toBe(undefined)
|
|
1484
|
+
|
|
1485
|
+
// should tell the session to commit it
|
|
1486
|
+
expect(socketB.__lastMessage).toEqual({
|
|
1487
|
+
type: 'data',
|
|
1488
|
+
data: [{ type: 'push_result', clientClock: 0, serverClock: 1, action: 'commit' }],
|
|
1489
|
+
})
|
|
1490
|
+
})
|
|
1491
|
+
|
|
1492
|
+
it('[RP9][RP7][RP10] still allows presence updates from readonly users', () => {
|
|
1493
|
+
const { room } = makeRoom()
|
|
1494
|
+
const socketA = connectSession(room, 'sessionA', { isReadonly: true })
|
|
1495
|
+
const socketB = connectSession(room, 'sessionB', { isReadonly: false })
|
|
1496
|
+
const presenceIdA = room.sessions.get('sessionA')!.presenceId!
|
|
1497
|
+
|
|
1498
|
+
const presencePushRequest: TLPushRequest<any> = {
|
|
1499
|
+
clientClock: 0,
|
|
1500
|
+
diff: undefined,
|
|
1501
|
+
presence: [
|
|
1502
|
+
'put',
|
|
1503
|
+
InstancePresenceRecordType.create({
|
|
1504
|
+
id: InstancePresenceRecordType.createId('foo'),
|
|
1505
|
+
currentPageId: 'page:page_2' as any,
|
|
1506
|
+
userId: createUserId('foo'),
|
|
1507
|
+
userName: 'Jimbo',
|
|
1508
|
+
}),
|
|
1509
|
+
],
|
|
1510
|
+
type: 'push',
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// sessionA is readonly
|
|
1514
|
+
room.handleMessage('sessionA', presencePushRequest)
|
|
1515
|
+
|
|
1516
|
+
// commit for sessionA
|
|
1517
|
+
expect(socketA.__lastMessage).toEqual({
|
|
1518
|
+
type: 'data',
|
|
1519
|
+
data: [{ type: 'push_result', clientClock: 0, serverClock: 0, action: 'commit' }],
|
|
1520
|
+
})
|
|
1521
|
+
|
|
1522
|
+
// patch for sessionB, stored under sessionA's presence id
|
|
1523
|
+
expect(socketB.__lastMessage).toEqual({
|
|
1524
|
+
type: 'data',
|
|
1525
|
+
data: [
|
|
1526
|
+
{
|
|
1527
|
+
type: 'patch',
|
|
1528
|
+
serverClock: 0,
|
|
1529
|
+
diff: {
|
|
1530
|
+
[presenceIdA]: [
|
|
1531
|
+
'put',
|
|
1532
|
+
expect.objectContaining({
|
|
1533
|
+
id: presenceIdA,
|
|
1534
|
+
typeName: 'instance_presence',
|
|
1535
|
+
currentPageId: 'page:page_2',
|
|
1536
|
+
userId: 'user:foo',
|
|
1537
|
+
userName: 'Jimbo',
|
|
1538
|
+
}),
|
|
1539
|
+
],
|
|
1540
|
+
},
|
|
1541
|
+
},
|
|
1542
|
+
],
|
|
1543
|
+
})
|
|
1544
|
+
})
|
|
1545
|
+
|
|
1546
|
+
it('[RP10] presence changes do not affect the document clock', () => {
|
|
1547
|
+
const { room, storage } = makeRoom({ snapshot: makeSnapshot(records, { documentClock: 10 }) })
|
|
1548
|
+
connectSession(room, 'presence-test', { lastServerClock: 10 })
|
|
1549
|
+
|
|
1550
|
+
const clockBefore = storage.getClock()
|
|
1551
|
+
|
|
1552
|
+
// Send presence update
|
|
1553
|
+
room.handleMessage('presence-test', {
|
|
1554
|
+
type: 'push',
|
|
1555
|
+
clientClock: 1,
|
|
1556
|
+
diff: undefined,
|
|
1557
|
+
presence: presencePut('Test User'),
|
|
1558
|
+
} as TLPushRequest<TLRecord>)
|
|
1559
|
+
|
|
1560
|
+
// Document clock should not have changed
|
|
1561
|
+
expect(storage.getClock()).toBe(clockBefore)
|
|
1562
|
+
})
|
|
1563
|
+
|
|
1564
|
+
it('[RP10] presence is stored in the presence store with a forced id and typeName, never in document storage', () => {
|
|
1565
|
+
const { room, storage } = makeRoom()
|
|
1566
|
+
connectSession(room, 'presence-test')
|
|
1567
|
+
|
|
1568
|
+
const presenceId = room.sessions.get('presence-test')!.presenceId!
|
|
1569
|
+
|
|
1570
|
+
// Send a presence update whose record has a client-chosen id
|
|
1571
|
+
room.handleMessage('presence-test', {
|
|
1572
|
+
type: 'push',
|
|
1573
|
+
clientClock: 1,
|
|
1574
|
+
diff: undefined,
|
|
1575
|
+
presence: presencePut('Test User'),
|
|
1576
|
+
} as TLPushRequest<TLRecord>)
|
|
1577
|
+
|
|
1578
|
+
// Presence should be in presenceStore under the session's presence id,
|
|
1579
|
+
// with id and typeName forced server-side
|
|
1580
|
+
const stored = room.presenceStore.get(presenceId) as any
|
|
1581
|
+
expect(stored).toBeDefined()
|
|
1582
|
+
expect(stored.id).toBe(presenceId)
|
|
1583
|
+
expect(stored.typeName).toBe('instance_presence')
|
|
1584
|
+
expect(stored.userName).toBe('Test User')
|
|
1585
|
+
|
|
1586
|
+
// Presence should NOT be in document storage
|
|
1587
|
+
storage.transaction((txn) => {
|
|
1588
|
+
expect(txn.get(presenceId)).toBeUndefined()
|
|
1589
|
+
})
|
|
1590
|
+
})
|
|
1591
|
+
|
|
1592
|
+
it('[RP10][SES3] presence is removed from the presence store when the session is removed', () => {
|
|
1593
|
+
const { room } = makeRoom()
|
|
1594
|
+
connectSession(room, 'presence-test')
|
|
1595
|
+
|
|
1596
|
+
const presenceId = room.sessions.get('presence-test')!.presenceId!
|
|
1597
|
+
|
|
1598
|
+
room.handleMessage('presence-test', {
|
|
1599
|
+
type: 'push',
|
|
1600
|
+
clientClock: 1,
|
|
1601
|
+
diff: undefined,
|
|
1602
|
+
presence: presencePut('Test User'),
|
|
1603
|
+
} as TLPushRequest<TLRecord>)
|
|
1604
|
+
|
|
1605
|
+
expect(room.presenceStore.get(presenceId)).toBeDefined()
|
|
1606
|
+
|
|
1607
|
+
// Close the session
|
|
1608
|
+
room.rejectSession('presence-test')
|
|
1609
|
+
|
|
1610
|
+
// Presence should be removed
|
|
1611
|
+
expect(room.presenceStore.get(presenceId)).toBeUndefined()
|
|
1612
|
+
})
|
|
1613
|
+
|
|
1614
|
+
it('[RP10] onPresenceChange fires on a microtask after presence changes', async () => {
|
|
1615
|
+
const onPresenceChange = vi.fn()
|
|
1616
|
+
const { room } = makeRoom({ onPresenceChange })
|
|
1617
|
+
connectSession(room, 'a')
|
|
1618
|
+
|
|
1619
|
+
room.handleMessage('a', {
|
|
1620
|
+
type: 'push',
|
|
1621
|
+
clientClock: 1,
|
|
1622
|
+
presence: presencePut(),
|
|
1623
|
+
} as TLPushRequest<TLRecord>)
|
|
1624
|
+
|
|
1625
|
+
// Not called synchronously
|
|
1626
|
+
expect(onPresenceChange).not.toHaveBeenCalled()
|
|
1627
|
+
|
|
1628
|
+
await Promise.resolve()
|
|
1629
|
+
expect(onPresenceChange).toHaveBeenCalledTimes(1)
|
|
1630
|
+
})
|
|
1631
|
+
|
|
1632
|
+
it('[RP11] pings are answered with pong, and pings and pushes update lastInteractionTime', () => {
|
|
1633
|
+
vi.useFakeTimers()
|
|
1634
|
+
const { room } = makeRoom()
|
|
1635
|
+
const socket = connectSession(room, 'a')
|
|
1636
|
+
const t0 = (room.sessions.get('a') as any).lastInteractionTime
|
|
1637
|
+
|
|
1638
|
+
vi.advanceTimersByTime(1234)
|
|
1639
|
+
room.handleMessage('a', { type: 'ping' })
|
|
1640
|
+
expect(socket.__lastMessage).toEqual({ type: 'pong' })
|
|
1641
|
+
expect((room.sessions.get('a') as any).lastInteractionTime).toBe(t0 + 1234)
|
|
1642
|
+
|
|
1643
|
+
vi.advanceTimersByTime(1000)
|
|
1644
|
+
room.handleMessage('a', {
|
|
1645
|
+
type: 'push',
|
|
1646
|
+
clientClock: 1,
|
|
1647
|
+
diff: { [pageRecord.id]: ['patch', { name: ['put', 'renamed'] }] },
|
|
1648
|
+
} as TLPushRequest<TLRecord>)
|
|
1649
|
+
expect((room.sessions.get('a') as any).lastInteractionTime).toBe(t0 + 2234)
|
|
1650
|
+
})
|
|
1651
|
+
|
|
1652
|
+
it('[RP12][RP2] a TLSyncError mid-push rejects only the offending session', () => {
|
|
1653
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
1654
|
+
try {
|
|
1655
|
+
const { room, socketA, socketB } = setupTwoSessions()
|
|
1656
|
+
|
|
1657
|
+
room.handleMessage('a', {
|
|
1658
|
+
type: 'push',
|
|
1659
|
+
clientClock: 1,
|
|
1660
|
+
diff: {
|
|
1661
|
+
'unknown:record': [
|
|
1662
|
+
'put',
|
|
1663
|
+
{ id: 'unknown:record', typeName: 'unknown_type_that_does_not_exist' } as any,
|
|
1664
|
+
],
|
|
1665
|
+
},
|
|
1666
|
+
} as TLPushRequest<TLRecord>)
|
|
1667
|
+
|
|
1668
|
+
// Only the offending session is rejected
|
|
1669
|
+
expect(room.sessions.get('a')).toBeUndefined()
|
|
1670
|
+
expect(socketA.close).toHaveBeenCalledWith(
|
|
1671
|
+
TLSyncErrorCloseEventCode,
|
|
1672
|
+
TLSyncErrorCloseEventReason.INVALID_RECORD
|
|
1673
|
+
)
|
|
1674
|
+
expect(room.sessions.get('b')?.state).toBe(RoomSessionState.Connected)
|
|
1675
|
+
expect(socketB.close).not.toHaveBeenCalled()
|
|
1676
|
+
} finally {
|
|
1677
|
+
consoleSpy.mockRestore()
|
|
1678
|
+
}
|
|
1679
|
+
})
|
|
1680
|
+
|
|
1681
|
+
it('[RP13] when storage modifies a pushed change, the pusher is rebased with the storage actual changes and others receive them', () => {
|
|
1682
|
+
// A storage layer that embellishes every written record (e.g. with server
|
|
1683
|
+
// metadata) and reports the actual changes back to the room.
|
|
1684
|
+
class EmbellishingStorage implements TLSyncStorage<TLRecord> {
|
|
1685
|
+
constructor(readonly inner: InMemorySyncStorage<TLRecord>) {}
|
|
1686
|
+
getClock() {
|
|
1687
|
+
return this.inner.getClock()
|
|
1688
|
+
}
|
|
1689
|
+
onChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown) {
|
|
1690
|
+
return this.inner.onChange(callback)
|
|
1691
|
+
}
|
|
1692
|
+
getSnapshot() {
|
|
1693
|
+
return this.inner.getSnapshot()
|
|
1694
|
+
}
|
|
1695
|
+
transaction<T>(
|
|
1696
|
+
callback: TLSyncStorageTransactionCallback<TLRecord, T>,
|
|
1697
|
+
opts?: TLSyncStorageTransactionOptions
|
|
1698
|
+
): TLSyncStorageTransactionResult<T, TLRecord> {
|
|
1699
|
+
return this.inner.transaction<T>(
|
|
1700
|
+
((txn: TLSyncStorageTransaction<TLRecord>) => {
|
|
1701
|
+
const wrapped: TLSyncStorageTransaction<TLRecord> = {
|
|
1702
|
+
getClock: () => txn.getClock(),
|
|
1703
|
+
getChangesSince: (since) => txn.getChangesSince(since),
|
|
1704
|
+
get: (id) => txn.get(id),
|
|
1705
|
+
set: (id, record) =>
|
|
1706
|
+
txn.set(id, {
|
|
1707
|
+
...record,
|
|
1708
|
+
meta: { ...(record as any).meta, embellished: true },
|
|
1709
|
+
} as TLRecord),
|
|
1710
|
+
delete: (id) => txn.delete(id),
|
|
1711
|
+
entries: () => txn.entries(),
|
|
1712
|
+
keys: () => txn.keys(),
|
|
1713
|
+
values: () => txn.values(),
|
|
1714
|
+
getSchema: () => txn.getSchema(),
|
|
1715
|
+
setSchema: (s) => txn.setSchema(s),
|
|
1716
|
+
}
|
|
1717
|
+
return (callback as any)(wrapped)
|
|
1718
|
+
}) as any,
|
|
1719
|
+
// Always emit the actual changes so the room sees that they differ
|
|
1720
|
+
// from what the client requested
|
|
1721
|
+
{ ...opts, emitChanges: 'always' }
|
|
1722
|
+
)
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
const inner = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
1727
|
+
const storage = new EmbellishingStorage(inner)
|
|
1728
|
+
const room = new TLSyncRoom<TLRecord, undefined>({ schema, storage })
|
|
1729
|
+
disposables.push(() => room.close())
|
|
1730
|
+
|
|
1731
|
+
const socketA = connectSession(room, 'a')
|
|
1732
|
+
const socketB = connectSession(room, 'b')
|
|
1733
|
+
|
|
1734
|
+
const newPage = makePage('page_3', 'embellish me')
|
|
1735
|
+
room.handleMessage('a', {
|
|
1736
|
+
type: 'push',
|
|
1737
|
+
clientClock: 1,
|
|
1738
|
+
diff: { [newPage.id]: ['put', newPage] },
|
|
1739
|
+
} as TLPushRequest<TLRecord>)
|
|
1740
|
+
|
|
1741
|
+
const embellished = { ...newPage, meta: { embellished: true } }
|
|
1742
|
+
expect(inner.documents.get(newPage.id)?.state).toEqual(embellished)
|
|
1743
|
+
|
|
1744
|
+
// The pusher is rebased onto the storage's actual changes
|
|
1745
|
+
expect(sentDataMessages(socketA)).toEqual([
|
|
1746
|
+
{
|
|
1747
|
+
type: 'push_result',
|
|
1748
|
+
clientClock: 1,
|
|
1749
|
+
serverClock: 1,
|
|
1750
|
+
action: { rebaseWithDiff: { [newPage.id]: ['put', embellished] } },
|
|
1751
|
+
},
|
|
1752
|
+
])
|
|
1753
|
+
|
|
1754
|
+
// Other sessions receive the actual changes too
|
|
1755
|
+
expect(sentDataMessages(socketB)).toEqual([
|
|
1756
|
+
{ type: 'patch', diff: { [newPage.id]: ['put', embellished] }, serverClock: 1 },
|
|
1757
|
+
])
|
|
1758
|
+
})
|
|
1759
|
+
})
|
|
1760
|
+
|
|
1761
|
+
describe('25. Messaging and broadcast (RB)', () => {
|
|
1762
|
+
function setupTwoSessions() {
|
|
1763
|
+
const { room, storage } = makeRoom()
|
|
1764
|
+
const socketA = connectSession(room, 'a')
|
|
1765
|
+
const socketB = connectSession(room, 'b')
|
|
1766
|
+
return { room, storage, socketA, socketB }
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
it('[RB1] data messages are debounced: the first is sent immediately, the rest are buffered until the flush', () => {
|
|
1770
|
+
vi.useFakeTimers()
|
|
1771
|
+
const { room, socketB } = setupTwoSessions()
|
|
1772
|
+
const newPage = makePage('page_3', 'v1')
|
|
1773
|
+
|
|
1774
|
+
// First data message is sent immediately, wrapped as a data event
|
|
1775
|
+
room.handleMessage('a', {
|
|
1776
|
+
type: 'push',
|
|
1777
|
+
clientClock: 1,
|
|
1778
|
+
diff: { [newPage.id]: ['put', newPage] },
|
|
1779
|
+
} as TLPushRequest<TLRecord>)
|
|
1780
|
+
|
|
1781
|
+
expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
|
|
1782
|
+
expect(sentMessages(socketB)[0]).toEqual({
|
|
1783
|
+
type: 'data',
|
|
1784
|
+
data: [{ type: 'patch', diff: { [newPage.id]: ['put', newPage] }, serverClock: 1 }],
|
|
1785
|
+
})
|
|
1786
|
+
|
|
1787
|
+
// Messages within the debounce interval are buffered
|
|
1788
|
+
room.handleMessage('a', {
|
|
1789
|
+
type: 'push',
|
|
1790
|
+
clientClock: 2,
|
|
1791
|
+
diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
|
|
1792
|
+
} as TLPushRequest<TLRecord>)
|
|
1793
|
+
room.handleMessage('a', {
|
|
1794
|
+
type: 'push',
|
|
1795
|
+
clientClock: 3,
|
|
1796
|
+
diff: { [newPage.id]: ['patch', { name: ['put', 'v3'] }] },
|
|
1797
|
+
} as TLPushRequest<TLRecord>)
|
|
1798
|
+
|
|
1799
|
+
expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
|
|
1800
|
+
|
|
1801
|
+
// ...and flushed together as a single data message
|
|
1802
|
+
vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
|
|
1803
|
+
|
|
1804
|
+
expect(socketB.sendMessage).toHaveBeenCalledTimes(2)
|
|
1805
|
+
expect(sentMessages(socketB)[1]).toEqual({
|
|
1806
|
+
type: 'data',
|
|
1807
|
+
data: [
|
|
1808
|
+
{
|
|
1809
|
+
type: 'patch',
|
|
1810
|
+
diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
|
|
1811
|
+
serverClock: 2,
|
|
1812
|
+
},
|
|
1813
|
+
{
|
|
1814
|
+
type: 'patch',
|
|
1815
|
+
diff: { [newPage.id]: ['patch', { name: ['put', 'v3'] }] },
|
|
1816
|
+
serverClock: 3,
|
|
1817
|
+
},
|
|
1818
|
+
],
|
|
1819
|
+
})
|
|
1820
|
+
})
|
|
1821
|
+
|
|
1822
|
+
it('[RB1] the flushed data array is handed off to the socket, not truncated in place', () => {
|
|
1823
|
+
vi.useFakeTimers()
|
|
1824
|
+
const { room, socketB } = setupTwoSessions()
|
|
1825
|
+
const newPage = makePage('page_3', 'v1')
|
|
1826
|
+
|
|
1827
|
+
room.handleMessage('a', {
|
|
1828
|
+
type: 'push',
|
|
1829
|
+
clientClock: 1,
|
|
1830
|
+
diff: { [newPage.id]: ['put', newPage] },
|
|
1831
|
+
} as TLPushRequest<TLRecord>)
|
|
1832
|
+
room.handleMessage('a', {
|
|
1833
|
+
type: 'push',
|
|
1834
|
+
clientClock: 2,
|
|
1835
|
+
diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
|
|
1836
|
+
} as TLPushRequest<TLRecord>)
|
|
1837
|
+
room.handleMessage('a', {
|
|
1838
|
+
type: 'push',
|
|
1839
|
+
clientClock: 3,
|
|
1840
|
+
diff: { [newPage.id]: ['patch', { name: ['put', 'v3'] }] },
|
|
1841
|
+
} as TLPushRequest<TLRecord>)
|
|
1842
|
+
|
|
1843
|
+
// capture the raw message object without cloning, as a socket that defers
|
|
1844
|
+
// serialization would observe it
|
|
1845
|
+
const rawMessages: any[] = []
|
|
1846
|
+
socketB.sendMessage.mockImplementation((msg: any) => rawMessages.push(msg))
|
|
1847
|
+
vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
|
|
1848
|
+
|
|
1849
|
+
expect(rawMessages).toHaveLength(1)
|
|
1850
|
+
expect(rawMessages[0].data).toHaveLength(2)
|
|
1851
|
+
})
|
|
1852
|
+
|
|
1853
|
+
it('[RB2] a custom message flushes buffered data messages first, preserving order', () => {
|
|
1854
|
+
vi.useFakeTimers()
|
|
1855
|
+
const { room, socketB } = setupTwoSessions()
|
|
1856
|
+
const newPage = makePage('page_3', 'v1')
|
|
1857
|
+
|
|
1858
|
+
room.handleMessage('a', {
|
|
1859
|
+
type: 'push',
|
|
1860
|
+
clientClock: 1,
|
|
1861
|
+
diff: { [newPage.id]: ['put', newPage] },
|
|
1862
|
+
} as TLPushRequest<TLRecord>)
|
|
1863
|
+
room.handleMessage('a', {
|
|
1864
|
+
type: 'push',
|
|
1865
|
+
clientClock: 2,
|
|
1866
|
+
diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
|
|
1867
|
+
} as TLPushRequest<TLRecord>)
|
|
1868
|
+
|
|
1869
|
+
// One immediate data message, one buffered
|
|
1870
|
+
expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
|
|
1871
|
+
|
|
1872
|
+
room.sendCustomMessage('b', 'hello')
|
|
1873
|
+
|
|
1874
|
+
const messages = sentMessages(socketB) as any[]
|
|
1875
|
+
expect(messages.map((m) => m.type)).toEqual(['data', 'data', 'custom'])
|
|
1876
|
+
expect(messages[1].data[0].diff).toEqual({
|
|
1877
|
+
[newPage.id]: ['patch', { name: ['put', 'v2'] }],
|
|
1878
|
+
})
|
|
1879
|
+
expect(messages[2]).toEqual({ type: 'custom', data: 'hello' })
|
|
1880
|
+
})
|
|
1881
|
+
|
|
1882
|
+
it('[RB2] pong does not flush buffered data messages', () => {
|
|
1883
|
+
vi.useFakeTimers()
|
|
1884
|
+
const { room, socketB } = setupTwoSessions()
|
|
1885
|
+
const newPage = makePage('page_3', 'v1')
|
|
1886
|
+
|
|
1887
|
+
room.handleMessage('a', {
|
|
1888
|
+
type: 'push',
|
|
1889
|
+
clientClock: 1,
|
|
1890
|
+
diff: { [newPage.id]: ['put', newPage] },
|
|
1891
|
+
} as TLPushRequest<TLRecord>)
|
|
1892
|
+
room.handleMessage('a', {
|
|
1893
|
+
type: 'push',
|
|
1894
|
+
clientClock: 2,
|
|
1895
|
+
diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
|
|
1896
|
+
} as TLPushRequest<TLRecord>)
|
|
1897
|
+
expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
|
|
1898
|
+
|
|
1899
|
+
// B pings while it has a buffered data message: the pong arrives first
|
|
1900
|
+
room.handleMessage('b', { type: 'ping' })
|
|
1901
|
+
|
|
1902
|
+
let messages = sentMessages(socketB) as any[]
|
|
1903
|
+
expect(messages.map((m) => m.type)).toEqual(['data', 'pong'])
|
|
1904
|
+
|
|
1905
|
+
// The buffered message is delivered by the regular flush afterwards
|
|
1906
|
+
vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
|
|
1907
|
+
messages = sentMessages(socketB) as any[]
|
|
1908
|
+
expect(messages.map((m) => m.type)).toEqual(['data', 'pong', 'data'])
|
|
1909
|
+
})
|
|
1910
|
+
|
|
1911
|
+
it('[RB3] sending to a session whose socket is closed cancels that session', () => {
|
|
1912
|
+
const { room, socketB } = setupTwoSessions()
|
|
1913
|
+
|
|
1914
|
+
socketB.isOpen = false
|
|
1915
|
+
|
|
1916
|
+
const newPage = makePage('page_3', 'hello')
|
|
1917
|
+
room.handleMessage('a', {
|
|
1918
|
+
type: 'push',
|
|
1919
|
+
clientClock: 1,
|
|
1920
|
+
diff: { [newPage.id]: ['put', newPage] },
|
|
1921
|
+
} as TLPushRequest<TLRecord>)
|
|
1922
|
+
|
|
1923
|
+
expect(room.sessions.get('b')?.state).toBe(RoomSessionState.AwaitingRemoval)
|
|
1924
|
+
expect(socketB.sendMessage).not.toHaveBeenCalled()
|
|
1925
|
+
})
|
|
1926
|
+
|
|
1927
|
+
it('[RB4] a per-session migration failure during broadcast rejects only the affected session', () => {
|
|
1928
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
1929
|
+
try {
|
|
1930
|
+
interface RBUser extends BaseRecord<'user', RecordId<RBUser>> {
|
|
1931
|
+
name: string
|
|
1932
|
+
}
|
|
1933
|
+
const RBUser = createRecordType<RBUser>('user', {
|
|
1934
|
+
scope: 'document',
|
|
1935
|
+
validator: { validate: (r) => r as RBUser },
|
|
1936
|
+
})
|
|
1937
|
+
const rbUserVersions = createMigrationIds('test.rb.user', { Touch: 1 } as const)
|
|
1938
|
+
const rbServerSchema = StoreSchema.create<RBUser>(
|
|
1939
|
+
{ user: RBUser },
|
|
1940
|
+
{
|
|
1941
|
+
migrations: [
|
|
1942
|
+
createRecordMigrationSequence({
|
|
1943
|
+
sequenceId: 'test.rb.user',
|
|
1944
|
+
recordType: 'user',
|
|
1945
|
+
sequence: [
|
|
1946
|
+
{
|
|
1947
|
+
id: rbUserVersions.Touch,
|
|
1948
|
+
up: (r: any) => r,
|
|
1949
|
+
down: (r: any) => {
|
|
1950
|
+
if (r.name === 'explode') throw new Error('cannot down-migrate')
|
|
1951
|
+
return r
|
|
1952
|
+
},
|
|
1953
|
+
},
|
|
1954
|
+
],
|
|
1955
|
+
}),
|
|
1956
|
+
],
|
|
1957
|
+
}
|
|
1958
|
+
)
|
|
1959
|
+
const rbOldSchema = StoreSchema.create<RBUser>(
|
|
1960
|
+
{ user: RBUser },
|
|
1961
|
+
{
|
|
1962
|
+
migrations: [
|
|
1963
|
+
createMigrationSequence({
|
|
1964
|
+
sequenceId: 'test.rb.user',
|
|
1965
|
+
sequence: [],
|
|
1966
|
+
retroactive: true,
|
|
1967
|
+
}),
|
|
1968
|
+
],
|
|
1969
|
+
}
|
|
1970
|
+
)
|
|
1971
|
+
|
|
1972
|
+
const storage = new InMemorySyncStorage<RBUser>({
|
|
1973
|
+
snapshot: { documents: [], clock: 0, documentClock: 0, schema: rbServerSchema.serialize() },
|
|
1974
|
+
})
|
|
1975
|
+
const room = new TLSyncRoom<RBUser, undefined>({ schema: rbServerSchema, storage })
|
|
1976
|
+
disposables.push(() => room.close())
|
|
1977
|
+
|
|
1978
|
+
const oldSocket = connectSession(room, 'old', { schema: rbOldSchema.serialize() })
|
|
1979
|
+
const peerSocket = connectSession(room, 'peer')
|
|
1980
|
+
const pusherSocket = connectSession(room, 'pusher')
|
|
1981
|
+
|
|
1982
|
+
const user = RBUser.create({ id: RBUser.createId('boom'), name: 'explode' })
|
|
1983
|
+
room.handleMessage('pusher', {
|
|
1984
|
+
type: 'push',
|
|
1985
|
+
clientClock: 1,
|
|
1986
|
+
diff: { [user.id]: ['put', user] },
|
|
1987
|
+
} as TLPushRequest<RBUser>)
|
|
1988
|
+
|
|
1989
|
+
// The old client could not down-migrate the record and was rejected
|
|
1990
|
+
expect(room.sessions.get('old')).toBeUndefined()
|
|
1991
|
+
expect(oldSocket.close).toHaveBeenCalledWith(
|
|
1992
|
+
TLSyncErrorCloseEventCode,
|
|
1993
|
+
TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
|
|
1994
|
+
)
|
|
1995
|
+
|
|
1996
|
+
// The broadcast proceeded for the same-version session
|
|
1997
|
+
expect(room.sessions.get('peer')?.state).toBe(RoomSessionState.Connected)
|
|
1998
|
+
expect(sentDataMessages(peerSocket)).toEqual([
|
|
1999
|
+
{ type: 'patch', diff: { [user.id]: ['put', user] }, serverClock: 1 },
|
|
2000
|
+
])
|
|
2001
|
+
|
|
2002
|
+
// And the pusher got its commit
|
|
2003
|
+
expect(sentDataMessages(pusherSocket)).toEqual([
|
|
2004
|
+
{ type: 'push_result', clientClock: 1, serverClock: 1, action: 'commit' },
|
|
2005
|
+
])
|
|
2006
|
+
} finally {
|
|
2007
|
+
consoleSpy.mockRestore()
|
|
2008
|
+
}
|
|
2009
|
+
})
|
|
2010
|
+
|
|
2011
|
+
it('[RB5] sends a custom message to a connected client', async () => {
|
|
2012
|
+
type Presence = UnknownRecord & { typeName: 'presence' }
|
|
2013
|
+
const presenceType = createRecordType<Presence>('presence', {
|
|
2014
|
+
scope: 'presence',
|
|
2015
|
+
validator: { validate: (record) => record as Presence },
|
|
2016
|
+
})
|
|
2017
|
+
const customSchema = StoreSchema.create<Presence>({ presence: presenceType })
|
|
2018
|
+
|
|
2019
|
+
const store = new Store<any, any>({ schema: customSchema, props: {} })
|
|
2020
|
+
|
|
2021
|
+
const sessionId = 'test-session-1'
|
|
2022
|
+
const server = new TestServer(customSchema)
|
|
2023
|
+
const socketPair = new TestSocketPair(sessionId, server)
|
|
2024
|
+
socketPair.connect()
|
|
2025
|
+
|
|
2026
|
+
const onCustomMessageReceived = vi.fn()
|
|
2027
|
+
const client = new TLSyncClient({
|
|
2028
|
+
store,
|
|
2029
|
+
socket: socketPair.clientSocket,
|
|
2030
|
+
onCustomMessageReceived,
|
|
2031
|
+
onLoad: vi.fn(),
|
|
2032
|
+
onSyncError: vi.fn(),
|
|
2033
|
+
presence: atom('', null),
|
|
2034
|
+
})
|
|
2035
|
+
disposables.push(() => client.close())
|
|
2036
|
+
await socketPair.flushAllEvents()
|
|
2037
|
+
server.room.sendCustomMessage(sessionId, 'hello world')
|
|
2038
|
+
await socketPair.flushAllEvents()
|
|
2039
|
+
expect(onCustomMessageReceived.mock.lastCall).toEqual(['hello world'])
|
|
2040
|
+
})
|
|
2041
|
+
|
|
2042
|
+
it('[RB5] sending a custom message to an unknown or not-yet-connected session warns and does nothing', () => {
|
|
2043
|
+
const warn = vi.fn()
|
|
2044
|
+
const { room } = makeRoom({ log: { warn } })
|
|
2045
|
+
|
|
2046
|
+
room.sendCustomMessage('nobody', 'hello')
|
|
2047
|
+
expect(warn).toHaveBeenCalledWith('Tried to send message to unknown session', 'custom')
|
|
2048
|
+
|
|
2049
|
+
warn.mockClear()
|
|
2050
|
+
const socket = makeSocket()
|
|
2051
|
+
room.handleNewSession({ sessionId: 'pending', socket, meta: undefined, isReadonly: false })
|
|
2052
|
+
room.sendCustomMessage('pending', 'hello')
|
|
2053
|
+
expect(warn).toHaveBeenCalledWith('Tried to send message to disconnected client', 'custom')
|
|
2054
|
+
expect(socket.sendMessage).not.toHaveBeenCalled()
|
|
2055
|
+
})
|
|
2056
|
+
})
|
|
2057
|
+
|
|
2058
|
+
describe('26. Session lifecycle (SES)', () => {
|
|
2059
|
+
it('[SES1][SES2][SES3] an idle connected session is cancelled, kept for a grace period, then removed', () => {
|
|
2060
|
+
vi.useFakeTimers()
|
|
2061
|
+
const { room } = makeRoom({ clientTimeout: SESSION_IDLE_TIMEOUT })
|
|
2062
|
+
const socket = connectSession(room, 'idle')
|
|
2063
|
+
const presenceId = room.sessions.get('idle')!.presenceId
|
|
2064
|
+
const removed = vi.fn()
|
|
2065
|
+
const becameEmpty = vi.fn()
|
|
2066
|
+
room.events.on('session_removed', removed)
|
|
2067
|
+
room.events.on('room_became_empty', becameEmpty)
|
|
2068
|
+
|
|
2069
|
+
// Shift the clock past the idle timeout without firing the interval so we
|
|
2070
|
+
// can observe the prune pass directly
|
|
2071
|
+
vi.setSystemTime(Date.now() + SESSION_IDLE_TIMEOUT + 1)
|
|
2072
|
+
room.pruneSessions()
|
|
2073
|
+
|
|
2074
|
+
const session = room.sessions.get('idle')!
|
|
2075
|
+
expect(session.state).toBe(RoomSessionState.AwaitingRemoval)
|
|
2076
|
+
// [SES2] presence id is kept for a quick reconnect, and the socket is closed
|
|
2077
|
+
expect(session.presenceId).toBe(presenceId)
|
|
2078
|
+
expect(socket.isOpen).toBe(false)
|
|
2079
|
+
expect(removed).not.toHaveBeenCalled()
|
|
2080
|
+
|
|
2081
|
+
// Not removed before the grace period has elapsed
|
|
2082
|
+
vi.setSystemTime(Date.now() + 1001)
|
|
2083
|
+
room.pruneSessions()
|
|
2084
|
+
expect(room.sessions.get('idle')?.state).toBe(RoomSessionState.AwaitingRemoval)
|
|
2085
|
+
|
|
2086
|
+
// [SES1] removed once it has been awaiting removal for SESSION_REMOVAL_WAIT_TIME
|
|
2087
|
+
vi.setSystemTime(Date.now() + SESSION_REMOVAL_WAIT_TIME + 1)
|
|
2088
|
+
room.pruneSessions()
|
|
2089
|
+
expect(room.sessions.size).toBe(0)
|
|
2090
|
+
// [SES3] events fire on removal
|
|
2091
|
+
expect(removed).toHaveBeenCalledWith({ sessionId: 'idle', meta: undefined })
|
|
2092
|
+
expect(becameEmpty).toHaveBeenCalledTimes(1)
|
|
2093
|
+
})
|
|
2094
|
+
|
|
2095
|
+
it('[SES1] a session that never sends a connect message is removed after SESSION_START_WAIT_TIME', () => {
|
|
2096
|
+
vi.useFakeTimers()
|
|
2097
|
+
const { room } = makeRoom()
|
|
2098
|
+
room.handleNewSession({
|
|
2099
|
+
sessionId: 'never-connects',
|
|
2100
|
+
socket: makeSocket(),
|
|
2101
|
+
meta: undefined,
|
|
2102
|
+
isReadonly: false,
|
|
2103
|
+
})
|
|
2104
|
+
|
|
2105
|
+
vi.setSystemTime(Date.now() + SESSION_START_WAIT_TIME + 1)
|
|
2106
|
+
room.pruneSessions()
|
|
2107
|
+
|
|
2108
|
+
expect(room.sessions.size).toBe(0)
|
|
2109
|
+
})
|
|
2110
|
+
|
|
2111
|
+
it('[SES1] a connected session whose socket has closed is cancelled on prune', () => {
|
|
2112
|
+
const { room } = makeRoom()
|
|
2113
|
+
const socket = connectSession(room, 's1')
|
|
2114
|
+
|
|
2115
|
+
socket.isOpen = false
|
|
2116
|
+
room.pruneSessions()
|
|
2117
|
+
|
|
2118
|
+
expect(room.sessions.get('s1')?.state).toBe(RoomSessionState.AwaitingRemoval)
|
|
2119
|
+
})
|
|
2120
|
+
|
|
2121
|
+
it('[SES2] handleClose moves the session to AwaitingRemoval keeping its presence id and meta', () => {
|
|
2122
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(records) })
|
|
2123
|
+
const room = new TLSyncRoom<TLRecord, { userId: string }>({ schema, storage })
|
|
2124
|
+
disposables.push(() => room.close())
|
|
2125
|
+
|
|
2126
|
+
const socket = makeSocket()
|
|
2127
|
+
room.handleNewSession({
|
|
2128
|
+
sessionId: 's1',
|
|
2129
|
+
socket,
|
|
2130
|
+
meta: { userId: 'user-1' },
|
|
2131
|
+
isReadonly: false,
|
|
2132
|
+
})
|
|
2133
|
+
room.handleMessage('s1', {
|
|
2134
|
+
type: 'connect',
|
|
2135
|
+
connectRequestId: 'connect-s1',
|
|
1399
2136
|
lastServerClock: 0,
|
|
1400
2137
|
protocolVersion: getTlsyncProtocolVersion(),
|
|
1401
2138
|
schema: room.serializedSchema,
|
|
1402
|
-
|
|
2139
|
+
} satisfies TLConnectRequest)
|
|
2140
|
+
const presenceId = room.sessions.get('s1')!.presenceId
|
|
2141
|
+
|
|
2142
|
+
room.handleClose('s1')
|
|
2143
|
+
|
|
2144
|
+
const session = room.sessions.get('s1')!
|
|
2145
|
+
expect(session.state).toBe(RoomSessionState.AwaitingRemoval)
|
|
2146
|
+
expect(session.presenceId).toBe(presenceId)
|
|
2147
|
+
expect(session.meta).toEqual({ userId: 'user-1' })
|
|
2148
|
+
expect(socket.close).toHaveBeenCalled()
|
|
2149
|
+
})
|
|
2150
|
+
|
|
2151
|
+
it('[SES3] removing a session deletes its presence record and broadcasts the deletion to everyone', () => {
|
|
2152
|
+
const { room } = makeRoom()
|
|
2153
|
+
connectSession(room, 'a')
|
|
2154
|
+
const socketB = connectSession(room, 'b')
|
|
2155
|
+
const presenceIdA = room.sessions.get('a')!.presenceId!
|
|
2156
|
+
|
|
2157
|
+
room.handleMessage('a', {
|
|
2158
|
+
type: 'push',
|
|
2159
|
+
clientClock: 1,
|
|
2160
|
+
presence: presencePut('Leaving Soon'),
|
|
2161
|
+
} as TLPushRequest<TLRecord>)
|
|
2162
|
+
expect(room.presenceStore.get(presenceIdA)).toBeDefined()
|
|
2163
|
+
|
|
2164
|
+
// Flush B's debounced data messages and start from a clean slate
|
|
2165
|
+
room._flushDataMessages('b')
|
|
2166
|
+
clearSocket(socketB)
|
|
2167
|
+
|
|
2168
|
+
room.rejectSession('a')
|
|
2169
|
+
|
|
2170
|
+
expect(room.presenceStore.get(presenceIdA)).toBeUndefined()
|
|
2171
|
+
expect(sentDataMessages(socketB)).toEqual([
|
|
2172
|
+
{ type: 'patch', diff: { [presenceIdA]: ['remove'] }, serverClock: 0 },
|
|
2173
|
+
])
|
|
2174
|
+
})
|
|
2175
|
+
|
|
2176
|
+
it('[SES4] legacy protocol clients are rejected with a mapped incompatibility_error and no close code', () => {
|
|
2177
|
+
const cases = [
|
|
2178
|
+
[TLSyncErrorCloseEventReason.CLIENT_TOO_OLD, 'clientTooOld'],
|
|
2179
|
+
[TLSyncErrorCloseEventReason.SERVER_TOO_OLD, 'serverTooOld'],
|
|
2180
|
+
[TLSyncErrorCloseEventReason.INVALID_RECORD, 'invalidRecord'],
|
|
2181
|
+
[TLSyncErrorCloseEventReason.FORBIDDEN, 'invalidOperation'],
|
|
2182
|
+
] as const
|
|
2183
|
+
|
|
2184
|
+
for (const [reason, legacyReason] of cases) {
|
|
2185
|
+
const { room } = makeRoom()
|
|
2186
|
+
// Protocol version 6 clients require legacy rejection
|
|
2187
|
+
const socket = connectSession(room, 'legacy', { protocolVersion: 6 })
|
|
2188
|
+
|
|
2189
|
+
room.rejectSession('legacy', reason)
|
|
2190
|
+
|
|
2191
|
+
expect(sentMessages(socket)).toEqual([
|
|
2192
|
+
{ type: 'incompatibility_error', reason: legacyReason },
|
|
2193
|
+
])
|
|
2194
|
+
expect(socket.close).toHaveBeenCalledWith()
|
|
2195
|
+
expect(room.sessions.size).toBe(0)
|
|
2196
|
+
}
|
|
2197
|
+
})
|
|
2198
|
+
|
|
2199
|
+
it('[SES4] modern clients are rejected by closing the socket with code 4099 and the reason', () => {
|
|
2200
|
+
const { room } = makeRoom()
|
|
2201
|
+
const socket = connectSession(room, 'modern')
|
|
2202
|
+
|
|
2203
|
+
room.rejectSession('modern', TLSyncErrorCloseEventReason.FORBIDDEN)
|
|
2204
|
+
|
|
2205
|
+
expect(sentMessages(socket)).toEqual([])
|
|
2206
|
+
expect(socket.close).toHaveBeenCalledWith(
|
|
2207
|
+
TLSyncErrorCloseEventCode,
|
|
2208
|
+
TLSyncErrorCloseEventReason.FORBIDDEN
|
|
2209
|
+
)
|
|
2210
|
+
expect(room.sessions.size).toBe(0)
|
|
2211
|
+
})
|
|
2212
|
+
|
|
2213
|
+
it('[HS2][SES5] sets supportsStringAppend to false for protocol version 7', () => {
|
|
2214
|
+
const { room } = makeRoom()
|
|
2215
|
+
connectSession(room, 'v7-session', { protocolVersion: 7 })
|
|
2216
|
+
|
|
2217
|
+
const session = room.sessions.get('v7-session')
|
|
2218
|
+
expect(session?.state).toBe(RoomSessionState.Connected)
|
|
2219
|
+
if (session?.state === RoomSessionState.Connected) {
|
|
2220
|
+
expect(session.supportsStringAppend).toBe(false)
|
|
2221
|
+
}
|
|
2222
|
+
})
|
|
2223
|
+
|
|
2224
|
+
it('[HS2][SES5] sets supportsStringAppend to true for protocol version 8', () => {
|
|
2225
|
+
const { room } = makeRoom()
|
|
2226
|
+
connectSession(room, 'v8-session', { protocolVersion: getTlsyncProtocolVersion() })
|
|
2227
|
+
|
|
2228
|
+
const session = room.sessions.get('v8-session')
|
|
2229
|
+
expect(session?.state).toBe(RoomSessionState.Connected)
|
|
2230
|
+
if (session?.state === RoomSessionState.Connected) {
|
|
2231
|
+
expect(session.supportsStringAppend).toBe(true)
|
|
2232
|
+
}
|
|
2233
|
+
})
|
|
2234
|
+
|
|
2235
|
+
it('[SES5] getCanEmitStringAppend returns false when any connected client lacks string append support', () => {
|
|
2236
|
+
const { room } = makeRoom()
|
|
2237
|
+
|
|
2238
|
+
connectSession(room, 'v8-client', { protocolVersion: 8 })
|
|
2239
|
+
|
|
2240
|
+
// With only a v8 client, should be true
|
|
2241
|
+
expect(room.getCanEmitStringAppend()).toBe(true)
|
|
2242
|
+
|
|
2243
|
+
connectSession(room, 'v7-client', { protocolVersion: 7 })
|
|
2244
|
+
|
|
2245
|
+
// With mixed clients, should be false
|
|
2246
|
+
expect(room.getCanEmitStringAppend()).toBe(false)
|
|
2247
|
+
})
|
|
2248
|
+
|
|
2249
|
+
it('[SES6] handleResumedSession registers a session directly in Connected state and restores its presence', () => {
|
|
2250
|
+
const { room } = makeRoom()
|
|
2251
|
+
const socket = makeSocket()
|
|
2252
|
+
const presenceId = InstancePresenceRecordType.createId('resumed')
|
|
2253
|
+
const presenceRecord = InstancePresenceRecordType.create({
|
|
2254
|
+
id: presenceId,
|
|
2255
|
+
currentPageId: pageRecord.id,
|
|
2256
|
+
userId: createUserId('resumed'),
|
|
2257
|
+
userName: 'Resumed',
|
|
1403
2258
|
})
|
|
1404
2259
|
|
|
1405
|
-
|
|
1406
|
-
|
|
2260
|
+
room.handleResumedSession({
|
|
2261
|
+
sessionId: 'resumed',
|
|
2262
|
+
socket,
|
|
2263
|
+
meta: undefined,
|
|
2264
|
+
isReadonly: false,
|
|
2265
|
+
serializedSchema: room.serializedSchema,
|
|
2266
|
+
presenceId,
|
|
2267
|
+
presenceRecord,
|
|
2268
|
+
requiresLegacyRejection: false,
|
|
2269
|
+
supportsStringAppend: true,
|
|
2270
|
+
})
|
|
2271
|
+
|
|
2272
|
+
const session = room.sessions.get('resumed')! as any
|
|
2273
|
+
expect(session.state).toBe(RoomSessionState.Connected)
|
|
2274
|
+
// requiresDownMigrations is recomputed from the supplied schema
|
|
2275
|
+
expect(session.requiresDownMigrations).toBe(false)
|
|
2276
|
+
// the presence record is restored into the presence store
|
|
2277
|
+
expect(room.presenceStore.get(presenceId)).toEqual(presenceRecord)
|
|
2278
|
+
|
|
2279
|
+
// the resumed session handles messages like any connected session
|
|
2280
|
+
room.handleMessage('resumed', { type: 'ping' })
|
|
2281
|
+
expect(socket.__lastMessage).toEqual({ type: 'pong' })
|
|
2282
|
+
})
|
|
2283
|
+
|
|
2284
|
+
it('[SES7] a message from an unknown session id logs a warning and is ignored', () => {
|
|
2285
|
+
const warn = vi.fn()
|
|
2286
|
+
const { room } = makeRoom({ log: { warn } })
|
|
2287
|
+
|
|
2288
|
+
room.handleMessage('unknown-session', { type: 'ping' })
|
|
2289
|
+
|
|
2290
|
+
expect(warn).toHaveBeenCalledWith('Received message from unknown session')
|
|
1407
2291
|
})
|
|
1408
2292
|
})
|