@tldraw/sync-core 5.2.0-canary.fe03bcdddf34 → 5.2.0-canary.fff413eea248
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 +3 -2
- 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 +3 -2
- 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 +5 -2
- 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 +1735 -893
- package/src/test/storageContractSuite.ts +618 -0
- package/src/test/upgradeDowngrade.test.ts +137 -37
- 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,19 +1,22 @@
|
|
|
1
|
-
import { Atom, atom } from '@tldraw/state'
|
|
2
|
-
import { Store } from '@tldraw/store'
|
|
1
|
+
import { Atom, Signal, atom, computed } from '@tldraw/state'
|
|
2
|
+
import { BaseRecord, RecordId, Store, StoreSchema, createRecordType } from '@tldraw/store'
|
|
3
3
|
import {
|
|
4
|
+
CameraRecordType,
|
|
4
5
|
DocumentRecordType,
|
|
6
|
+
InstancePresenceRecordType,
|
|
5
7
|
PageRecordType,
|
|
6
8
|
TLDOCUMENT_ID,
|
|
7
9
|
TLRecord,
|
|
8
10
|
createTLSchema,
|
|
9
11
|
} from '@tldraw/tlschema'
|
|
10
|
-
/// <reference types="vitest" />
|
|
11
12
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
12
|
-
import {
|
|
13
|
+
import { TestServer } from '../test/TestServer'
|
|
14
|
+
import { TestSocketPair } from '../test/TestSocketPair'
|
|
15
|
+
import { NetworkDiff, RecordOpType, ValueOpType } from './diff'
|
|
13
16
|
import {
|
|
17
|
+
TLConnectRequest,
|
|
14
18
|
TLPushRequest,
|
|
15
19
|
TLSocketClientSentEvent,
|
|
16
|
-
TLSocketServerSentDataEvent,
|
|
17
20
|
TLSocketServerSentEvent,
|
|
18
21
|
getTlsyncProtocolVersion,
|
|
19
22
|
} from './protocol'
|
|
@@ -24,7 +27,9 @@ import {
|
|
|
24
27
|
TLSyncClient,
|
|
25
28
|
} from './TLSyncClient'
|
|
26
29
|
|
|
27
|
-
//
|
|
30
|
+
// These tests express the rules in SPEC.md sections 19 (CL), 20 (CP), and 21 (CR).
|
|
31
|
+
// Each test name cites the rule(s) it expresses.
|
|
32
|
+
|
|
28
33
|
const schema = createTLSchema()
|
|
29
34
|
const protocolVersion = getTlsyncProtocolVersion()
|
|
30
35
|
type TestRecord = TLRecord
|
|
@@ -108,6 +113,23 @@ class MockSocket implements TLPersistentClientSocket<
|
|
|
108
113
|
}
|
|
109
114
|
}
|
|
110
115
|
|
|
116
|
+
function makePage(name = 'Test Page', index = 'a1') {
|
|
117
|
+
return PageRecordType.create({
|
|
118
|
+
id: PageRecordType.createId(),
|
|
119
|
+
name,
|
|
120
|
+
index: index as any,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function makePeerPresence() {
|
|
125
|
+
return InstancePresenceRecordType.create({
|
|
126
|
+
id: InstancePresenceRecordType.createId('peer'),
|
|
127
|
+
userId: 'user:peer' as any,
|
|
128
|
+
userName: 'Peer',
|
|
129
|
+
currentPageId: 'page:main' as any,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
111
133
|
describe('TLSyncClient', () => {
|
|
112
134
|
let store: Store<TestRecord, any>
|
|
113
135
|
let socket: MockSocket
|
|
@@ -139,12 +161,18 @@ describe('TLSyncClient', () => {
|
|
|
139
161
|
},
|
|
140
162
|
})
|
|
141
163
|
|
|
142
|
-
// Add basic document
|
|
164
|
+
// Add basic document and page records so the tldraw schema's integrity checker
|
|
165
|
+
// has nothing to create on its own
|
|
143
166
|
store.put([
|
|
144
167
|
DocumentRecordType.create({
|
|
145
168
|
id: TLDOCUMENT_ID,
|
|
146
169
|
gridSize: 10,
|
|
147
170
|
}),
|
|
171
|
+
PageRecordType.create({
|
|
172
|
+
id: PageRecordType.createId('page'),
|
|
173
|
+
name: 'Page 1',
|
|
174
|
+
index: 'a1' as any,
|
|
175
|
+
}),
|
|
148
176
|
])
|
|
149
177
|
|
|
150
178
|
socket = new MockSocket() as MockSocket & TLPersistentClientSocket<TestRecord>
|
|
@@ -162,10 +190,25 @@ describe('TLSyncClient', () => {
|
|
|
162
190
|
|
|
163
191
|
afterEach(() => {
|
|
164
192
|
client?.close()
|
|
193
|
+
client = undefined as any
|
|
165
194
|
vi.useRealTimers()
|
|
166
195
|
vi.clearAllMocks()
|
|
167
196
|
})
|
|
168
197
|
|
|
198
|
+
/**
|
|
199
|
+
* A server diff that mirrors the store's current document records. Using this as the default
|
|
200
|
+
* connect diff means a wipe_all hydration round-trips to no change, keeping the tldraw schema's
|
|
201
|
+
* integrity checker quiet (it would otherwise recreate the document/page records as new local
|
|
202
|
+
* changes).
|
|
203
|
+
*/
|
|
204
|
+
function documentScopeDiff(): NetworkDiff<TestRecord> {
|
|
205
|
+
const diff: NetworkDiff<TestRecord> = {}
|
|
206
|
+
for (const [id, record] of Object.entries(store.serialize('document'))) {
|
|
207
|
+
diff[id] = [RecordOpType.Put, record as TestRecord]
|
|
208
|
+
}
|
|
209
|
+
return diff
|
|
210
|
+
}
|
|
211
|
+
|
|
169
212
|
function createConnectMessage(
|
|
170
213
|
overrides: Partial<Extract<TLSocketServerSentEvent<TestRecord>, { type: 'connect' }>> = {}
|
|
171
214
|
): Extract<TLSocketServerSentEvent<TestRecord>, { type: 'connect' }> {
|
|
@@ -177,7 +220,7 @@ describe('TLSyncClient', () => {
|
|
|
177
220
|
schema: schema.serialize(),
|
|
178
221
|
isReadonly: false,
|
|
179
222
|
serverClock: 1,
|
|
180
|
-
diff:
|
|
223
|
+
diff: documentScopeDiff(),
|
|
181
224
|
...overrides,
|
|
182
225
|
}
|
|
183
226
|
}
|
|
@@ -211,28 +254,27 @@ describe('TLSyncClient', () => {
|
|
|
211
254
|
})
|
|
212
255
|
}
|
|
213
256
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
})
|
|
257
|
+
/** Create a client, complete the connect handshake, and clear the sent messages. */
|
|
258
|
+
function connectClient(
|
|
259
|
+
overrides: Partial<Extract<TLSocketServerSentEvent<TestRecord>, { type: 'connect' }>> = {}
|
|
260
|
+
) {
|
|
261
|
+
client = createClient()
|
|
262
|
+
socket.mockServerMessage(createConnectMessage(overrides))
|
|
263
|
+
socket.clearSentMessages()
|
|
264
|
+
}
|
|
223
265
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
})
|
|
266
|
+
function getSentPushes() {
|
|
267
|
+
return socket.getSentMessages().filter((m): m is TLPushRequest<TestRecord> => m.type === 'push')
|
|
268
|
+
}
|
|
228
269
|
|
|
229
|
-
|
|
270
|
+
describe('19. connection lifecycle (CL)', () => {
|
|
271
|
+
it('[CL1] sends a connect message immediately when the socket is already online at construction', () => {
|
|
230
272
|
client = createClient()
|
|
231
273
|
expect(socket.getSentMessages()).toHaveLength(1)
|
|
232
274
|
expect(socket.getLastSentMessage().type).toBe('connect')
|
|
233
275
|
})
|
|
234
276
|
|
|
235
|
-
it('
|
|
277
|
+
it('[CL1] sends the connect message when the socket first reports online', () => {
|
|
236
278
|
socket.connectionStatus = 'offline'
|
|
237
279
|
client = createClient()
|
|
238
280
|
expect(socket.getSentMessages()).toHaveLength(0)
|
|
@@ -242,981 +284,1043 @@ describe('TLSyncClient', () => {
|
|
|
242
284
|
expect(socket.getLastSentMessage().type).toBe('connect')
|
|
243
285
|
})
|
|
244
286
|
|
|
245
|
-
it('
|
|
287
|
+
it('[CL2] connect message carries a fresh request id, the serialized schema, protocol version 8, and lastServerClock -1', () => {
|
|
246
288
|
client = createClient()
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
})
|
|
255
|
-
expect(client).toBeInstanceOf(TLSyncClient)
|
|
256
|
-
})
|
|
257
|
-
|
|
258
|
-
it('handles optional presence mode', () => {
|
|
259
|
-
client = createClient({
|
|
260
|
-
presenceMode: undefined,
|
|
261
|
-
})
|
|
262
|
-
expect(client.presenceMode).toBeUndefined()
|
|
289
|
+
const msg = socket.getLastSentMessage() as TLConnectRequest
|
|
290
|
+
expect(msg.type).toBe('connect')
|
|
291
|
+
expect(msg.connectRequestId).toBe(client.latestConnectRequestId)
|
|
292
|
+
expect(msg.schema).toEqual(schema.serialize())
|
|
293
|
+
expect(msg.protocolVersion).toBe(8)
|
|
294
|
+
expect(msg.protocolVersion).toBe(getTlsyncProtocolVersion())
|
|
295
|
+
expect(msg.lastServerClock).toBe(-1)
|
|
263
296
|
})
|
|
264
|
-
})
|
|
265
297
|
|
|
266
|
-
|
|
267
|
-
beforeEach(() => {
|
|
298
|
+
it('[CL2] generates a new unique connectRequestId for each connection attempt', () => {
|
|
268
299
|
client = createClient()
|
|
269
|
-
socket.
|
|
270
|
-
})
|
|
271
|
-
|
|
272
|
-
it('handles successful connection', () => {
|
|
273
|
-
const connectMessage = createConnectMessage()
|
|
300
|
+
const first = (socket.getLastSentMessage() as TLConnectRequest).connectRequestId
|
|
274
301
|
|
|
275
|
-
socket.
|
|
302
|
+
socket.mockConnectionStatus('offline')
|
|
303
|
+
socket.mockConnectionStatus('online')
|
|
276
304
|
|
|
277
|
-
|
|
278
|
-
expect(
|
|
279
|
-
expect(
|
|
305
|
+
const second = (socket.getLastSentMessage() as TLConnectRequest).connectRequestId
|
|
306
|
+
expect(second).toBeDefined()
|
|
307
|
+
expect(second).not.toBe(first)
|
|
308
|
+
expect(second).toBe(client.latestConnectRequestId)
|
|
280
309
|
})
|
|
281
310
|
|
|
282
|
-
it('
|
|
283
|
-
|
|
311
|
+
it('[CL3] fires onLoad on the first message received from the server, of any type', () => {
|
|
312
|
+
client = createClient()
|
|
313
|
+
expect(onLoad).not.toHaveBeenCalled()
|
|
284
314
|
|
|
285
|
-
|
|
315
|
+
// even a pong counts as the first server message
|
|
316
|
+
socket.mockServerMessage({ type: 'pong' })
|
|
317
|
+
expect(onLoad).toHaveBeenCalledTimes(1)
|
|
318
|
+
expect(onLoad).toHaveBeenCalledWith(client)
|
|
286
319
|
|
|
287
|
-
|
|
320
|
+
// it only fires once
|
|
321
|
+
socket.mockServerMessage(createConnectMessage())
|
|
322
|
+
expect(onLoad).toHaveBeenCalledTimes(1)
|
|
288
323
|
})
|
|
289
324
|
|
|
290
|
-
it('
|
|
291
|
-
|
|
292
|
-
socket.mockServerMessage(createConnectMessage())
|
|
293
|
-
expect(client.isConnectedToRoom).toBe(true)
|
|
325
|
+
it('[CL4] ignores a connect response with a stale connectRequestId', () => {
|
|
326
|
+
client = createClient()
|
|
294
327
|
|
|
295
|
-
|
|
296
|
-
socket.mockConnectionStatus('offline')
|
|
328
|
+
socket.mockServerMessage(createConnectMessage({ connectRequestId: 'stale-request-id' }))
|
|
297
329
|
expect(client.isConnectedToRoom).toBe(false)
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
it('handles socket errors', () => {
|
|
301
|
-
socket.mockConnectionStatus('error', 'Connection failed')
|
|
302
|
-
expect(onSyncError).toHaveBeenCalledWith('Connection failed')
|
|
303
|
-
})
|
|
330
|
+
expect(onAfterConnect).not.toHaveBeenCalled()
|
|
304
331
|
|
|
305
|
-
|
|
306
|
-
// Connect first
|
|
332
|
+
// the matching response still works afterwards
|
|
307
333
|
socket.mockServerMessage(createConnectMessage())
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
// Advance time to trigger ping
|
|
312
|
-
vi.advanceTimersByTime(5000)
|
|
313
|
-
expect(socket.getSentMessages()).toHaveLength(1)
|
|
314
|
-
expect(socket.getLastSentMessage().type).toBe('ping')
|
|
334
|
+
expect(client.isConnectedToRoom).toBe(true)
|
|
335
|
+
expect(onAfterConnect).toHaveBeenCalledTimes(1)
|
|
315
336
|
})
|
|
316
337
|
|
|
317
|
-
it('
|
|
318
|
-
|
|
338
|
+
it('[CL5][CP3] wipe_presence reconnect keeps confirmed documents and re-pushes speculative changes', () => {
|
|
339
|
+
connectClient()
|
|
319
340
|
|
|
320
|
-
//
|
|
321
|
-
|
|
341
|
+
// a server-confirmed document record
|
|
342
|
+
const serverPage = makePage('Server Page', 'a2')
|
|
343
|
+
socket.mockServerMessage({
|
|
344
|
+
type: 'data',
|
|
345
|
+
data: [
|
|
346
|
+
{
|
|
347
|
+
type: 'patch',
|
|
348
|
+
serverClock: 2,
|
|
349
|
+
diff: { [serverPage.id]: [RecordOpType.Put, serverPage] },
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
})
|
|
353
|
+
vi.advanceTimersByTime(100)
|
|
322
354
|
|
|
323
|
-
//
|
|
324
|
-
|
|
355
|
+
// speculative change made while offline
|
|
356
|
+
socket.mockConnectionStatus('offline')
|
|
357
|
+
const localPage = makePage('Offline Page')
|
|
358
|
+
store.put([localPage])
|
|
359
|
+
vi.advanceTimersByTime(100)
|
|
325
360
|
|
|
326
|
-
|
|
327
|
-
|
|
361
|
+
socket.mockConnectionStatus('online')
|
|
362
|
+
socket.clearSentMessages()
|
|
363
|
+
socket.mockServerMessage(
|
|
364
|
+
createConnectMessage({ hydrationType: 'wipe_presence', serverClock: 2, diff: {} })
|
|
328
365
|
)
|
|
329
|
-
|
|
366
|
+
vi.advanceTimersByTime(100)
|
|
330
367
|
|
|
331
|
-
|
|
368
|
+
// confirmed server data is kept (not wiped, unlike wipe_all)
|
|
369
|
+
expect(store.get(serverPage.id)).toEqual(serverPage)
|
|
370
|
+
// the speculative change is re-applied on top
|
|
371
|
+
expect(store.get(localPage.id)?.name).toBe('Offline Page')
|
|
372
|
+
// and pushed as a new push request
|
|
373
|
+
const pushWithPage = getSentPushes().find((msg) => msg.diff?.[localPage.id])
|
|
374
|
+
expect(pushWithPage).toBeDefined()
|
|
375
|
+
expect(pushWithPage!.diff![localPage.id][0]).toBe(RecordOpType.Put)
|
|
332
376
|
})
|
|
333
|
-
})
|
|
334
377
|
|
|
335
|
-
|
|
336
|
-
beforeEach(() => {
|
|
378
|
+
it('[CL6] wipe_all reconnect wipes document records before applying the server diff', () => {
|
|
337
379
|
client = createClient()
|
|
338
|
-
// Connect first
|
|
339
|
-
socket.mockServerMessage(createConnectMessage())
|
|
340
|
-
socket.clearSentMessages()
|
|
341
|
-
})
|
|
342
|
-
|
|
343
|
-
it('handles pong messages', () => {
|
|
344
|
-
const pongMessage: Extract<TLSocketServerSentEvent<TestRecord>, { type: 'pong' }> = {
|
|
345
|
-
type: 'pong',
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
socket.mockServerMessage(pongMessage)
|
|
349
|
-
// Pong messages are just used to update lastServerInteractionTimestamp
|
|
350
|
-
// No specific assertion needed beyond not throwing
|
|
351
|
-
})
|
|
352
380
|
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
381
|
+
const serverDoc = DocumentRecordType.create({ id: TLDOCUMENT_ID, gridSize: 20 })
|
|
382
|
+
const serverPage = makePage('Server Page')
|
|
383
|
+
socket.mockServerMessage(
|
|
384
|
+
createConnectMessage({
|
|
385
|
+
diff: {
|
|
386
|
+
[TLDOCUMENT_ID]: [RecordOpType.Put, serverDoc],
|
|
387
|
+
[serverPage.id]: [RecordOpType.Put, serverPage],
|
|
388
|
+
},
|
|
389
|
+
})
|
|
390
|
+
)
|
|
391
|
+
// the wipe_all hydration replaced the local document state with the server's
|
|
392
|
+
expect((store.get(TLDOCUMENT_ID) as any)?.gridSize).toBe(20)
|
|
364
393
|
|
|
365
|
-
|
|
366
|
-
const
|
|
394
|
+
// another server-confirmed page arrives
|
|
395
|
+
const extraPage = makePage('Extra Page', 'a2')
|
|
396
|
+
socket.mockServerMessage({
|
|
367
397
|
type: 'data',
|
|
368
398
|
data: [
|
|
369
399
|
{
|
|
370
400
|
type: 'patch',
|
|
371
401
|
serverClock: 2,
|
|
372
|
-
diff: {},
|
|
402
|
+
diff: { [extraPage.id]: [RecordOpType.Put, extraPage] },
|
|
373
403
|
},
|
|
374
404
|
],
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
socket.mockServerMessage(dataMessage)
|
|
378
|
-
// Rebase is throttled, so advance timers
|
|
379
|
-
vi.advanceTimersByTime(100)
|
|
380
|
-
})
|
|
381
|
-
|
|
382
|
-
it('handles legacy patch messages', () => {
|
|
383
|
-
const patchMessage: Extract<TLSocketServerSentEvent<TestRecord>, { type: 'patch' }> = {
|
|
384
|
-
type: 'patch',
|
|
385
|
-
serverClock: 2,
|
|
386
|
-
diff: {},
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
socket.mockServerMessage(patchMessage)
|
|
405
|
+
})
|
|
390
406
|
vi.advanceTimersByTime(100)
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
it('ignores messages when not connected to room', () => {
|
|
394
|
-
// Reset connection
|
|
395
|
-
client.isConnectedToRoom = false
|
|
407
|
+
expect(store.has(extraPage.id)).toBe(true)
|
|
396
408
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
socket.mockServerMessage(dataMessage)
|
|
403
|
-
// Should not process the message or throw
|
|
404
|
-
})
|
|
405
|
-
|
|
406
|
-
it('handles incompatibility_error messages', () => {
|
|
407
|
-
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
408
|
-
|
|
409
|
-
const errorMessage: Extract<
|
|
410
|
-
TLSocketServerSentEvent<TestRecord>,
|
|
411
|
-
{ type: 'incompatibility_error' }
|
|
412
|
-
> = {
|
|
413
|
-
type: 'incompatibility_error',
|
|
414
|
-
reason: 'clientTooOld',
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
socket.mockServerMessage(errorMessage)
|
|
409
|
+
socket.mockConnectionStatus('offline')
|
|
410
|
+
socket.mockConnectionStatus('online')
|
|
411
|
+
socket.clearSentMessages()
|
|
418
412
|
|
|
419
|
-
|
|
420
|
-
|
|
413
|
+
// the server's wipe_all diff no longer contains the extra page
|
|
414
|
+
socket.mockServerMessage(
|
|
415
|
+
createConnectMessage({
|
|
416
|
+
hydrationType: 'wipe_all',
|
|
417
|
+
serverClock: 3,
|
|
418
|
+
diff: {
|
|
419
|
+
[TLDOCUMENT_ID]: [RecordOpType.Put, serverDoc],
|
|
420
|
+
[serverPage.id]: [RecordOpType.Put, serverPage],
|
|
421
|
+
},
|
|
422
|
+
})
|
|
421
423
|
)
|
|
424
|
+
vi.advanceTimersByTime(100)
|
|
422
425
|
|
|
423
|
-
|
|
426
|
+
expect(store.has(extraPage.id)).toBe(false)
|
|
427
|
+
expect(store.get(TLDOCUMENT_ID)).toEqual(serverDoc)
|
|
428
|
+
expect(store.get(serverPage.id)).toEqual(serverPage)
|
|
424
429
|
})
|
|
425
|
-
})
|
|
426
430
|
|
|
427
|
-
|
|
428
|
-
beforeEach(() => {
|
|
431
|
+
it('[CL6][CP3] re-applies offline changes after a wipe_all reconnect and pushes them to the server', () => {
|
|
429
432
|
client = createClient()
|
|
430
|
-
// Connect first
|
|
431
433
|
socket.mockServerMessage(createConnectMessage())
|
|
432
434
|
socket.clearSentMessages()
|
|
433
|
-
})
|
|
434
435
|
|
|
435
|
-
|
|
436
|
-
const pageId = PageRecordType.createId()
|
|
437
|
-
store.put([
|
|
438
|
-
PageRecordType.create({
|
|
439
|
-
id: pageId,
|
|
440
|
-
name: 'Test Page',
|
|
441
|
-
index: 'a1' as any,
|
|
442
|
-
}),
|
|
443
|
-
])
|
|
444
|
-
|
|
445
|
-
vi.advanceTimersByTime(100)
|
|
446
|
-
|
|
447
|
-
expect(socket.getSentMessages()).toHaveLength(1)
|
|
448
|
-
const message = socket.getLastSentMessage() as TLPushRequest<TestRecord>
|
|
449
|
-
expect(message.type).toBe('push')
|
|
450
|
-
expect(message.diff).toBeDefined()
|
|
451
|
-
})
|
|
452
|
-
|
|
453
|
-
it('does not send push requests when offline', () => {
|
|
436
|
+
// make a change while offline - this is a truly speculative change
|
|
454
437
|
socket.mockConnectionStatus('offline')
|
|
438
|
+
socket.clearSentMessages()
|
|
455
439
|
|
|
456
440
|
const pageId = PageRecordType.createId()
|
|
457
|
-
store.put([
|
|
458
|
-
PageRecordType.create({
|
|
459
|
-
id: pageId,
|
|
460
|
-
name: 'Test Page',
|
|
461
|
-
index: 'a1' as any,
|
|
462
|
-
}),
|
|
463
|
-
])
|
|
464
|
-
|
|
441
|
+
store.put([PageRecordType.create({ id: pageId, name: 'Offline Page', index: 'a1' as any })])
|
|
465
442
|
vi.advanceTimersByTime(100)
|
|
466
443
|
|
|
444
|
+
// page exists locally, no messages sent (offline)
|
|
445
|
+
expect(store.has(pageId)).toBe(true)
|
|
467
446
|
expect(socket.getSentMessages()).toHaveLength(0)
|
|
468
|
-
})
|
|
469
447
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
PageRecordType.create({
|
|
476
|
-
id: pageId,
|
|
477
|
-
name: `Test Page ${i}`,
|
|
478
|
-
index: `a${i}` as any,
|
|
479
|
-
}),
|
|
480
|
-
])
|
|
481
|
-
}
|
|
448
|
+
// come back online
|
|
449
|
+
socket.mockConnectionStatus('online')
|
|
450
|
+
const connectMsg = socket.getSentMessages().find((m) => m.type === 'connect')
|
|
451
|
+
expect(connectMsg).toBeDefined()
|
|
452
|
+
socket.clearSentMessages()
|
|
482
453
|
|
|
483
|
-
//
|
|
454
|
+
// server responds with wipe_all (simulating fresh sync)
|
|
455
|
+
socket.mockServerMessage(
|
|
456
|
+
createConnectMessage({
|
|
457
|
+
connectRequestId: (connectMsg as TLConnectRequest).connectRequestId,
|
|
458
|
+
hydrationType: 'wipe_all',
|
|
459
|
+
diff: {}, // server has no pages
|
|
460
|
+
})
|
|
461
|
+
)
|
|
484
462
|
vi.advanceTimersByTime(100)
|
|
485
|
-
// Should have sent at least one message but possibly consolidated multiple changes
|
|
486
|
-
const messages = socket.getSentMessages()
|
|
487
|
-
expect(messages.length).toBeGreaterThan(0)
|
|
488
|
-
expect(messages.length).toBeLessThanOrEqual(5)
|
|
489
|
-
})
|
|
490
|
-
})
|
|
491
463
|
|
|
492
|
-
|
|
493
|
-
|
|
464
|
+
// the page should still exist locally
|
|
465
|
+
expect(store.has(pageId)).toBe(true)
|
|
466
|
+
expect(store.get(pageId)?.name).toBe('Offline Page')
|
|
494
467
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
userName: 'Test User',
|
|
502
|
-
} as any
|
|
468
|
+
// the speculative change should have been pushed to the server
|
|
469
|
+
const pushWithPage = getSentPushes().find((msg) => msg.diff?.[pageId])
|
|
470
|
+
expect(pushWithPage).toBeDefined()
|
|
471
|
+
expect(pushWithPage!.diff![pageId][0]).toBe(RecordOpType.Put)
|
|
472
|
+
expect((pushWithPage!.diff![pageId][1] as any).name).toBe('Offline Page')
|
|
473
|
+
})
|
|
503
474
|
|
|
475
|
+
it('[CL7] calls onAfterConnect with the isReadonly flag from the connect message', () => {
|
|
504
476
|
client = createClient()
|
|
477
|
+
socket.mockServerMessage(createConnectMessage({ isReadonly: false }))
|
|
478
|
+
expect(client.isConnectedToRoom).toBe(true)
|
|
479
|
+
expect(onAfterConnect).toHaveBeenCalledWith(client, { isReadonly: false })
|
|
505
480
|
|
|
506
|
-
//
|
|
507
|
-
socket.
|
|
508
|
-
socket.
|
|
481
|
+
// reconnect as readonly
|
|
482
|
+
socket.mockConnectionStatus('offline')
|
|
483
|
+
socket.mockConnectionStatus('online')
|
|
484
|
+
socket.mockServerMessage(createConnectMessage({ isReadonly: true }))
|
|
485
|
+
expect(onAfterConnect).toHaveBeenLastCalledWith(client, { isReadonly: true })
|
|
509
486
|
})
|
|
510
487
|
|
|
511
|
-
it('
|
|
488
|
+
it('[CL7] pushes the current presence state after connecting', () => {
|
|
489
|
+
client = createClient()
|
|
490
|
+
const presenceRecord = {
|
|
491
|
+
id: 'presence:user1' as any,
|
|
492
|
+
typeName: 'instance_presence',
|
|
493
|
+
cursor: { x: 100, y: 200 },
|
|
494
|
+
} as TestRecord
|
|
512
495
|
presence.set(presenceRecord)
|
|
513
|
-
|
|
496
|
+
socket.clearSentMessages()
|
|
514
497
|
|
|
515
|
-
|
|
516
|
-
const pushMessages = messages.filter(
|
|
517
|
-
(msg) => msg.type === 'push'
|
|
518
|
-
) as TLPushRequest<TestRecord>[]
|
|
519
|
-
expect(pushMessages.length).toBeGreaterThan(0)
|
|
498
|
+
socket.mockServerMessage(createConnectMessage())
|
|
520
499
|
|
|
521
|
-
const
|
|
522
|
-
expect(
|
|
523
|
-
expect(
|
|
500
|
+
const pushWithPresence = getSentPushes().find((msg) => msg.presence)
|
|
501
|
+
expect(pushWithPresence).toBeDefined()
|
|
502
|
+
expect(pushWithPresence!.presence).toEqual([RecordOpType.Put, presenceRecord])
|
|
524
503
|
})
|
|
525
504
|
|
|
526
|
-
it('
|
|
527
|
-
|
|
528
|
-
presence.set(presenceRecord)
|
|
529
|
-
vi.advanceTimersByTime(100)
|
|
530
|
-
|
|
531
|
-
expect(socket.getSentMessages()).toHaveLength(0)
|
|
532
|
-
})
|
|
505
|
+
it('[CL8] removes presence records from the store when the socket goes offline', () => {
|
|
506
|
+
connectClient()
|
|
533
507
|
|
|
534
|
-
|
|
535
|
-
|
|
508
|
+
const peerPresence = makePeerPresence()
|
|
509
|
+
socket.mockServerMessage({
|
|
510
|
+
type: 'data',
|
|
511
|
+
data: [
|
|
512
|
+
{
|
|
513
|
+
type: 'patch',
|
|
514
|
+
serverClock: 2,
|
|
515
|
+
diff: { [peerPresence.id]: [RecordOpType.Put, peerPresence] },
|
|
516
|
+
},
|
|
517
|
+
],
|
|
518
|
+
})
|
|
536
519
|
vi.advanceTimersByTime(100)
|
|
520
|
+
expect(store.has(peerPresence.id)).toBe(true)
|
|
537
521
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
) as TLPushRequest<TestRecord>[]
|
|
542
|
-
const messageWithPresence = pushMessages.find((msg) => msg.presence)
|
|
543
|
-
|
|
544
|
-
expect(messageWithPresence).toBeDefined()
|
|
545
|
-
expect(messageWithPresence!.presence![0]).toBe(RecordOpType.Put)
|
|
546
|
-
expect(messageWithPresence!.presence![1]).toBe(presenceRecord)
|
|
522
|
+
socket.mockConnectionStatus('offline')
|
|
523
|
+
expect(client.isConnectedToRoom).toBe(false)
|
|
524
|
+
expect(store.has(peerPresence.id)).toBe(false)
|
|
547
525
|
})
|
|
548
526
|
|
|
549
|
-
it('
|
|
550
|
-
|
|
551
|
-
presence.set(presenceRecord)
|
|
552
|
-
vi.advanceTimersByTime(100)
|
|
553
|
-
socket.clearSentMessages()
|
|
527
|
+
it('[CL8] drops pending pushes when going offline and re-pushes the speculative diff after reconnect', () => {
|
|
528
|
+
connectClient()
|
|
554
529
|
|
|
555
|
-
//
|
|
556
|
-
const
|
|
557
|
-
|
|
530
|
+
// an in-flight (pending, unacknowledged) push
|
|
531
|
+
const page = makePage()
|
|
532
|
+
store.put([page])
|
|
558
533
|
vi.advanceTimersByTime(100)
|
|
534
|
+
expect(getSentPushes()).toHaveLength(1)
|
|
559
535
|
|
|
560
|
-
const messages = socket.getSentMessages()
|
|
561
|
-
const pushMessages = messages.filter(
|
|
562
|
-
(msg) => msg.type === 'push'
|
|
563
|
-
) as TLPushRequest<TestRecord>[]
|
|
564
|
-
const messageWithPresence = pushMessages.find((msg) => msg.presence)
|
|
565
|
-
|
|
566
|
-
expect(messageWithPresence).toBeDefined()
|
|
567
|
-
expect(messageWithPresence!.presence![0]).toBe(RecordOpType.Patch)
|
|
568
|
-
})
|
|
569
|
-
|
|
570
|
-
it('does not send presence when offline', () => {
|
|
571
536
|
socket.mockConnectionStatus('offline')
|
|
537
|
+
socket.mockConnectionStatus('online')
|
|
538
|
+
socket.clearSentMessages()
|
|
572
539
|
|
|
573
|
-
|
|
540
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
541
|
+
socket.mockServerMessage(
|
|
542
|
+
createConnectMessage({ hydrationType: 'wipe_presence', serverClock: 2, diff: {} })
|
|
543
|
+
)
|
|
574
544
|
vi.advanceTimersByTime(100)
|
|
575
545
|
|
|
576
|
-
|
|
546
|
+
// the pending push queue was emptied by the reset (no internal error logged)
|
|
547
|
+
expect(errorSpy).not.toHaveBeenCalled()
|
|
548
|
+
expect(client.isConnectedToRoom).toBe(true)
|
|
549
|
+
// the still-speculative change is pushed again
|
|
550
|
+
const pushWithPage = getSentPushes().find((msg) => msg.diff?.[page.id])
|
|
551
|
+
expect(pushWithPage).toBeDefined()
|
|
552
|
+
errorSpy.mockRestore()
|
|
577
553
|
})
|
|
578
|
-
})
|
|
579
554
|
|
|
580
|
-
|
|
581
|
-
beforeEach(() => {
|
|
555
|
+
it('[CL8] fires onSyncError and closes permanently when the socket reports an error', () => {
|
|
582
556
|
client = createClient()
|
|
557
|
+
expect((globalThis as any).tlsync).toBe(client)
|
|
558
|
+
|
|
559
|
+
socket.mockConnectionStatus('error', 'Connection failed')
|
|
560
|
+
|
|
561
|
+
expect(onSyncError).toHaveBeenCalledWith('Connection failed')
|
|
562
|
+
// the client closed itself
|
|
563
|
+
expect((globalThis as any).tlsync).toBeUndefined()
|
|
583
564
|
})
|
|
584
565
|
|
|
585
|
-
it('handles
|
|
586
|
-
|
|
566
|
+
it('[CL8] handles rapid connection status changes ending in an error', () => {
|
|
567
|
+
connectClient()
|
|
568
|
+
socket.mockConnectionStatus('offline')
|
|
569
|
+
socket.mockConnectionStatus('online')
|
|
570
|
+
socket.mockConnectionStatus('error', 'Test error')
|
|
571
|
+
expect(onSyncError).toHaveBeenCalledWith('Test error')
|
|
572
|
+
})
|
|
587
573
|
|
|
588
|
-
|
|
589
|
-
|
|
574
|
+
it('[CL9] sends a ping every 5 seconds while connected', () => {
|
|
575
|
+
client = createClient()
|
|
576
|
+
socket.clearSentMessages()
|
|
590
577
|
|
|
591
|
-
//
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
action: 'commit',
|
|
595
|
-
serverClock: 2,
|
|
596
|
-
clientClock: 999999, // Non-existent client clock
|
|
597
|
-
}
|
|
578
|
+
// no pings before the room connection is established
|
|
579
|
+
vi.advanceTimersByTime(5000)
|
|
580
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
598
581
|
|
|
599
|
-
socket.mockServerMessage(
|
|
600
|
-
|
|
601
|
-
data: [malformedMessage],
|
|
602
|
-
})
|
|
582
|
+
socket.mockServerMessage(createConnectMessage())
|
|
583
|
+
socket.clearSentMessages()
|
|
603
584
|
|
|
604
|
-
vi.advanceTimersByTime(
|
|
585
|
+
vi.advanceTimersByTime(5000)
|
|
586
|
+
expect(socket.getSentMessages().filter((m) => m.type === 'ping')).toHaveLength(1)
|
|
605
587
|
|
|
606
|
-
|
|
607
|
-
|
|
588
|
+
// keep the connection healthy and observe the next ping
|
|
589
|
+
socket.mockServerMessage({ type: 'pong' })
|
|
590
|
+
vi.advanceTimersByTime(5000)
|
|
591
|
+
expect(socket.getSentMessages().filter((m) => m.type === 'ping')).toHaveLength(2)
|
|
608
592
|
})
|
|
609
593
|
|
|
610
|
-
it('
|
|
611
|
-
const consoleSpy = vi.spyOn(console, '
|
|
612
|
-
|
|
613
|
-
// Connect first
|
|
594
|
+
it('[CL9] warns and resets the connection after 10 seconds without server interaction', () => {
|
|
595
|
+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
596
|
+
client = createClient()
|
|
614
597
|
socket.mockServerMessage(createConnectMessage())
|
|
615
598
|
|
|
616
|
-
//
|
|
617
|
-
|
|
599
|
+
// advance time beyond the health check threshold
|
|
600
|
+
vi.advanceTimersByTime(15000)
|
|
618
601
|
|
|
619
|
-
|
|
620
|
-
|
|
602
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
603
|
+
expect.stringContaining("Haven't heard from the server in a while")
|
|
604
|
+
)
|
|
605
|
+
expect(client.isConnectedToRoom).toBe(false)
|
|
621
606
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
store.put([
|
|
625
|
-
PageRecordType.create({
|
|
626
|
-
id: pageId,
|
|
627
|
-
name: 'Test',
|
|
628
|
-
index: 'a1' as any,
|
|
629
|
-
}),
|
|
630
|
-
])
|
|
607
|
+
consoleSpy.mockRestore()
|
|
608
|
+
})
|
|
631
609
|
|
|
632
|
-
|
|
610
|
+
it('[CL10] a pong refreshes the server interaction timestamp and defers the health reset', () => {
|
|
611
|
+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
612
|
+
client = createClient()
|
|
613
|
+
socket.mockServerMessage(createConnectMessage())
|
|
633
614
|
|
|
634
|
-
|
|
635
|
-
|
|
615
|
+
vi.advanceTimersByTime(6000)
|
|
616
|
+
socket.mockServerMessage({ type: 'pong' })
|
|
617
|
+
|
|
618
|
+
// at t=10s the health check passes because the pong was only 4s ago
|
|
619
|
+
vi.advanceTimersByTime(4000)
|
|
620
|
+
expect(client.isConnectedToRoom).toBe(true)
|
|
621
|
+
|
|
622
|
+
// at t=20s nothing has been heard since the pong, so the connection resets
|
|
623
|
+
vi.advanceTimersByTime(10000)
|
|
624
|
+
expect(client.isConnectedToRoom).toBe(false)
|
|
636
625
|
|
|
637
626
|
consoleSpy.mockRestore()
|
|
638
627
|
})
|
|
639
628
|
|
|
640
|
-
it('
|
|
641
|
-
|
|
642
|
-
client = createClient({ didCancel })
|
|
629
|
+
it('[CL11] closes instead of processing the next event when didCancel returns true', () => {
|
|
630
|
+
let cancelled = false
|
|
631
|
+
client = createClient({ didCancel: () => cancelled })
|
|
632
|
+
socket.mockServerMessage(createConnectMessage())
|
|
633
|
+
expect((globalThis as any).tlsync).toBe(client)
|
|
643
634
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
name: 'Test',
|
|
650
|
-
index: 'a1' as any,
|
|
651
|
-
}),
|
|
652
|
-
])
|
|
653
|
-
|
|
654
|
-
expect(didCancel).toHaveBeenCalled()
|
|
635
|
+
cancelled = true
|
|
636
|
+
socket.mockServerMessage({ type: 'pong' })
|
|
637
|
+
|
|
638
|
+
// the client closed itself instead of processing the event
|
|
639
|
+
expect((globalThis as any).tlsync).toBeUndefined()
|
|
655
640
|
})
|
|
656
|
-
})
|
|
657
641
|
|
|
658
|
-
|
|
659
|
-
beforeEach(() => {
|
|
642
|
+
it('[CL12] construction installs window.tlsync and close removes it', () => {
|
|
660
643
|
client = createClient()
|
|
644
|
+
expect((globalThis as any).tlsync).toBe(client)
|
|
645
|
+
client.close()
|
|
646
|
+
expect((globalThis as any).tlsync).toBeUndefined()
|
|
661
647
|
})
|
|
662
648
|
|
|
663
|
-
it('
|
|
649
|
+
it('[CL12] close disposes store listeners and timers', () => {
|
|
650
|
+
connectClient()
|
|
664
651
|
client.close()
|
|
665
652
|
|
|
666
|
-
//
|
|
653
|
+
// no pushes for store changes, and no pings, after close
|
|
667
654
|
expect(() => {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
PageRecordType.create({
|
|
671
|
-
id: pageId,
|
|
672
|
-
name: 'Test',
|
|
673
|
-
index: 'a1' as any,
|
|
674
|
-
}),
|
|
675
|
-
])
|
|
676
|
-
vi.advanceTimersByTime(100)
|
|
655
|
+
store.put([makePage()])
|
|
656
|
+
vi.advanceTimersByTime(20000)
|
|
677
657
|
}).not.toThrow()
|
|
658
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
678
659
|
})
|
|
679
660
|
|
|
680
|
-
it('
|
|
681
|
-
|
|
682
|
-
|
|
661
|
+
it('[CL13] logs incompatibility_error messages without closing or raising a sync error', () => {
|
|
662
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
663
|
+
connectClient()
|
|
683
664
|
|
|
684
|
-
|
|
685
|
-
store.put([
|
|
686
|
-
PageRecordType.create({
|
|
687
|
-
id: pageId,
|
|
688
|
-
name: 'Test',
|
|
689
|
-
index: 'a1' as any,
|
|
690
|
-
}),
|
|
691
|
-
])
|
|
692
|
-
|
|
693
|
-
// Close before throttle resolves
|
|
694
|
-
client.close()
|
|
665
|
+
socket.mockServerMessage({ type: 'incompatibility_error', reason: 'clientTooOld' })
|
|
695
666
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
expect(
|
|
667
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
668
|
+
expect.stringContaining('incompatibility error is legacy')
|
|
669
|
+
)
|
|
670
|
+
expect(onSyncError).not.toHaveBeenCalled()
|
|
671
|
+
expect(client.isConnectedToRoom).toBe(true)
|
|
672
|
+
|
|
673
|
+
consoleSpy.mockRestore()
|
|
700
674
|
})
|
|
701
675
|
})
|
|
702
676
|
|
|
703
|
-
describe('
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
})
|
|
677
|
+
describe('20. pushing changes (CP)', () => {
|
|
678
|
+
it('[CP1] pushes user changes to document-scope records', () => {
|
|
679
|
+
connectClient()
|
|
707
680
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
681
|
+
const page = makePage()
|
|
682
|
+
store.put([page])
|
|
683
|
+
vi.advanceTimersByTime(100)
|
|
711
684
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
name: 'Test',
|
|
718
|
-
index: 'a1' as any,
|
|
719
|
-
}),
|
|
720
|
-
])
|
|
721
|
-
|
|
722
|
-
// Go offline
|
|
723
|
-
socket.mockConnectionStatus('offline')
|
|
685
|
+
const pushes = getSentPushes()
|
|
686
|
+
expect(pushes).toHaveLength(1)
|
|
687
|
+
expect(pushes[0].diff).toBeDefined()
|
|
688
|
+
expect(pushes[0].diff![page.id]).toBeDefined()
|
|
689
|
+
})
|
|
724
690
|
|
|
725
|
-
|
|
726
|
-
|
|
691
|
+
it('[CP1] does not push changes made with a remote source', () => {
|
|
692
|
+
connectClient()
|
|
727
693
|
|
|
728
|
-
|
|
729
|
-
|
|
694
|
+
const page = makePage()
|
|
695
|
+
store.mergeRemoteChanges(() => {
|
|
696
|
+
store.put([page])
|
|
697
|
+
})
|
|
698
|
+
vi.advanceTimersByTime(100)
|
|
730
699
|
|
|
731
|
-
|
|
732
|
-
expect(socket.getSentMessages()
|
|
700
|
+
expect(store.has(page.id)).toBe(true)
|
|
701
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
733
702
|
})
|
|
734
703
|
|
|
735
|
-
it('
|
|
736
|
-
|
|
737
|
-
const pageId = PageRecordType.createId()
|
|
738
|
-
store.put([
|
|
739
|
-
PageRecordType.create({
|
|
740
|
-
id: pageId,
|
|
741
|
-
name: 'Test',
|
|
742
|
-
index: 'a1' as any,
|
|
743
|
-
}),
|
|
744
|
-
])
|
|
704
|
+
it('[CP1] does not push changes to non-document-scope records', () => {
|
|
705
|
+
connectClient()
|
|
745
706
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
[TLDOCUMENT_ID]: [
|
|
750
|
-
RecordOpType.Put,
|
|
751
|
-
DocumentRecordType.create({
|
|
752
|
-
id: TLDOCUMENT_ID,
|
|
753
|
-
gridSize: 20,
|
|
754
|
-
}),
|
|
755
|
-
],
|
|
756
|
-
},
|
|
757
|
-
})
|
|
758
|
-
)
|
|
707
|
+
// camera records are session-scoped
|
|
708
|
+
store.put([CameraRecordType.create({ id: CameraRecordType.createId() })])
|
|
709
|
+
vi.advanceTimersByTime(100)
|
|
759
710
|
|
|
760
|
-
|
|
761
|
-
const doc = store.get(TLDOCUMENT_ID)
|
|
762
|
-
expect(doc?.gridSize).toBe(20)
|
|
711
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
763
712
|
})
|
|
764
|
-
})
|
|
765
713
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
// Connect first
|
|
770
|
-
socket.mockServerMessage(createConnectMessage())
|
|
714
|
+
it('[CP3] accumulates changes while offline without sending anything', () => {
|
|
715
|
+
connectClient()
|
|
716
|
+
socket.mockConnectionStatus('offline')
|
|
771
717
|
socket.clearSentMessages()
|
|
718
|
+
|
|
719
|
+
const page = makePage('Offline Page')
|
|
720
|
+
store.put([page])
|
|
721
|
+
vi.advanceTimersByTime(100)
|
|
722
|
+
|
|
723
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
724
|
+
// but the page exists locally
|
|
725
|
+
expect(store.has(page.id)).toBe(true)
|
|
772
726
|
})
|
|
773
727
|
|
|
774
|
-
it('
|
|
775
|
-
|
|
776
|
-
socket.mockConnectionStatus('offline')
|
|
777
|
-
socket.mockConnectionStatus('online')
|
|
778
|
-
socket.mockConnectionStatus('error', 'Test error')
|
|
728
|
+
it('[CP4] increments the client clock by exactly one per sent push', () => {
|
|
729
|
+
connectClient()
|
|
779
730
|
|
|
780
|
-
|
|
731
|
+
store.put([makePage('One', 'a1')])
|
|
732
|
+
vi.advanceTimersByTime(100)
|
|
733
|
+
store.put([makePage('Two', 'a2')])
|
|
734
|
+
vi.advanceTimersByTime(100)
|
|
735
|
+
store.put([makePage('Three', 'a3')])
|
|
736
|
+
vi.advanceTimersByTime(100)
|
|
737
|
+
|
|
738
|
+
const pushes = getSentPushes()
|
|
739
|
+
expect(pushes.map((p) => p.clientClock)).toEqual([0, 1, 2])
|
|
781
740
|
})
|
|
782
741
|
|
|
783
|
-
it('
|
|
784
|
-
|
|
742
|
+
it('[CP5] sends the first presence as a put and subsequent presence as patches', () => {
|
|
743
|
+
connectClient()
|
|
785
744
|
const presenceRecord = {
|
|
786
745
|
id: 'presence:user1' as any,
|
|
787
746
|
typeName: 'instance_presence',
|
|
788
747
|
cursor: { x: 100, y: 200 },
|
|
748
|
+
userName: 'Test User',
|
|
789
749
|
} as TestRecord
|
|
790
750
|
|
|
791
751
|
presence.set(presenceRecord)
|
|
752
|
+
vi.advanceTimersByTime(100)
|
|
792
753
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
id: pageId,
|
|
798
|
-
name: 'Test',
|
|
799
|
-
index: 'a1' as any,
|
|
800
|
-
}),
|
|
801
|
-
])
|
|
754
|
+
const firstPush = getSentPushes().find((msg) => msg.presence)
|
|
755
|
+
expect(firstPush).toBeDefined()
|
|
756
|
+
expect(firstPush!.presence).toEqual([RecordOpType.Put, presenceRecord])
|
|
757
|
+
socket.clearSentMessages()
|
|
802
758
|
|
|
759
|
+
const updatedPresence = { ...presenceRecord, cursor: { x: 150, y: 250 } } as TestRecord
|
|
760
|
+
presence.set(updatedPresence)
|
|
803
761
|
vi.advanceTimersByTime(100)
|
|
804
762
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
const pushMessages = messages.filter(
|
|
811
|
-
(msg) => msg.type === 'push'
|
|
812
|
-
) as TLPushRequest<TestRecord>[]
|
|
813
|
-
const hasPresenceOrDocument = pushMessages.some((msg) => msg.presence || msg.diff)
|
|
814
|
-
expect(hasPresenceOrDocument).toBe(true)
|
|
763
|
+
const secondPush = getSentPushes().find((msg) => msg.presence)
|
|
764
|
+
expect(secondPush).toBeDefined()
|
|
765
|
+
// the patch is a diff against the last pushed state
|
|
766
|
+
expect(secondPush!.presence![0]).toBe(RecordOpType.Patch)
|
|
767
|
+
expect(secondPush!.presence![1]).toEqual({ cursor: [ValueOpType.Put, { x: 150, y: 250 }] })
|
|
815
768
|
})
|
|
816
769
|
|
|
817
|
-
it('
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
})
|
|
830
|
-
}
|
|
770
|
+
it('[CP5] sends nothing when the presence state is unchanged', () => {
|
|
771
|
+
connectClient()
|
|
772
|
+
const presenceRecord = {
|
|
773
|
+
id: 'presence:user1' as any,
|
|
774
|
+
typeName: 'instance_presence',
|
|
775
|
+
cursor: { x: 100, y: 200 },
|
|
776
|
+
} as TestRecord
|
|
777
|
+
|
|
778
|
+
presence.set(presenceRecord)
|
|
779
|
+
vi.advanceTimersByTime(100)
|
|
780
|
+
expect(getSentPushes()).toHaveLength(1)
|
|
781
|
+
socket.clearSentMessages()
|
|
831
782
|
|
|
783
|
+
// a deep-equal copy produces no diff and therefore no push
|
|
784
|
+
presence.set({ ...presenceRecord, cursor: { x: 100, y: 200 } } as any)
|
|
832
785
|
vi.advanceTimersByTime(100)
|
|
833
|
-
|
|
786
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
834
787
|
})
|
|
835
|
-
})
|
|
836
788
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
789
|
+
it('[CP5] re-puts presence in full after a reconnect', () => {
|
|
790
|
+
connectClient()
|
|
791
|
+
const presenceRecord = {
|
|
792
|
+
id: 'presence:user1' as any,
|
|
793
|
+
typeName: 'instance_presence',
|
|
794
|
+
cursor: { x: 1, y: 1 },
|
|
795
|
+
} as TestRecord
|
|
796
|
+
|
|
797
|
+
presence.set(presenceRecord)
|
|
798
|
+
vi.advanceTimersByTime(100)
|
|
799
|
+
const updatedPresence = { ...presenceRecord, cursor: { x: 2, y: 2 } } as TestRecord
|
|
800
|
+
presence.set(updatedPresence)
|
|
801
|
+
vi.advanceTimersByTime(100)
|
|
802
|
+
// sanity: we are in patch mode by now
|
|
803
|
+
expect(getSentPushes().at(-1)!.presence![0]).toBe(RecordOpType.Patch)
|
|
843
804
|
|
|
844
|
-
it('does not send changes made while offline', () => {
|
|
845
|
-
// Go offline
|
|
846
805
|
socket.mockConnectionStatus('offline')
|
|
806
|
+
socket.mockConnectionStatus('online')
|
|
847
807
|
socket.clearSentMessages()
|
|
808
|
+
socket.mockServerMessage(
|
|
809
|
+
createConnectMessage({ hydrationType: 'wipe_presence', serverClock: 2, diff: {} })
|
|
810
|
+
)
|
|
811
|
+
vi.advanceTimersByTime(100)
|
|
848
812
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
id: pageId,
|
|
854
|
-
name: 'Offline Page',
|
|
855
|
-
index: 'a1' as any,
|
|
856
|
-
}),
|
|
857
|
-
])
|
|
813
|
+
const pushWithPresence = getSentPushes().find((msg) => msg.presence)
|
|
814
|
+
expect(pushWithPresence).toBeDefined()
|
|
815
|
+
expect(pushWithPresence!.presence).toEqual([RecordOpType.Put, updatedPresence])
|
|
816
|
+
})
|
|
858
817
|
|
|
859
|
-
|
|
818
|
+
it('[CP6] does not push presence in solo mode', () => {
|
|
819
|
+
presenceMode.set('solo')
|
|
820
|
+
connectClient()
|
|
860
821
|
|
|
861
|
-
|
|
862
|
-
|
|
822
|
+
presence.set({
|
|
823
|
+
id: 'presence:user1' as any,
|
|
824
|
+
typeName: 'instance_presence',
|
|
825
|
+
cursor: { x: 100, y: 200 },
|
|
826
|
+
} as TestRecord)
|
|
827
|
+
vi.advanceTimersByTime(2000)
|
|
863
828
|
|
|
864
|
-
|
|
865
|
-
expect(store.has(pageId)).toBe(true)
|
|
829
|
+
expect(getSentPushes().filter((msg) => msg.presence)).toHaveLength(0)
|
|
866
830
|
})
|
|
867
831
|
|
|
868
|
-
it('
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
socket.clearSentMessages()
|
|
832
|
+
it('[CP6] still pushes document changes in solo mode', () => {
|
|
833
|
+
presenceMode.set('solo')
|
|
834
|
+
connectClient()
|
|
872
835
|
|
|
873
|
-
const
|
|
874
|
-
store.put([
|
|
875
|
-
|
|
876
|
-
id: pageId,
|
|
877
|
-
name: 'Offline Page',
|
|
878
|
-
index: 'a1' as any,
|
|
879
|
-
}),
|
|
880
|
-
])
|
|
881
|
-
vi.advanceTimersByTime(100)
|
|
836
|
+
const page = makePage('Solo Page')
|
|
837
|
+
store.put([page])
|
|
838
|
+
vi.advanceTimersByTime(2000)
|
|
882
839
|
|
|
883
|
-
|
|
884
|
-
expect(
|
|
885
|
-
|
|
840
|
+
const docPushes = getSentPushes().filter((msg) => msg.diff?.[page.id])
|
|
841
|
+
expect(docPushes.length).toBeGreaterThan(0)
|
|
842
|
+
})
|
|
886
843
|
|
|
887
|
-
|
|
888
|
-
|
|
844
|
+
it('[CP6] drops the network throttle from 30fps to 1fps in solo mode', () => {
|
|
845
|
+
client = createClient()
|
|
846
|
+
expect((client as any).fpsScheduler.targetFps).toBe(30)
|
|
889
847
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
expect(connectMsg).toBeDefined()
|
|
848
|
+
presenceMode.set('solo')
|
|
849
|
+
expect((client as any).fpsScheduler.targetFps).toBe(1)
|
|
893
850
|
|
|
894
|
-
|
|
895
|
-
|
|
851
|
+
presenceMode.set('full')
|
|
852
|
+
expect((client as any).fpsScheduler.targetFps).toBe(30)
|
|
853
|
+
})
|
|
896
854
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
connectRequestId: (connectMsg as any).connectRequestId,
|
|
901
|
-
hydrationType: 'wipe_all',
|
|
902
|
-
diff: {}, // Server has no pages
|
|
903
|
-
})
|
|
904
|
-
)
|
|
905
|
-
vi.advanceTimersByTime(100)
|
|
855
|
+
it('[CP7] stops sending pushes when the store reports possible corruption', () => {
|
|
856
|
+
connectClient()
|
|
857
|
+
vi.spyOn(store, 'isPossiblyCorrupted').mockReturnValue(true)
|
|
906
858
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
expect(store.get(pageId)?.name).toBe('Offline Page')
|
|
859
|
+
store.put([makePage()])
|
|
860
|
+
vi.advanceTimersByTime(100)
|
|
910
861
|
|
|
911
|
-
|
|
912
|
-
const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
|
|
913
|
-
const pushWithPage = messages.find(
|
|
914
|
-
(msg) => msg.type === 'push' && msg.diff && msg.diff[pageId]
|
|
915
|
-
)
|
|
916
|
-
expect(pushWithPage).toBeDefined()
|
|
917
|
-
expect(pushWithPage!.diff![pageId][0]).toBe(RecordOpType.Put)
|
|
918
|
-
expect((pushWithPage!.diff![pageId][1] as any).name).toBe('Offline Page')
|
|
862
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
919
863
|
})
|
|
920
|
-
})
|
|
921
864
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
865
|
+
describe('push coalescing (multiple push() calls become a single network message)', () => {
|
|
866
|
+
/**
|
|
867
|
+
* These tests verify that multiple store changes (each triggering push())
|
|
868
|
+
* get coalesced into a single TLPushRequest when the throttle fires.
|
|
869
|
+
*
|
|
870
|
+
* We enable RAF mode so the throttle actually delays execution,
|
|
871
|
+
* allowing changes to accumulate before sending.
|
|
872
|
+
*/
|
|
873
|
+
|
|
874
|
+
let rafCallbacks: Array<FrameRequestCallback>
|
|
875
|
+
let rafId: number
|
|
876
|
+
|
|
877
|
+
function flushOneRaf() {
|
|
878
|
+
if (rafCallbacks.length > 0) {
|
|
879
|
+
const callbacks = rafCallbacks.splice(0, rafCallbacks.length)
|
|
880
|
+
const now = performance.now()
|
|
881
|
+
for (const callback of callbacks) {
|
|
882
|
+
callback(now)
|
|
883
|
+
}
|
|
940
884
|
}
|
|
941
885
|
}
|
|
942
|
-
}
|
|
943
886
|
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
887
|
+
function flushThrottle() {
|
|
888
|
+
// FpsScheduler needs: advance time + flush RAF (potentially twice for tick + flush)
|
|
889
|
+
// Also need to clear any stale callbacks and keep flushing until stable
|
|
890
|
+
for (let i = 0; i < 10 && rafCallbacks.length > 0; i++) {
|
|
891
|
+
vi.advanceTimersByTime(100)
|
|
892
|
+
flushOneRaf()
|
|
893
|
+
}
|
|
950
894
|
}
|
|
951
|
-
}
|
|
952
895
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
896
|
+
beforeEach(() => {
|
|
897
|
+
// Reset timer to a known state to avoid pollution from previous tests
|
|
898
|
+
vi.setSystemTime(0)
|
|
899
|
+
|
|
900
|
+
// Force RAF behavior so throttle actually delays
|
|
901
|
+
// @ts-expect-error - testing flag
|
|
902
|
+
globalThis.__FORCE_RAF_IN_TESTS__ = true
|
|
903
|
+
|
|
904
|
+
rafCallbacks = []
|
|
905
|
+
rafId = 0
|
|
906
|
+
|
|
907
|
+
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
|
908
|
+
const id = ++rafId
|
|
909
|
+
rafCallbacks.push(callback)
|
|
910
|
+
return id
|
|
911
|
+
})
|
|
956
912
|
|
|
957
|
-
|
|
958
|
-
// @ts-expect-error - testing flag
|
|
959
|
-
globalThis.__FORCE_RAF_IN_TESTS__ = true
|
|
913
|
+
vi.stubGlobal('cancelAnimationFrame', (_id: number) => {})
|
|
960
914
|
|
|
961
|
-
|
|
962
|
-
|
|
915
|
+
// Create client with RAF mode enabled
|
|
916
|
+
client = createClient()
|
|
917
|
+
socket.mockServerMessage(createConnectMessage())
|
|
963
918
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
return id
|
|
919
|
+
// Flush initial setup and clear
|
|
920
|
+
flushThrottle()
|
|
921
|
+
socket.clearSentMessages()
|
|
968
922
|
})
|
|
969
923
|
|
|
970
|
-
|
|
924
|
+
afterEach(() => {
|
|
925
|
+
// @ts-expect-error - testing flag
|
|
926
|
+
delete globalThis.__FORCE_RAF_IN_TESTS__
|
|
927
|
+
vi.unstubAllGlobals()
|
|
928
|
+
})
|
|
971
929
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
930
|
+
it('[CP2] coalesces 5 store.put() calls into 1 push request', () => {
|
|
931
|
+
expect(client.isConnectedToRoom).toBe(true)
|
|
932
|
+
|
|
933
|
+
// Make 5 separate store changes synchronously
|
|
934
|
+
const pageIds: string[] = []
|
|
935
|
+
for (let i = 0; i < 5; i++) {
|
|
936
|
+
const pageId = PageRecordType.createId()
|
|
937
|
+
pageIds.push(pageId)
|
|
938
|
+
store.put([
|
|
939
|
+
PageRecordType.create({
|
|
940
|
+
id: pageId,
|
|
941
|
+
name: `Page ${i}`,
|
|
942
|
+
index: `a${i}` as any,
|
|
943
|
+
}),
|
|
944
|
+
])
|
|
945
|
+
}
|
|
975
946
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
socket.clearSentMessages()
|
|
979
|
-
})
|
|
947
|
+
// Before throttle fires: no messages should be sent yet
|
|
948
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
980
949
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
delete globalThis.__FORCE_RAF_IN_TESTS__
|
|
984
|
-
vi.unstubAllGlobals()
|
|
985
|
-
})
|
|
950
|
+
// Flush the throttle
|
|
951
|
+
flushThrottle()
|
|
986
952
|
|
|
987
|
-
|
|
988
|
-
|
|
953
|
+
// Should have sent exactly ONE push request containing all 5 pages
|
|
954
|
+
const messages = getSentPushes()
|
|
955
|
+
expect(messages).toHaveLength(1)
|
|
956
|
+
expect(messages[0].type).toBe('push')
|
|
957
|
+
|
|
958
|
+
// The single message should contain all 5 page IDs
|
|
959
|
+
const diff = messages[0].diff || {}
|
|
960
|
+
expect(Object.keys(diff)).toHaveLength(5)
|
|
961
|
+
for (const pageId of pageIds) {
|
|
962
|
+
expect(diff[pageId]).toBeDefined()
|
|
963
|
+
}
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
it('[CP2] coalesces create + multiple updates into 1 push with the final state', () => {
|
|
967
|
+
expect(client.isConnectedToRoom).toBe(true)
|
|
989
968
|
|
|
990
|
-
// Make 5 separate store changes synchronously
|
|
991
|
-
const pageIds: string[] = []
|
|
992
|
-
for (let i = 0; i < 5; i++) {
|
|
993
969
|
const pageId = PageRecordType.createId()
|
|
994
|
-
|
|
970
|
+
|
|
971
|
+
// Create and update the same record multiple times synchronously
|
|
995
972
|
store.put([
|
|
996
973
|
PageRecordType.create({
|
|
997
974
|
id: pageId,
|
|
998
|
-
name:
|
|
999
|
-
index:
|
|
975
|
+
name: 'Version 1',
|
|
976
|
+
index: 'a1' as any,
|
|
1000
977
|
}),
|
|
1001
978
|
])
|
|
1002
|
-
|
|
979
|
+
store.update(pageId, (p) => ({ ...p, name: 'Version 2' }))
|
|
980
|
+
store.update(pageId, (p) => ({ ...p, name: 'Version 3' }))
|
|
981
|
+
store.update(pageId, (p) => ({ ...p, name: 'Final Version' }))
|
|
1003
982
|
|
|
1004
|
-
|
|
1005
|
-
|
|
983
|
+
// Before throttle fires: no messages
|
|
984
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
1006
985
|
|
|
1007
|
-
|
|
1008
|
-
|
|
986
|
+
// Flush the throttle
|
|
987
|
+
flushThrottle()
|
|
1009
988
|
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
expect(messages[0].type).toBe('push')
|
|
989
|
+
// Should have exactly ONE push request
|
|
990
|
+
const messages = getSentPushes()
|
|
991
|
+
expect(messages).toHaveLength(1)
|
|
1014
992
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
expect(Object.keys(diff)).toHaveLength(5)
|
|
1018
|
-
for (const pageId of pageIds) {
|
|
993
|
+
// The diff should have the final state
|
|
994
|
+
const diff = messages[0].diff!
|
|
1019
995
|
expect(diff[pageId]).toBeDefined()
|
|
1020
|
-
|
|
1021
|
-
|
|
996
|
+
expect(diff[pageId][0]).toBe(RecordOpType.Put)
|
|
997
|
+
expect((diff[pageId][1] as any).name).toBe('Final Version')
|
|
998
|
+
})
|
|
1022
999
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1000
|
+
it('[CP2] coalesces create + delete into no push at all (changes cancel out)', () => {
|
|
1001
|
+
const pageId = PageRecordType.createId()
|
|
1025
1002
|
|
|
1026
|
-
|
|
1003
|
+
// Create then immediately delete
|
|
1004
|
+
store.put([
|
|
1005
|
+
PageRecordType.create({
|
|
1006
|
+
id: pageId,
|
|
1007
|
+
name: 'Ephemeral',
|
|
1008
|
+
index: 'a1' as any,
|
|
1009
|
+
}),
|
|
1010
|
+
])
|
|
1011
|
+
store.remove([pageId])
|
|
1027
1012
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
PageRecordType.create({
|
|
1031
|
-
id: pageId,
|
|
1032
|
-
name: 'Version 1',
|
|
1033
|
-
index: 'a1' as any,
|
|
1034
|
-
}),
|
|
1035
|
-
])
|
|
1036
|
-
store.update(pageId, (p) => ({ ...p, name: 'Version 2' }))
|
|
1037
|
-
store.update(pageId, (p) => ({ ...p, name: 'Version 3' }))
|
|
1038
|
-
store.update(pageId, (p) => ({ ...p, name: 'Final Version' }))
|
|
1039
|
-
|
|
1040
|
-
// Before throttle fires: no messages
|
|
1041
|
-
expect(socket.getSentMessages()).toHaveLength(0)
|
|
1013
|
+
// Before throttle fires: no messages
|
|
1014
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
1042
1015
|
|
|
1043
|
-
|
|
1044
|
-
|
|
1016
|
+
// Flush the throttle
|
|
1017
|
+
flushThrottle()
|
|
1045
1018
|
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1019
|
+
// add + remove = no-op, so nothing is sent
|
|
1020
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
1021
|
+
})
|
|
1049
1022
|
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1023
|
+
it('[CP2][CP5] coalesces multiple presence updates into 1 push with the final presence (first is put)', () => {
|
|
1024
|
+
// Multiple presence updates synchronously - first presence ever sent
|
|
1025
|
+
presence.set({
|
|
1026
|
+
id: 'presence:u1' as any,
|
|
1027
|
+
typeName: 'instance_presence',
|
|
1028
|
+
cursor: { x: 0, y: 0 },
|
|
1029
|
+
} as any)
|
|
1030
|
+
presence.set({
|
|
1031
|
+
id: 'presence:u1' as any,
|
|
1032
|
+
typeName: 'instance_presence',
|
|
1033
|
+
cursor: { x: 50, y: 50 },
|
|
1034
|
+
} as any)
|
|
1035
|
+
presence.set({
|
|
1036
|
+
id: 'presence:u1' as any,
|
|
1037
|
+
typeName: 'instance_presence',
|
|
1038
|
+
cursor: { x: 100, y: 100 },
|
|
1039
|
+
} as any)
|
|
1040
|
+
|
|
1041
|
+
// Before throttle fires: no messages
|
|
1042
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
1043
|
+
|
|
1044
|
+
// Flush the throttle
|
|
1045
|
+
flushThrottle()
|
|
1046
|
+
|
|
1047
|
+
// Should have exactly ONE push with final presence
|
|
1048
|
+
const messages = getSentPushes()
|
|
1049
|
+
expect(messages).toHaveLength(1)
|
|
1050
|
+
expect(messages[0].presence).toBeDefined()
|
|
1051
|
+
// First presence is always a Put (full record)
|
|
1052
|
+
expect(messages[0].presence![0]).toBe(RecordOpType.Put)
|
|
1053
|
+
expect((messages[0].presence![1] as any).cursor).toEqual({ x: 100, y: 100 })
|
|
1054
|
+
})
|
|
1056
1055
|
|
|
1057
|
-
|
|
1058
|
-
|
|
1056
|
+
it('[CP2][CP5] sends subsequent presence updates as patch after the initial put', () => {
|
|
1057
|
+
// Send initial presence
|
|
1058
|
+
presence.set({
|
|
1059
|
+
id: 'presence:u1' as any,
|
|
1060
|
+
typeName: 'instance_presence',
|
|
1061
|
+
cursor: { x: 0, y: 0 },
|
|
1062
|
+
} as any)
|
|
1063
|
+
flushThrottle()
|
|
1064
|
+
|
|
1065
|
+
// Verify first presence was a Put
|
|
1066
|
+
const firstMessages = getSentPushes()
|
|
1067
|
+
expect(firstMessages).toHaveLength(1)
|
|
1068
|
+
expect(firstMessages[0].presence![0]).toBe(RecordOpType.Put)
|
|
1069
|
+
|
|
1070
|
+
socket.clearSentMessages()
|
|
1071
|
+
|
|
1072
|
+
// Now send multiple presence updates
|
|
1073
|
+
presence.set({
|
|
1074
|
+
id: 'presence:u1' as any,
|
|
1075
|
+
typeName: 'instance_presence',
|
|
1076
|
+
cursor: { x: 50, y: 50 },
|
|
1077
|
+
} as any)
|
|
1078
|
+
presence.set({
|
|
1079
|
+
id: 'presence:u1' as any,
|
|
1080
|
+
typeName: 'instance_presence',
|
|
1081
|
+
cursor: { x: 100, y: 100 },
|
|
1082
|
+
} as any)
|
|
1083
|
+
|
|
1084
|
+
// Before throttle fires: no messages
|
|
1085
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
1086
|
+
|
|
1087
|
+
// Flush the throttle
|
|
1088
|
+
flushThrottle()
|
|
1089
|
+
|
|
1090
|
+
// Should have exactly ONE push with final presence as a Patch
|
|
1091
|
+
const messages = getSentPushes()
|
|
1092
|
+
expect(messages).toHaveLength(1)
|
|
1093
|
+
expect(messages[0].presence).toBeDefined()
|
|
1094
|
+
// Subsequent presence updates should be Patches (only changed fields)
|
|
1095
|
+
expect(messages[0].presence![0]).toBe(RecordOpType.Patch)
|
|
1096
|
+
// The patch should contain the cursor update
|
|
1097
|
+
expect((messages[0].presence![1] as any).cursor).toBeDefined()
|
|
1098
|
+
})
|
|
1059
1099
|
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
PageRecordType.create({
|
|
1063
|
-
id: pageId,
|
|
1064
|
-
name: 'Ephemeral',
|
|
1065
|
-
index: 'a1' as any,
|
|
1066
|
-
}),
|
|
1067
|
-
])
|
|
1068
|
-
store.remove([pageId])
|
|
1100
|
+
it('[CP2][CP5] coalesces document changes + presence into 1 push request', () => {
|
|
1101
|
+
const pageId = PageRecordType.createId()
|
|
1069
1102
|
|
|
1070
|
-
|
|
1071
|
-
|
|
1103
|
+
// Document change
|
|
1104
|
+
store.put([
|
|
1105
|
+
PageRecordType.create({
|
|
1106
|
+
id: pageId,
|
|
1107
|
+
name: 'Test Page',
|
|
1108
|
+
index: 'a1' as any,
|
|
1109
|
+
}),
|
|
1110
|
+
])
|
|
1072
1111
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1112
|
+
// Presence change
|
|
1113
|
+
presence.set({
|
|
1114
|
+
id: 'presence:u1' as any,
|
|
1115
|
+
typeName: 'instance_presence',
|
|
1116
|
+
cursor: { x: 42, y: 42 },
|
|
1117
|
+
} as any)
|
|
1075
1118
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
if (messages.length > 0) {
|
|
1079
|
-
const diff = messages[0].diff || {}
|
|
1080
|
-
// The page should NOT be in the diff (add + remove = no-op)
|
|
1081
|
-
expect(diff[pageId]).toBeUndefined()
|
|
1082
|
-
}
|
|
1083
|
-
})
|
|
1119
|
+
// Before throttle fires: no messages
|
|
1120
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
1084
1121
|
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
presence.set({
|
|
1088
|
-
id: 'presence:u1' as any,
|
|
1089
|
-
typeName: 'instance_presence',
|
|
1090
|
-
cursor: { x: 0, y: 0 },
|
|
1091
|
-
} as any)
|
|
1092
|
-
presence.set({
|
|
1093
|
-
id: 'presence:u1' as any,
|
|
1094
|
-
typeName: 'instance_presence',
|
|
1095
|
-
cursor: { x: 50, y: 50 },
|
|
1096
|
-
} as any)
|
|
1097
|
-
presence.set({
|
|
1098
|
-
id: 'presence:u1' as any,
|
|
1099
|
-
typeName: 'instance_presence',
|
|
1100
|
-
cursor: { x: 100, y: 100 },
|
|
1101
|
-
} as any)
|
|
1122
|
+
// Flush the throttle
|
|
1123
|
+
flushThrottle()
|
|
1102
1124
|
|
|
1103
|
-
|
|
1104
|
-
|
|
1125
|
+
// Should have exactly ONE push with both document and presence
|
|
1126
|
+
const messages = getSentPushes()
|
|
1127
|
+
expect(messages).toHaveLength(1)
|
|
1105
1128
|
|
|
1106
|
-
|
|
1107
|
-
|
|
1129
|
+
// Has document diff
|
|
1130
|
+
expect(messages[0].diff).toBeDefined()
|
|
1131
|
+
expect(messages[0].diff![pageId]).toBeDefined()
|
|
1108
1132
|
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
expect(messages[0].presence).toBeDefined()
|
|
1113
|
-
// First presence is always a Put (full record)
|
|
1114
|
-
expect(messages[0].presence![0]).toBe(RecordOpType.Put)
|
|
1115
|
-
expect((messages[0].presence![1] as any).cursor).toEqual({ x: 100, y: 100 })
|
|
1133
|
+
// Has presence
|
|
1134
|
+
expect(messages[0].presence).toBeDefined()
|
|
1135
|
+
})
|
|
1116
1136
|
})
|
|
1117
1137
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
cursor: { x: 0, y: 0 },
|
|
1124
|
-
} as any)
|
|
1125
|
-
flushThrottle()
|
|
1138
|
+
describe('presence mode integration with a real server', () => {
|
|
1139
|
+
interface IntegrationUser extends BaseRecord<'user', RecordId<IntegrationUser>> {
|
|
1140
|
+
name: string
|
|
1141
|
+
age: number
|
|
1142
|
+
}
|
|
1126
1143
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1144
|
+
interface IntegrationPresence extends BaseRecord<'presence', RecordId<IntegrationPresence>> {
|
|
1145
|
+
name: string
|
|
1146
|
+
age: number
|
|
1147
|
+
}
|
|
1131
1148
|
|
|
1132
|
-
|
|
1149
|
+
const IntegrationPresenceType = createRecordType<IntegrationPresence>('presence', {
|
|
1150
|
+
scope: 'presence',
|
|
1151
|
+
validator: { validate: (value) => value as IntegrationPresence },
|
|
1152
|
+
})
|
|
1133
1153
|
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
cursor: { x: 50, y: 50 },
|
|
1139
|
-
} as any)
|
|
1140
|
-
presence.set({
|
|
1141
|
-
id: 'presence:u1' as any,
|
|
1142
|
-
typeName: 'instance_presence',
|
|
1143
|
-
cursor: { x: 100, y: 100 },
|
|
1144
|
-
} as any)
|
|
1154
|
+
const IntegrationUserType = createRecordType<IntegrationUser>('user', {
|
|
1155
|
+
scope: 'document',
|
|
1156
|
+
validator: { validate: (value) => value as IntegrationUser },
|
|
1157
|
+
})
|
|
1145
1158
|
|
|
1146
|
-
|
|
1147
|
-
expect(socket.getSentMessages()).toHaveLength(0)
|
|
1159
|
+
type R = IntegrationUser | IntegrationPresence
|
|
1148
1160
|
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
|
|
1154
|
-
expect(messages).toHaveLength(1)
|
|
1155
|
-
expect(messages[0].presence).toBeDefined()
|
|
1156
|
-
// Subsequent presence updates should be Patches (only changed fields)
|
|
1157
|
-
expect(messages[0].presence![0]).toBe(RecordOpType.Patch)
|
|
1158
|
-
// The patch should contain the cursor update
|
|
1159
|
-
expect((messages[0].presence![1] as any).cursor).toBeDefined()
|
|
1160
|
-
})
|
|
1161
|
+
const integrationSchema = StoreSchema.create<R>({
|
|
1162
|
+
user: IntegrationUserType,
|
|
1163
|
+
presence: IntegrationPresenceType,
|
|
1164
|
+
})
|
|
1161
1165
|
|
|
1162
|
-
|
|
1163
|
-
const pageId = PageRecordType.createId()
|
|
1166
|
+
const disposables: Array<() => void> = []
|
|
1164
1167
|
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
}),
|
|
1172
|
-
])
|
|
1168
|
+
afterEach(() => {
|
|
1169
|
+
for (const dispose of disposables) {
|
|
1170
|
+
dispose()
|
|
1171
|
+
}
|
|
1172
|
+
disposables.length = 0
|
|
1173
|
+
})
|
|
1173
1174
|
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1175
|
+
class TestInstance {
|
|
1176
|
+
server: TestServer<R>
|
|
1177
|
+
socketPair: TestSocketPair<R>
|
|
1178
|
+
client: TLSyncClient<R>
|
|
1179
|
+
|
|
1180
|
+
hasLoaded = false
|
|
1181
|
+
|
|
1182
|
+
constructor(
|
|
1183
|
+
presenceSignal: Signal<IntegrationPresence | null>,
|
|
1184
|
+
presenceMode?: 'solo' | 'full'
|
|
1185
|
+
) {
|
|
1186
|
+
this.server = new TestServer(integrationSchema)
|
|
1187
|
+
this.socketPair = new TestSocketPair('test_presence_mode', this.server)
|
|
1188
|
+
this.socketPair.connect()
|
|
1189
|
+
|
|
1190
|
+
this.client = new TLSyncClient<R>({
|
|
1191
|
+
store: new Store({ schema: integrationSchema, props: {} }),
|
|
1192
|
+
socket: this.socketPair.clientSocket,
|
|
1193
|
+
onLoad: () => {
|
|
1194
|
+
this.hasLoaded = true
|
|
1195
|
+
},
|
|
1196
|
+
onSyncError: vi.fn((reason) => {
|
|
1197
|
+
throw new Error('onSyncError: ' + reason)
|
|
1198
|
+
}),
|
|
1199
|
+
presence: presenceSignal,
|
|
1200
|
+
presenceMode: presenceMode ? computed('', () => presenceMode) : undefined,
|
|
1201
|
+
})
|
|
1202
|
+
|
|
1203
|
+
disposables.push(() => {
|
|
1204
|
+
this.client.close()
|
|
1205
|
+
})
|
|
1206
|
+
}
|
|
1180
1207
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1208
|
+
flush() {
|
|
1209
|
+
this.server.flushDebouncingMessages()
|
|
1210
|
+
|
|
1211
|
+
while (this.socketPair.getNeedsFlushing()) {
|
|
1212
|
+
this.socketPair.flushClientSentEvents()
|
|
1213
|
+
this.socketPair.flushServerSentEvents()
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
it('[CP5] presence updates reach the server presence store when mode is full', () => {
|
|
1219
|
+
const presence = IntegrationPresenceType.create({ name: 'bob', age: 10 })
|
|
1220
|
+
const presenceSignal = atom('', presence)
|
|
1221
|
+
|
|
1222
|
+
const t = new TestInstance(presenceSignal, 'full')
|
|
1223
|
+
t.socketPair.connect()
|
|
1224
|
+
t.flush()
|
|
1225
|
+
|
|
1226
|
+
const session = t.server.room.sessions.values().next().value
|
|
1227
|
+
expect(session).toBeDefined()
|
|
1228
|
+
expect(session?.presenceId).toBeDefined()
|
|
1229
|
+
expect(t.server.room.presenceStore.get(session!.presenceId!)).toMatchObject({
|
|
1230
|
+
name: 'bob',
|
|
1231
|
+
age: 10,
|
|
1232
|
+
})
|
|
1233
|
+
|
|
1234
|
+
presenceSignal.set(IntegrationPresenceType.create({ name: 'bob', age: 11 }))
|
|
1235
|
+
t.flush()
|
|
1236
|
+
expect(t.server.room.presenceStore.get(session!.presenceId!)).toMatchObject({
|
|
1237
|
+
name: 'bob',
|
|
1238
|
+
age: 11,
|
|
1239
|
+
})
|
|
1240
|
+
|
|
1241
|
+
presenceSignal.set(IntegrationPresenceType.create({ name: 'bob', age: 12 }))
|
|
1242
|
+
t.flush()
|
|
1243
|
+
expect(t.server.room.presenceStore.get(session!.presenceId!)).toMatchObject({
|
|
1244
|
+
name: 'bob',
|
|
1245
|
+
age: 12,
|
|
1246
|
+
})
|
|
1247
|
+
})
|
|
1248
|
+
|
|
1249
|
+
it('[CP6] presence is only pushed once on connect when mode is solo', () => {
|
|
1250
|
+
const presence = IntegrationPresenceType.create({ name: 'bob', age: 10 })
|
|
1251
|
+
const presenceSignal = atom('', presence)
|
|
1183
1252
|
|
|
1184
|
-
|
|
1185
|
-
|
|
1253
|
+
const t = new TestInstance(presenceSignal, 'solo')
|
|
1254
|
+
t.socketPair.connect()
|
|
1255
|
+
t.flush()
|
|
1186
1256
|
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1257
|
+
const session = t.server.room.sessions.values().next().value
|
|
1258
|
+
expect(session).toBeDefined()
|
|
1259
|
+
expect(session?.presenceId).toBeDefined()
|
|
1260
|
+
expect(t.server.room.presenceStore.get(session!.presenceId!)).toMatchObject({
|
|
1261
|
+
name: 'bob',
|
|
1262
|
+
age: 10,
|
|
1263
|
+
})
|
|
1190
1264
|
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1265
|
+
presenceSignal.set(IntegrationPresenceType.create({ name: 'bob', age: 11 }))
|
|
1266
|
+
t.flush()
|
|
1267
|
+
expect(t.server.room.presenceStore.get(session!.presenceId!)).not.toMatchObject({
|
|
1268
|
+
name: 'bob',
|
|
1269
|
+
age: 11,
|
|
1270
|
+
})
|
|
1194
1271
|
|
|
1195
|
-
|
|
1196
|
-
|
|
1272
|
+
presenceSignal.set(IntegrationPresenceType.create({ name: 'bob', age: 12 }))
|
|
1273
|
+
t.flush()
|
|
1274
|
+
expect(t.server.room.presenceStore.get(session!.presenceId!)).not.toMatchObject({
|
|
1275
|
+
name: 'bob',
|
|
1276
|
+
age: 12,
|
|
1277
|
+
})
|
|
1278
|
+
})
|
|
1197
1279
|
})
|
|
1198
1280
|
})
|
|
1199
1281
|
|
|
1200
|
-
describe('
|
|
1201
|
-
|
|
1282
|
+
describe('21. receiving and rebasing (CR)', () => {
|
|
1283
|
+
it('[CR1] drops data events that arrive before the room connection is established', () => {
|
|
1202
1284
|
client = createClient()
|
|
1285
|
+
|
|
1286
|
+
const page = makePage('Early Page')
|
|
1287
|
+
socket.mockServerMessage({
|
|
1288
|
+
type: 'data',
|
|
1289
|
+
data: [{ type: 'patch', serverClock: 5, diff: { [page.id]: [RecordOpType.Put, page] } }],
|
|
1290
|
+
})
|
|
1291
|
+
vi.advanceTimersByTime(100)
|
|
1292
|
+
expect(store.has(page.id)).toBe(false)
|
|
1293
|
+
|
|
1294
|
+
// the event was dropped entirely, not buffered until the connect succeeds
|
|
1203
1295
|
socket.mockServerMessage(createConnectMessage())
|
|
1204
|
-
|
|
1296
|
+
vi.advanceTimersByTime(100)
|
|
1297
|
+
expect(client.isConnectedToRoom).toBe(true)
|
|
1298
|
+
expect(store.has(page.id)).toBe(false)
|
|
1205
1299
|
})
|
|
1206
1300
|
|
|
1207
|
-
it('
|
|
1208
|
-
|
|
1301
|
+
it('[CR1] processes legacy top-level patch events like buffered data events', () => {
|
|
1302
|
+
connectClient()
|
|
1303
|
+
|
|
1304
|
+
const page = makePage('Legacy Page')
|
|
1305
|
+
socket.mockServerMessage({
|
|
1306
|
+
type: 'patch',
|
|
1307
|
+
serverClock: 2,
|
|
1308
|
+
diff: { [page.id]: [RecordOpType.Put, page] },
|
|
1309
|
+
})
|
|
1310
|
+
vi.advanceTimersByTime(100)
|
|
1311
|
+
|
|
1312
|
+
expect(store.get(page.id)).toEqual(page)
|
|
1313
|
+
})
|
|
1314
|
+
|
|
1315
|
+
it('[CR2] preserves local speculative changes when the server patches other records', () => {
|
|
1316
|
+
connectClient()
|
|
1317
|
+
|
|
1318
|
+
// make a local change
|
|
1209
1319
|
const pageId = PageRecordType.createId()
|
|
1210
|
-
store.put([
|
|
1211
|
-
PageRecordType.create({
|
|
1212
|
-
id: pageId,
|
|
1213
|
-
name: 'Local Page',
|
|
1214
|
-
index: 'a1' as any,
|
|
1215
|
-
}),
|
|
1216
|
-
])
|
|
1320
|
+
store.put([PageRecordType.create({ id: pageId, name: 'Local Page', index: 'a1' as any })])
|
|
1217
1321
|
vi.advanceTimersByTime(100)
|
|
1218
1322
|
|
|
1219
|
-
//
|
|
1323
|
+
// receive a server patch for a different record
|
|
1220
1324
|
const serverPageId = PageRecordType.createId()
|
|
1221
1325
|
socket.mockServerMessage({
|
|
1222
1326
|
type: 'data',
|
|
@@ -1239,69 +1343,233 @@ describe('TLSyncClient', () => {
|
|
|
1239
1343
|
})
|
|
1240
1344
|
vi.advanceTimersByTime(100)
|
|
1241
1345
|
|
|
1242
|
-
//
|
|
1243
|
-
expect(store.has(pageId)).toBe(true)
|
|
1346
|
+
// both local and server pages should coexist
|
|
1244
1347
|
expect(store.get(pageId)?.name).toBe('Local Page')
|
|
1245
|
-
expect(store.has(serverPageId)).toBe(true)
|
|
1246
1348
|
expect(store.get(serverPageId)?.name).toBe('Server Page')
|
|
1247
1349
|
})
|
|
1248
|
-
})
|
|
1249
1350
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
socket.mockServerMessage(
|
|
1351
|
+
it('[CR2] does not generate new push requests for changes applied during a rebase', () => {
|
|
1352
|
+
connectClient()
|
|
1353
|
+
|
|
1354
|
+
const serverPage = makePage('Server Page')
|
|
1355
|
+
socket.mockServerMessage({
|
|
1356
|
+
type: 'data',
|
|
1357
|
+
data: [
|
|
1358
|
+
{
|
|
1359
|
+
type: 'patch',
|
|
1360
|
+
serverClock: 2,
|
|
1361
|
+
diff: { [serverPage.id]: [RecordOpType.Put, serverPage] },
|
|
1362
|
+
},
|
|
1363
|
+
],
|
|
1364
|
+
})
|
|
1365
|
+
vi.advanceTimersByTime(100)
|
|
1366
|
+
|
|
1367
|
+
expect(store.has(serverPage.id)).toBe(true)
|
|
1368
|
+
expect(getSentPushes()).toHaveLength(0)
|
|
1369
|
+
})
|
|
1370
|
+
|
|
1371
|
+
it('[CR3] a commit push_result confirms the pending push without changing the store', () => {
|
|
1372
|
+
connectClient()
|
|
1373
|
+
|
|
1374
|
+
const page = makePage('Local Page')
|
|
1375
|
+
store.put([page])
|
|
1376
|
+
vi.advanceTimersByTime(100)
|
|
1377
|
+
const push = getSentPushes()[0]
|
|
1378
|
+
expect(push).toBeDefined()
|
|
1379
|
+
|
|
1380
|
+
socket.mockServerMessage({
|
|
1381
|
+
type: 'data',
|
|
1382
|
+
data: [
|
|
1383
|
+
{ type: 'push_result', clientClock: push.clientClock, serverClock: 2, action: 'commit' },
|
|
1384
|
+
],
|
|
1385
|
+
})
|
|
1386
|
+
vi.advanceTimersByTime(100)
|
|
1387
|
+
expect(store.get(page.id)?.name).toBe('Local Page')
|
|
1388
|
+
|
|
1389
|
+
// the change is now confirmed: a reconnect neither reverts nor re-pushes it
|
|
1390
|
+
socket.mockConnectionStatus('offline')
|
|
1391
|
+
socket.mockConnectionStatus('online')
|
|
1255
1392
|
socket.clearSentMessages()
|
|
1393
|
+
socket.mockServerMessage(
|
|
1394
|
+
createConnectMessage({ hydrationType: 'wipe_presence', serverClock: 2, diff: {} })
|
|
1395
|
+
)
|
|
1396
|
+
vi.advanceTimersByTime(100)
|
|
1256
1397
|
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
cursor: { x: 100, y: 200 },
|
|
1261
|
-
} as TestRecord
|
|
1262
|
-
presence.set(presenceRecord)
|
|
1398
|
+
expect(store.get(page.id)?.name).toBe('Local Page')
|
|
1399
|
+
expect(getSentPushes()).toHaveLength(0)
|
|
1400
|
+
})
|
|
1263
1401
|
|
|
1264
|
-
|
|
1402
|
+
it('[CR3] a discard push_result drops the pending push and reverts the local change', () => {
|
|
1403
|
+
connectClient()
|
|
1404
|
+
|
|
1405
|
+
const page = makePage('Discarded Page')
|
|
1406
|
+
store.put([page])
|
|
1407
|
+
vi.advanceTimersByTime(100)
|
|
1408
|
+
const push = getSentPushes()[0]
|
|
1409
|
+
expect(store.has(page.id)).toBe(true)
|
|
1410
|
+
|
|
1411
|
+
socket.mockServerMessage({
|
|
1412
|
+
type: 'data',
|
|
1413
|
+
data: [
|
|
1414
|
+
{ type: 'push_result', clientClock: push.clientClock, serverClock: 2, action: 'discard' },
|
|
1415
|
+
],
|
|
1416
|
+
})
|
|
1417
|
+
vi.advanceTimersByTime(100)
|
|
1265
1418
|
|
|
1266
|
-
|
|
1267
|
-
const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
|
|
1268
|
-
const presenceMessages = messages.filter((msg) => msg.presence)
|
|
1269
|
-
expect(presenceMessages).toHaveLength(0)
|
|
1419
|
+
expect(store.has(page.id)).toBe(false)
|
|
1270
1420
|
})
|
|
1271
1421
|
|
|
1272
|
-
it(
|
|
1273
|
-
|
|
1274
|
-
client = createClient()
|
|
1275
|
-
socket.mockServerMessage(createConnectMessage())
|
|
1276
|
-
socket.clearSentMessages()
|
|
1422
|
+
it("[CR3] a rebaseWithDiff push_result applies the server's modified diff instead", () => {
|
|
1423
|
+
connectClient()
|
|
1277
1424
|
|
|
1278
|
-
const
|
|
1279
|
-
store.put([
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
name: 'Solo Page',
|
|
1283
|
-
index: 'a1' as any,
|
|
1284
|
-
}),
|
|
1285
|
-
])
|
|
1425
|
+
const page = makePage('Local Name')
|
|
1426
|
+
store.put([page])
|
|
1427
|
+
vi.advanceTimersByTime(100)
|
|
1428
|
+
const push = getSentPushes()[0]
|
|
1286
1429
|
|
|
1287
|
-
|
|
1430
|
+
const serverVersion = { ...page, name: 'Server Name' }
|
|
1431
|
+
socket.mockServerMessage({
|
|
1432
|
+
type: 'data',
|
|
1433
|
+
data: [
|
|
1434
|
+
{
|
|
1435
|
+
type: 'push_result',
|
|
1436
|
+
clientClock: push.clientClock,
|
|
1437
|
+
serverClock: 2,
|
|
1438
|
+
action: { rebaseWithDiff: { [page.id]: [RecordOpType.Put, serverVersion] } },
|
|
1439
|
+
},
|
|
1440
|
+
],
|
|
1441
|
+
})
|
|
1442
|
+
vi.advanceTimersByTime(100)
|
|
1288
1443
|
|
|
1289
|
-
|
|
1290
|
-
const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
|
|
1291
|
-
const docMessages = messages.filter((msg) => msg.diff && Object.keys(msg.diff).length > 0)
|
|
1292
|
-
expect(docMessages.length).toBeGreaterThan(0)
|
|
1444
|
+
expect(store.get(page.id)?.name).toBe('Server Name')
|
|
1293
1445
|
})
|
|
1294
|
-
})
|
|
1295
1446
|
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
socket.
|
|
1447
|
+
it('[CR4] resets the connection on a push_result with no pending pushes', () => {
|
|
1448
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
1449
|
+
connectClient()
|
|
1450
|
+
|
|
1451
|
+
socket.mockServerMessage({
|
|
1452
|
+
type: 'data',
|
|
1453
|
+
data: [{ type: 'push_result', clientClock: 0, serverClock: 2, action: 'commit' }],
|
|
1454
|
+
})
|
|
1455
|
+
|
|
1456
|
+
expect(consoleSpy).toHaveBeenCalled()
|
|
1457
|
+
expect(client.isConnectedToRoom).toBe(false)
|
|
1458
|
+
|
|
1459
|
+
// the reset restarts the socket, leading to a fresh connect attempt
|
|
1460
|
+
vi.advanceTimersByTime(100)
|
|
1461
|
+
expect(socket.getSentMessages().some((m) => m.type === 'connect')).toBe(true)
|
|
1462
|
+
|
|
1463
|
+
consoleSpy.mockRestore()
|
|
1464
|
+
})
|
|
1465
|
+
|
|
1466
|
+
it('[CR4] resets the connection on a push_result with a mismatched clientClock', () => {
|
|
1467
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
1468
|
+
connectClient()
|
|
1469
|
+
|
|
1470
|
+
// create a pending push with clientClock 0
|
|
1471
|
+
store.put([makePage()])
|
|
1472
|
+
vi.advanceTimersByTime(100)
|
|
1473
|
+
expect(getSentPushes()).toHaveLength(1)
|
|
1474
|
+
|
|
1475
|
+
socket.mockServerMessage({
|
|
1476
|
+
type: 'data',
|
|
1477
|
+
data: [{ type: 'push_result', clientClock: 999999, serverClock: 2, action: 'commit' }],
|
|
1478
|
+
})
|
|
1479
|
+
vi.advanceTimersByTime(100)
|
|
1480
|
+
|
|
1481
|
+
expect(consoleSpy).toHaveBeenCalled()
|
|
1482
|
+
expect(client.isConnectedToRoom).toBe(false)
|
|
1483
|
+
|
|
1484
|
+
consoleSpy.mockRestore()
|
|
1485
|
+
})
|
|
1486
|
+
|
|
1487
|
+
it("[CR5][CL2] advances lastServerClock to the last buffered event's serverClock and reuses it on reconnect", () => {
|
|
1488
|
+
connectClient({ serverClock: 1 })
|
|
1489
|
+
|
|
1490
|
+
socket.mockServerMessage({
|
|
1491
|
+
type: 'data',
|
|
1492
|
+
data: [
|
|
1493
|
+
{ type: 'patch', serverClock: 7, diff: {} },
|
|
1494
|
+
{ type: 'patch', serverClock: 12, diff: {} },
|
|
1495
|
+
],
|
|
1496
|
+
})
|
|
1497
|
+
vi.advanceTimersByTime(100)
|
|
1498
|
+
|
|
1499
|
+
socket.mockConnectionStatus('offline')
|
|
1500
|
+
socket.mockConnectionStatus('online')
|
|
1501
|
+
|
|
1502
|
+
const connectMsg = socket
|
|
1503
|
+
.getSentMessages()
|
|
1504
|
+
.find((m): m is TLConnectRequest => m.type === 'connect')
|
|
1505
|
+
expect(connectMsg).toBeDefined()
|
|
1506
|
+
expect(connectMsg!.lastServerClock).toBe(12)
|
|
1507
|
+
})
|
|
1508
|
+
|
|
1509
|
+
it('[CR6] a put equal to the stored record does not notify store listeners', () => {
|
|
1510
|
+
connectClient()
|
|
1511
|
+
|
|
1512
|
+
const page = makePage('Server Page')
|
|
1513
|
+
socket.mockServerMessage({
|
|
1514
|
+
type: 'data',
|
|
1515
|
+
data: [{ type: 'patch', serverClock: 2, diff: { [page.id]: [RecordOpType.Put, page] } }],
|
|
1516
|
+
})
|
|
1517
|
+
vi.advanceTimersByTime(100)
|
|
1518
|
+
expect(store.get(page.id)).toEqual(page)
|
|
1519
|
+
|
|
1520
|
+
const listener = vi.fn()
|
|
1521
|
+
const unsubscribe = store.listen(listener)
|
|
1522
|
+
|
|
1523
|
+
// a deep-equal (but referentially distinct) put is a no-op
|
|
1524
|
+
const equalCopy = JSON.parse(JSON.stringify(page))
|
|
1525
|
+
socket.mockServerMessage({
|
|
1526
|
+
type: 'data',
|
|
1527
|
+
data: [
|
|
1528
|
+
{ type: 'patch', serverClock: 3, diff: { [page.id]: [RecordOpType.Put, equalCopy] } },
|
|
1529
|
+
],
|
|
1530
|
+
})
|
|
1531
|
+
vi.advanceTimersByTime(100)
|
|
1532
|
+
|
|
1533
|
+
expect(listener).not.toHaveBeenCalled()
|
|
1534
|
+
expect(store.get(page.id)).toEqual(page)
|
|
1535
|
+
unsubscribe()
|
|
1301
1536
|
})
|
|
1302
1537
|
|
|
1303
|
-
it('
|
|
1304
|
-
|
|
1538
|
+
it('[CR6] patch and remove ops for missing records are skipped', () => {
|
|
1539
|
+
connectClient()
|
|
1540
|
+
|
|
1541
|
+
const missingPatchId = PageRecordType.createId()
|
|
1542
|
+
const missingRemoveId = PageRecordType.createId()
|
|
1543
|
+
const listener = vi.fn()
|
|
1544
|
+
const unsubscribe = store.listen(listener)
|
|
1545
|
+
|
|
1546
|
+
expect(() => {
|
|
1547
|
+
socket.mockServerMessage({
|
|
1548
|
+
type: 'data',
|
|
1549
|
+
data: [
|
|
1550
|
+
{
|
|
1551
|
+
type: 'patch',
|
|
1552
|
+
serverClock: 2,
|
|
1553
|
+
diff: {
|
|
1554
|
+
[missingPatchId]: [RecordOpType.Patch, { name: [ValueOpType.Put, 'x'] }],
|
|
1555
|
+
[missingRemoveId]: [RecordOpType.Remove],
|
|
1556
|
+
},
|
|
1557
|
+
},
|
|
1558
|
+
],
|
|
1559
|
+
})
|
|
1560
|
+
vi.advanceTimersByTime(100)
|
|
1561
|
+
}).not.toThrow()
|
|
1562
|
+
|
|
1563
|
+
expect(store.has(missingPatchId)).toBe(false)
|
|
1564
|
+
expect(store.has(missingRemoveId)).toBe(false)
|
|
1565
|
+
expect(listener).not.toHaveBeenCalled()
|
|
1566
|
+
unsubscribe()
|
|
1567
|
+
})
|
|
1568
|
+
|
|
1569
|
+
it('[CR6] handles update-then-delete of a server record correctly', () => {
|
|
1570
|
+
connectClient()
|
|
1571
|
+
|
|
1572
|
+
// create a page via server
|
|
1305
1573
|
const pageId = PageRecordType.createId()
|
|
1306
1574
|
socket.mockServerMessage({
|
|
1307
1575
|
type: 'data',
|
|
@@ -1312,11 +1580,7 @@ describe('TLSyncClient', () => {
|
|
|
1312
1580
|
diff: {
|
|
1313
1581
|
[pageId]: [
|
|
1314
1582
|
RecordOpType.Put,
|
|
1315
|
-
PageRecordType.create({
|
|
1316
|
-
id: pageId,
|
|
1317
|
-
name: 'Server Page',
|
|
1318
|
-
index: 'a1' as any,
|
|
1319
|
-
}),
|
|
1583
|
+
PageRecordType.create({ id: pageId, name: 'Server Page', index: 'a1' as any }),
|
|
1320
1584
|
],
|
|
1321
1585
|
},
|
|
1322
1586
|
},
|
|
@@ -1325,7 +1589,7 @@ describe('TLSyncClient', () => {
|
|
|
1325
1589
|
vi.advanceTimersByTime(100)
|
|
1326
1590
|
socket.clearSentMessages()
|
|
1327
1591
|
|
|
1328
|
-
//
|
|
1592
|
+
// update then delete
|
|
1329
1593
|
store.update(pageId, (p) => ({ ...p, name: 'Updated' }))
|
|
1330
1594
|
vi.advanceTimersByTime(100)
|
|
1331
1595
|
socket.clearSentMessages()
|
|
@@ -1333,19 +1597,20 @@ describe('TLSyncClient', () => {
|
|
|
1333
1597
|
store.remove([pageId])
|
|
1334
1598
|
vi.advanceTimersByTime(100)
|
|
1335
1599
|
|
|
1336
|
-
//
|
|
1600
|
+
// page should not exist locally
|
|
1337
1601
|
expect(store.has(pageId)).toBe(false)
|
|
1338
1602
|
|
|
1339
|
-
//
|
|
1340
|
-
const
|
|
1341
|
-
|
|
1342
|
-
(msg) => msg.diff && msg.diff[pageId] && msg.diff[pageId][0] === RecordOpType.Remove
|
|
1603
|
+
// should have sent a remove operation
|
|
1604
|
+
const removeMsg = getSentPushes().find(
|
|
1605
|
+
(msg) => msg.diff?.[pageId] && msg.diff[pageId][0] === RecordOpType.Remove
|
|
1343
1606
|
)
|
|
1344
1607
|
expect(removeMsg).toBeDefined()
|
|
1345
1608
|
})
|
|
1346
1609
|
|
|
1347
|
-
it('handles delete-then-recreate of server record with same
|
|
1348
|
-
|
|
1610
|
+
it('[CR6] handles delete-then-recreate of a server record with the same id', () => {
|
|
1611
|
+
connectClient()
|
|
1612
|
+
|
|
1613
|
+
// create a page via server
|
|
1349
1614
|
const pageId = PageRecordType.createId()
|
|
1350
1615
|
socket.mockServerMessage({
|
|
1351
1616
|
type: 'data',
|
|
@@ -1356,11 +1621,7 @@ describe('TLSyncClient', () => {
|
|
|
1356
1621
|
diff: {
|
|
1357
1622
|
[pageId]: [
|
|
1358
1623
|
RecordOpType.Put,
|
|
1359
|
-
PageRecordType.create({
|
|
1360
|
-
id: pageId,
|
|
1361
|
-
name: 'Original',
|
|
1362
|
-
index: 'a1' as any,
|
|
1363
|
-
}),
|
|
1624
|
+
PageRecordType.create({ id: pageId, name: 'Original', index: 'a1' as any }),
|
|
1364
1625
|
],
|
|
1365
1626
|
},
|
|
1366
1627
|
},
|
|
@@ -1369,24 +1630,19 @@ describe('TLSyncClient', () => {
|
|
|
1369
1630
|
vi.advanceTimersByTime(100)
|
|
1370
1631
|
socket.clearSentMessages()
|
|
1371
1632
|
|
|
1372
|
-
//
|
|
1633
|
+
// delete then recreate with same ID
|
|
1373
1634
|
store.remove([pageId])
|
|
1374
|
-
store.put([
|
|
1375
|
-
PageRecordType.create({
|
|
1376
|
-
id: pageId,
|
|
1377
|
-
name: 'Recreated',
|
|
1378
|
-
index: 'a1' as any,
|
|
1379
|
-
}),
|
|
1380
|
-
])
|
|
1635
|
+
store.put([PageRecordType.create({ id: pageId, name: 'Recreated', index: 'a1' as any })])
|
|
1381
1636
|
vi.advanceTimersByTime(100)
|
|
1382
1637
|
|
|
1383
|
-
//
|
|
1384
|
-
expect(store.has(pageId)).toBe(true)
|
|
1638
|
+
// page should exist with new name
|
|
1385
1639
|
expect(store.get(pageId)?.name).toBe('Recreated')
|
|
1386
1640
|
})
|
|
1387
1641
|
|
|
1388
|
-
it('sends patches (not full puts) for updates to server records', () => {
|
|
1389
|
-
|
|
1642
|
+
it('[CR6] sends patches (not full puts) for updates to server records', () => {
|
|
1643
|
+
connectClient()
|
|
1644
|
+
|
|
1645
|
+
// create a page via server
|
|
1390
1646
|
const pageId = PageRecordType.createId()
|
|
1391
1647
|
socket.mockServerMessage({
|
|
1392
1648
|
type: 'data',
|
|
@@ -1397,11 +1653,7 @@ describe('TLSyncClient', () => {
|
|
|
1397
1653
|
diff: {
|
|
1398
1654
|
[pageId]: [
|
|
1399
1655
|
RecordOpType.Put,
|
|
1400
|
-
PageRecordType.create({
|
|
1401
|
-
id: pageId,
|
|
1402
|
-
name: 'Server Name',
|
|
1403
|
-
index: 'a1' as any,
|
|
1404
|
-
}),
|
|
1656
|
+
PageRecordType.create({ id: pageId, name: 'Server Name', index: 'a1' as any }),
|
|
1405
1657
|
],
|
|
1406
1658
|
},
|
|
1407
1659
|
},
|
|
@@ -1410,18 +1662,47 @@ describe('TLSyncClient', () => {
|
|
|
1410
1662
|
vi.advanceTimersByTime(100)
|
|
1411
1663
|
socket.clearSentMessages()
|
|
1412
1664
|
|
|
1413
|
-
//
|
|
1665
|
+
// local user updates only the name
|
|
1414
1666
|
store.update(pageId, (p) => ({ ...p, name: 'Local Name' }))
|
|
1415
1667
|
vi.advanceTimersByTime(100)
|
|
1416
1668
|
|
|
1417
|
-
//
|
|
1418
|
-
const
|
|
1419
|
-
const pushWithPage = messages.find((msg) => msg.diff && msg.diff[pageId])
|
|
1669
|
+
// should send a Patch operation (not Put)
|
|
1670
|
+
const pushWithPage = getSentPushes().find((msg) => msg.diff?.[pageId])
|
|
1420
1671
|
expect(pushWithPage).toBeDefined()
|
|
1421
1672
|
|
|
1422
1673
|
const op = pushWithPage!.diff![pageId]
|
|
1423
1674
|
expect(op[0]).toBe(RecordOpType.Patch)
|
|
1424
1675
|
expect((op[1] as any).name).toBeDefined()
|
|
1425
1676
|
})
|
|
1677
|
+
|
|
1678
|
+
it('[CR7] invokes onCustomMessageReceived with this bound to null', () => {
|
|
1679
|
+
let captured: { thisArg: any; data: any } | undefined
|
|
1680
|
+
client = createClient({
|
|
1681
|
+
onCustomMessageReceived: function (this: any, data: any) {
|
|
1682
|
+
captured = { thisArg: this, data }
|
|
1683
|
+
},
|
|
1684
|
+
})
|
|
1685
|
+
socket.mockServerMessage(createConnectMessage())
|
|
1686
|
+
|
|
1687
|
+
const customData = { type: 'chat', message: 'Hello world' }
|
|
1688
|
+
socket.mockServerMessage({ type: 'custom', data: customData })
|
|
1689
|
+
|
|
1690
|
+
expect(captured).toBeDefined()
|
|
1691
|
+
expect(captured!.data).toEqual(customData)
|
|
1692
|
+
expect(captured!.thisArg).toBeNull()
|
|
1693
|
+
})
|
|
1694
|
+
|
|
1695
|
+
it('[CR7][CL7] tolerates omitted optional callbacks', () => {
|
|
1696
|
+
client = createClient({
|
|
1697
|
+
onCustomMessageReceived: undefined,
|
|
1698
|
+
onAfterConnect: undefined,
|
|
1699
|
+
})
|
|
1700
|
+
socket.mockServerMessage(createConnectMessage())
|
|
1701
|
+
expect(client.isConnectedToRoom).toBe(true)
|
|
1702
|
+
|
|
1703
|
+
expect(() => {
|
|
1704
|
+
socket.mockServerMessage({ type: 'custom', data: { hello: true } })
|
|
1705
|
+
}).not.toThrow()
|
|
1706
|
+
})
|
|
1426
1707
|
})
|
|
1427
1708
|
})
|