@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.
Files changed (49) hide show
  1. package/DOCS.md +662 -0
  2. package/README.md +9 -1
  3. package/dist-cjs/index.js +1 -1
  4. package/dist-cjs/lib/TLSocketRoom.js +4 -1
  5. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  6. package/dist-cjs/lib/TLSyncClient.js +2 -3
  7. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  8. package/dist-cjs/lib/TLSyncRoom.js +3 -2
  9. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  10. package/dist-esm/index.mjs +1 -1
  11. package/dist-esm/lib/TLSocketRoom.mjs +4 -1
  12. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  13. package/dist-esm/lib/TLSyncClient.mjs +2 -4
  14. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  15. package/dist-esm/lib/TLSyncRoom.mjs +3 -2
  16. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  17. package/package.json +9 -8
  18. package/src/lib/ClientWebSocketAdapter.test.ts +486 -508
  19. package/src/lib/DurableObjectSqliteSyncWrapper.test.ts +102 -0
  20. package/src/lib/InMemorySyncStorage.test.ts +294 -0
  21. package/src/lib/MicrotaskNotifier.test.ts +79 -254
  22. package/src/lib/{NodeSqliteSyncWrapper.test.ts → NodeSqliteWrapper.test.ts} +29 -25
  23. package/src/lib/SQLiteSyncStorage.test.ts +239 -0
  24. package/src/lib/ServerSocketAdapter.test.ts +60 -199
  25. package/src/lib/TLSocketRoom.ts +6 -1
  26. package/src/lib/TLSyncClient.test.ts +1127 -846
  27. package/src/lib/TLSyncClient.ts +4 -4
  28. package/src/lib/TLSyncRoom.ts +5 -2
  29. package/src/lib/TLSyncStorage.test.ts +225 -0
  30. package/src/lib/chunk.test.ts +372 -0
  31. package/src/lib/diff.test.ts +885 -0
  32. package/src/lib/interval.test.ts +43 -0
  33. package/src/lib/protocol.test.ts +43 -0
  34. package/src/lib/recordDiff.test.ts +159 -0
  35. package/src/test/TLSocketRoom.test.ts +1109 -894
  36. package/src/test/TLSyncRoom.test.ts +1735 -893
  37. package/src/test/storageContractSuite.ts +618 -0
  38. package/src/test/upgradeDowngrade.test.ts +137 -37
  39. package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
  40. package/src/lib/RoomSession.test.ts +0 -101
  41. package/src/lib/computeTombstonePruning.test.ts +0 -352
  42. package/src/lib/server-types.test.ts +0 -44
  43. package/src/test/InMemorySyncStorage.test.ts +0 -1780
  44. package/src/test/SQLiteSyncStorage.test.ts +0 -1485
  45. package/src/test/chunk.test.ts +0 -385
  46. package/src/test/customMessages.test.ts +0 -36
  47. package/src/test/diff.test.ts +0 -784
  48. package/src/test/presenceMode.test.ts +0 -149
  49. 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 { RecordOpType } from './diff'
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
- // Mock store and schema setup
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 record
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
- describe('Construction and Initialization', () => {
215
- it('creates a client with required configuration', () => {
216
- client = createClient()
217
- expect(client).toBeInstanceOf(TLSyncClient)
218
- expect(client.store).toBe(store)
219
- expect(client.socket).toBe(socket)
220
- expect(client.presenceState).toBe(presence)
221
- expect(client.presenceMode).toBe(presenceMode)
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
- it('initializes with correct default state', () => {
225
- client = createClient()
226
- expect(client.isConnectedToRoom).toBe(false)
227
- })
266
+ function getSentPushes() {
267
+ return socket.getSentMessages().filter((m): m is TLPushRequest<TestRecord> => m.type === 'push')
268
+ }
228
269
 
229
- it('sends connect message when socket is already online', () => {
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('waits for socket to come online before sending connect message', () => {
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('sets up window.tlsync for debugging', () => {
287
+ it('[CL2] connect message carries a fresh request id, the serialized schema, protocol version 8, and lastServerClock -1', () => {
246
288
  client = createClient()
247
- expect((globalThis as any).tlsync).toBe(client)
248
- })
249
-
250
- it('handles optional callbacks', () => {
251
- client = createClient({
252
- onCustomMessageReceived: undefined,
253
- onAfterConnect: undefined,
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
- describe('Connection Lifecycle', () => {
267
- beforeEach(() => {
298
+ it('[CL2] generates a new unique connectRequestId for each connection attempt', () => {
268
299
  client = createClient()
269
- socket.clearSentMessages()
270
- })
271
-
272
- it('handles successful connection', () => {
273
- const connectMessage = createConnectMessage()
300
+ const first = (socket.getLastSentMessage() as TLConnectRequest).connectRequestId
274
301
 
275
- socket.mockServerMessage(connectMessage)
302
+ socket.mockConnectionStatus('offline')
303
+ socket.mockConnectionStatus('online')
276
304
 
277
- expect(client.isConnectedToRoom).toBe(true)
278
- expect(onLoad).toHaveBeenCalledWith(client)
279
- expect(onAfterConnect).toHaveBeenCalledWith(client, { isReadonly: false })
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('handles connection with readonly mode', () => {
283
- const connectMessage = createConnectMessage({ isReadonly: true })
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
- socket.mockServerMessage(connectMessage)
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
- expect(onAfterConnect).toHaveBeenCalledWith(client, { isReadonly: true })
320
+ // it only fires once
321
+ socket.mockServerMessage(createConnectMessage())
322
+ expect(onLoad).toHaveBeenCalledTimes(1)
288
323
  })
289
324
 
290
- it('handles socket going offline', () => {
291
- // First connect
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
- // Then go offline
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
- it('sends ping messages periodically', () => {
306
- // Connect first
332
+ // the matching response still works afterwards
307
333
  socket.mockServerMessage(createConnectMessage())
308
-
309
- socket.clearSentMessages()
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('resets connection if no server interaction for too long', () => {
318
- const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
338
+ it('[CL5][CP3] wipe_presence reconnect keeps confirmed documents and re-pushes speculative changes', () => {
339
+ connectClient()
319
340
 
320
- // Connect first
321
- socket.mockServerMessage(createConnectMessage())
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
- // Advance time beyond health check threshold
324
- vi.advanceTimersByTime(15000) // Greater than MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION
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
- expect(consoleSpy).toHaveBeenCalledWith(
327
- expect.stringContaining("Haven't heard from the server in a while")
361
+ socket.mockConnectionStatus('online')
362
+ socket.clearSentMessages()
363
+ socket.mockServerMessage(
364
+ createConnectMessage({ hydrationType: 'wipe_presence', serverClock: 2, diff: {} })
328
365
  )
329
- expect(client.isConnectedToRoom).toBe(false)
366
+ vi.advanceTimersByTime(100)
330
367
 
331
- consoleSpy.mockRestore()
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
- describe('Message Handling', () => {
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
- it('handles custom messages', () => {
354
- const customData = { type: 'chat', message: 'Hello world' }
355
- const customMessage: Extract<TLSocketServerSentEvent<TestRecord>, { type: 'custom' }> = {
356
- type: 'custom',
357
- data: customData,
358
- }
359
-
360
- socket.mockServerMessage(customMessage)
361
-
362
- expect(onCustomMessageReceived).toHaveBeenCalledWith(customData)
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
- it('handles data messages and triggers rebase', () => {
366
- const dataMessage: Extract<TLSocketServerSentEvent<TestRecord>, { type: 'data' }> = {
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
- const dataMessage: Extract<TLSocketServerSentEvent<TestRecord>, { type: 'data' }> = {
398
- type: 'data',
399
- data: [],
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
- expect(consoleSpy).toHaveBeenCalledWith(
420
- expect.stringContaining('incompatibility error is legacy')
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
- consoleSpy.mockRestore()
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
- describe('Store Synchronization', () => {
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
- it('sends push requests for local changes', () => {
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
- it('throttles push request sending', () => {
471
- // Make multiple rapid changes without waiting for timers
472
- for (let i = 0; i < 5; i++) {
473
- const pageId = PageRecordType.createId()
474
- store.put([
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
- // After throttle resolves
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
- describe('Presence Management', () => {
493
- let presenceRecord: TestRecord
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
- beforeEach(() => {
496
- // Mock a presence record type
497
- presenceRecord = {
498
- id: 'presence:user1' as any,
499
- typeName: 'instance_presence',
500
- cursor: { x: 100, y: 200 },
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
- // Connect first
507
- socket.mockServerMessage(createConnectMessage())
508
- socket.clearSentMessages()
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('sends presence updates when presence changes', () => {
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
- vi.advanceTimersByTime(100)
496
+ socket.clearSentMessages()
514
497
 
515
- const messages = socket.getSentMessages()
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 messageWithPresence = pushMessages.find((msg) => msg.presence)
522
- expect(messageWithPresence).toBeDefined()
523
- expect(messageWithPresence!.presence).toBeDefined()
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('does not send presence when mode is solo', () => {
527
- presenceMode.set('solo')
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
- it('sends full presence on first update', () => {
535
- presence.set(presenceRecord)
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
- const messages = socket.getSentMessages()
539
- const pushMessages = messages.filter(
540
- (msg) => msg.type === 'push'
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('sends presence diffs for subsequent updates', () => {
550
- // Set initial presence
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
- // Update presence
556
- const updatedPresence = { ...presenceRecord, cursor: { x: 150, y: 250 } }
557
- presence.set(updatedPresence as TestRecord)
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
- presence.set(presenceRecord)
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
- expect(socket.getSentMessages()).toHaveLength(0)
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
- describe('Error Handling', () => {
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 rebase errors gracefully', () => {
586
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
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
- // Connect first
589
- socket.mockServerMessage(createConnectMessage())
574
+ it('[CL9] sends a ping every 5 seconds while connected', () => {
575
+ client = createClient()
576
+ socket.clearSentMessages()
590
577
 
591
- // Simulate a corrupted message that causes rebase to fail
592
- const malformedMessage: TLSocketServerSentDataEvent<TestRecord> = {
593
- type: 'push_result',
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
- type: 'data',
601
- data: [malformedMessage],
602
- })
582
+ socket.mockServerMessage(createConnectMessage())
583
+ socket.clearSentMessages()
603
584
 
604
- vi.advanceTimersByTime(100)
585
+ vi.advanceTimersByTime(5000)
586
+ expect(socket.getSentMessages().filter((m) => m.type === 'ping')).toHaveLength(1)
605
587
 
606
- expect(consoleSpy).toHaveBeenCalled()
607
- consoleSpy.mockRestore()
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('handles store corruption recovery', () => {
611
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
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
- // Clear initial messages
617
- socket.clearSentMessages()
599
+ // advance time beyond the health check threshold
600
+ vi.advanceTimersByTime(15000)
618
601
 
619
- // Mock store as corrupted
620
- vi.spyOn(store, 'isPossiblyCorrupted').mockReturnValue(true)
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
- // Try to make a change
623
- const pageId = PageRecordType.createId()
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
- vi.advanceTimersByTime(100)
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
- // Should not send new messages when store is corrupted
635
- expect(socket.getSentMessages()).toHaveLength(0)
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('handles didCancel function', () => {
641
- const didCancel = vi.fn(() => true)
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
- // Make a change that would trigger cancellation
645
- const pageId = PageRecordType.createId()
646
- store.put([
647
- PageRecordType.create({
648
- id: pageId,
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
- describe('Cleanup and Disposal', () => {
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('properly cleans up resources on close', () => {
649
+ it('[CL12] close disposes store listeners and timers', () => {
650
+ connectClient()
664
651
  client.close()
665
652
 
666
- // Should not throw errors after close
653
+ // no pushes for store changes, and no pings, after close
667
654
  expect(() => {
668
- const pageId = PageRecordType.createId()
669
- store.put([
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('cancels throttled functions on close', () => {
681
- // Reset and ensure we're connected
682
- socket.clearSentMessages()
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
- const pageId = PageRecordType.createId()
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
- // Should not send new messages after close (except the connect message that was already sent)
697
- const messagesBefore = socket.getSentMessages().length
698
- vi.advanceTimersByTime(100)
699
- expect(socket.getSentMessages().length).toBe(messagesBefore)
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('Connection Recovery', () => {
704
- beforeEach(() => {
705
- client = createClient()
706
- })
677
+ describe('20. pushing changes (CP)', () => {
678
+ it('[CP1] pushes user changes to document-scope records', () => {
679
+ connectClient()
707
680
 
708
- it('handles reconnection with speculative changes', () => {
709
- // Connect initially
710
- socket.mockServerMessage(createConnectMessage())
681
+ const page = makePage()
682
+ store.put([page])
683
+ vi.advanceTimersByTime(100)
711
684
 
712
- // Make local changes while online
713
- const pageId = PageRecordType.createId()
714
- store.put([
715
- PageRecordType.create({
716
- id: pageId,
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
- // Make more changes while offline
726
- store.update(pageId, (p) => ({ ...p, name: 'Updated Offline' }))
691
+ it('[CP1] does not push changes made with a remote source', () => {
692
+ connectClient()
727
693
 
728
- // Reconnect
729
- socket.mockConnectionStatus('online')
694
+ const page = makePage()
695
+ store.mergeRemoteChanges(() => {
696
+ store.put([page])
697
+ })
698
+ vi.advanceTimersByTime(100)
730
699
 
731
- // Should send a new connect message
732
- expect(socket.getSentMessages().some((msg) => msg.type === 'connect')).toBe(true)
700
+ expect(store.has(page.id)).toBe(true)
701
+ expect(socket.getSentMessages()).toHaveLength(0)
733
702
  })
734
703
 
735
- it('handles wipe_all reconnection', () => {
736
- // Connect initially with some data
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
- socket.mockServerMessage(
747
- createConnectMessage({
748
- diff: {
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
- // Should apply server data
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
- describe('Complex Scenarios', () => {
767
- beforeEach(() => {
768
- client = createClient()
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('handles rapid connection state changes', () => {
775
- // Rapidly change connection states
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
- expect(onSyncError).toHaveBeenCalledWith('Test error')
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('handles multiple simultaneous presence and document changes', () => {
784
- // Set presence
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
- // Make document changes
794
- const pageId = PageRecordType.createId()
795
- store.put([
796
- PageRecordType.create({
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
- // Should send messages
806
- const messages = socket.getSentMessages()
807
- expect(messages.length).toBeGreaterThan(0)
808
-
809
- // Check if any push messages contain presence or document data
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('handles server clock advancement correctly', () => {
818
- // Send multiple server messages with advancing clocks
819
- for (let i = 1; i <= 5; i++) {
820
- socket.mockServerMessage({
821
- type: 'data',
822
- data: [
823
- {
824
- type: 'patch',
825
- serverClock: i + 1,
826
- diff: {},
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
- // Should track the latest server clock internally
786
+ expect(socket.getSentMessages()).toHaveLength(0)
834
787
  })
835
- })
836
788
 
837
- describe('Offline and Reconnection Behavior', () => {
838
- beforeEach(() => {
839
- client = createClient()
840
- socket.mockServerMessage(createConnectMessage())
841
- socket.clearSentMessages()
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
- // Make changes while offline
850
- const pageId = PageRecordType.createId()
851
- store.put([
852
- PageRecordType.create({
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
- vi.advanceTimersByTime(100)
818
+ it('[CP6] does not push presence in solo mode', () => {
819
+ presenceMode.set('solo')
820
+ connectClient()
860
821
 
861
- // Should not have sent any messages
862
- expect(socket.getSentMessages()).toHaveLength(0)
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
- // But the page should exist locally
865
- expect(store.has(pageId)).toBe(true)
829
+ expect(getSentPushes().filter((msg) => msg.presence)).toHaveLength(0)
866
830
  })
867
831
 
868
- it('re-applies offline changes after reconnection and pushes them to server', () => {
869
- // Make a change while offline - this is a truly speculative change
870
- socket.mockConnectionStatus('offline')
871
- socket.clearSentMessages()
832
+ it('[CP6] still pushes document changes in solo mode', () => {
833
+ presenceMode.set('solo')
834
+ connectClient()
872
835
 
873
- const pageId = PageRecordType.createId()
874
- store.put([
875
- PageRecordType.create({
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
- // Page exists locally, no messages sent (offline)
884
- expect(store.has(pageId)).toBe(true)
885
- expect(socket.getSentMessages()).toHaveLength(0)
840
+ const docPushes = getSentPushes().filter((msg) => msg.diff?.[page.id])
841
+ expect(docPushes.length).toBeGreaterThan(0)
842
+ })
886
843
 
887
- // Come back online
888
- socket.mockConnectionStatus('online')
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
- // Get the connect message
891
- const connectMsg = socket.getSentMessages().find((m) => m.type === 'connect')
892
- expect(connectMsg).toBeDefined()
848
+ presenceMode.set('solo')
849
+ expect((client as any).fpsScheduler.targetFps).toBe(1)
893
850
 
894
- // Clear messages before server response
895
- socket.clearSentMessages()
851
+ presenceMode.set('full')
852
+ expect((client as any).fpsScheduler.targetFps).toBe(30)
853
+ })
896
854
 
897
- // Server responds with wipe_all (simulating fresh sync)
898
- socket.mockServerMessage(
899
- createConnectMessage({
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
- // The page should still exist locally
908
- expect(store.has(pageId)).toBe(true)
909
- expect(store.get(pageId)?.name).toBe('Offline Page')
859
+ store.put([makePage()])
860
+ vi.advanceTimersByTime(100)
910
861
 
911
- // The speculative change should have been pushed to the server
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
- describe('Push Coalescing (Multiple push() calls single network message)', () => {
923
- /**
924
- * These tests verify that multiple store changes (each triggering push())
925
- * get coalesced into a single TLPushRequest when the throttle fires.
926
- *
927
- * We enable RAF mode so the throttle actually delays execution,
928
- * allowing changes to accumulate before sending.
929
- */
930
-
931
- let rafCallbacks: Array<FrameRequestCallback>
932
- let rafId: number
933
-
934
- function flushOneRaf() {
935
- if (rafCallbacks.length > 0) {
936
- const callbacks = rafCallbacks.splice(0, rafCallbacks.length)
937
- const now = performance.now()
938
- for (const callback of callbacks) {
939
- callback(now)
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
- function flushThrottle() {
945
- // FpsScheduler needs: advance time + flush RAF (potentially twice for tick + flush)
946
- // Also need to clear any stale callbacks and keep flushing until stable
947
- for (let i = 0; i < 10 && rafCallbacks.length > 0; i++) {
948
- vi.advanceTimersByTime(100)
949
- flushOneRaf()
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
- beforeEach(() => {
954
- // Reset timer to a known state to avoid pollution from previous tests
955
- vi.setSystemTime(0)
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
- // Force RAF behavior so throttle actually delays
958
- // @ts-expect-error - testing flag
959
- globalThis.__FORCE_RAF_IN_TESTS__ = true
913
+ vi.stubGlobal('cancelAnimationFrame', (_id: number) => {})
960
914
 
961
- rafCallbacks = []
962
- rafId = 0
915
+ // Create client with RAF mode enabled
916
+ client = createClient()
917
+ socket.mockServerMessage(createConnectMessage())
963
918
 
964
- vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
965
- const id = ++rafId
966
- rafCallbacks.push(callback)
967
- return id
919
+ // Flush initial setup and clear
920
+ flushThrottle()
921
+ socket.clearSentMessages()
968
922
  })
969
923
 
970
- vi.stubGlobal('cancelAnimationFrame', (_id: number) => {})
924
+ afterEach(() => {
925
+ // @ts-expect-error - testing flag
926
+ delete globalThis.__FORCE_RAF_IN_TESTS__
927
+ vi.unstubAllGlobals()
928
+ })
971
929
 
972
- // Create client with RAF mode enabled
973
- client = createClient()
974
- socket.mockServerMessage(createConnectMessage())
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
- // Flush initial setup and clear
977
- flushThrottle()
978
- socket.clearSentMessages()
979
- })
947
+ // Before throttle fires: no messages should be sent yet
948
+ expect(socket.getSentMessages()).toHaveLength(0)
980
949
 
981
- afterEach(() => {
982
- // @ts-expect-error - testing flag
983
- delete globalThis.__FORCE_RAF_IN_TESTS__
984
- vi.unstubAllGlobals()
985
- })
950
+ // Flush the throttle
951
+ flushThrottle()
986
952
 
987
- it('coalesces 5 store.put() calls into 1 push request', () => {
988
- expect((client as any).isConnectedToRoom).toBe(true)
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
- pageIds.push(pageId)
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: `Page ${i}`,
999
- index: `a${i}` as any,
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
- // Before throttle fires: no messages should be sent yet
1005
- expect(socket.getSentMessages()).toHaveLength(0)
983
+ // Before throttle fires: no messages
984
+ expect(socket.getSentMessages()).toHaveLength(0)
1006
985
 
1007
- // Flush the throttle
1008
- flushThrottle()
986
+ // Flush the throttle
987
+ flushThrottle()
1009
988
 
1010
- // Should have sent exactly ONE push request containing all 5 pages
1011
- const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
1012
- expect(messages).toHaveLength(1)
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
- // The single message should contain all 5 page IDs
1016
- const diff = messages[0].diff || {}
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
- it('coalesces create + multiple updates into 1 push with final state', () => {
1024
- expect((client as any).isConnectedToRoom).toBe(true)
1000
+ it('[CP2] coalesces create + delete into no push at all (changes cancel out)', () => {
1001
+ const pageId = PageRecordType.createId()
1025
1002
 
1026
- const pageId = PageRecordType.createId()
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
- // Create and update the same record multiple times synchronously
1029
- store.put([
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
- // Flush the throttle
1044
- flushThrottle()
1016
+ // Flush the throttle
1017
+ flushThrottle()
1045
1018
 
1046
- // Should have exactly ONE push request
1047
- const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
1048
- expect(messages).toHaveLength(1)
1019
+ // add + remove = no-op, so nothing is sent
1020
+ expect(socket.getSentMessages()).toHaveLength(0)
1021
+ })
1049
1022
 
1050
- // The diff should have the final state
1051
- const diff = messages[0].diff!
1052
- expect(diff[pageId]).toBeDefined()
1053
- expect(diff[pageId][0]).toBe(RecordOpType.Put)
1054
- expect((diff[pageId][1] as any).name).toBe('Final Version')
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
- it('coalesces create + delete into no diff (cancels out)', () => {
1058
- const pageId = PageRecordType.createId()
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
- // Create then immediately delete
1061
- store.put([
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
- // Before throttle fires: no messages
1071
- expect(socket.getSentMessages()).toHaveLength(0)
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
- // Flush the throttle
1074
- flushThrottle()
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
- // Either no message, or message with empty/no diff for this page
1077
- const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
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
- it('coalesces multiple presence updates into 1 push with final presence (first is Put)', () => {
1086
- // Multiple presence updates synchronously - first presence ever sent
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
- // Before throttle fires: no messages
1104
- expect(socket.getSentMessages()).toHaveLength(0)
1125
+ // Should have exactly ONE push with both document and presence
1126
+ const messages = getSentPushes()
1127
+ expect(messages).toHaveLength(1)
1105
1128
 
1106
- // Flush the throttle
1107
- flushThrottle()
1129
+ // Has document diff
1130
+ expect(messages[0].diff).toBeDefined()
1131
+ expect(messages[0].diff![pageId]).toBeDefined()
1108
1132
 
1109
- // Should have exactly ONE push with final presence
1110
- const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
1111
- expect(messages).toHaveLength(1)
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
- it('sends subsequent presence updates as Patch after initial Put', () => {
1119
- // Send initial presence
1120
- presence.set({
1121
- id: 'presence:u1' as any,
1122
- typeName: 'instance_presence',
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
- // Verify first presence was a Put
1128
- const firstMessages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
1129
- expect(firstMessages).toHaveLength(1)
1130
- expect(firstMessages[0].presence![0]).toBe(RecordOpType.Put)
1144
+ interface IntegrationPresence extends BaseRecord<'presence', RecordId<IntegrationPresence>> {
1145
+ name: string
1146
+ age: number
1147
+ }
1131
1148
 
1132
- socket.clearSentMessages()
1149
+ const IntegrationPresenceType = createRecordType<IntegrationPresence>('presence', {
1150
+ scope: 'presence',
1151
+ validator: { validate: (value) => value as IntegrationPresence },
1152
+ })
1133
1153
 
1134
- // Now send multiple presence updates
1135
- presence.set({
1136
- id: 'presence:u1' as any,
1137
- typeName: 'instance_presence',
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
- // Before throttle fires: no messages
1147
- expect(socket.getSentMessages()).toHaveLength(0)
1159
+ type R = IntegrationUser | IntegrationPresence
1148
1160
 
1149
- // Flush the throttle
1150
- flushThrottle()
1151
-
1152
- // Should have exactly ONE push with final presence as a Patch
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
- it('coalesces document changes + presence into 1 push request', () => {
1163
- const pageId = PageRecordType.createId()
1166
+ const disposables: Array<() => void> = []
1164
1167
 
1165
- // Document change
1166
- store.put([
1167
- PageRecordType.create({
1168
- id: pageId,
1169
- name: 'Test Page',
1170
- index: 'a1' as any,
1171
- }),
1172
- ])
1168
+ afterEach(() => {
1169
+ for (const dispose of disposables) {
1170
+ dispose()
1171
+ }
1172
+ disposables.length = 0
1173
+ })
1173
1174
 
1174
- // Presence change
1175
- presence.set({
1176
- id: 'presence:u1' as any,
1177
- typeName: 'instance_presence',
1178
- cursor: { x: 42, y: 42 },
1179
- } as any)
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
- // Before throttle fires: no messages
1182
- expect(socket.getSentMessages()).toHaveLength(0)
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
- // Flush the throttle
1185
- flushThrottle()
1253
+ const t = new TestInstance(presenceSignal, 'solo')
1254
+ t.socketPair.connect()
1255
+ t.flush()
1186
1256
 
1187
- // Should have exactly ONE push with both document and presence
1188
- const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
1189
- expect(messages).toHaveLength(1)
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
- // Has document diff
1192
- expect(messages[0].diff).toBeDefined()
1193
- expect(messages[0].diff![pageId]).toBeDefined()
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
- // Has presence
1196
- expect(messages[0].presence).toBeDefined()
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('Rebase Behavior', () => {
1201
- beforeEach(() => {
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
- socket.clearSentMessages()
1296
+ vi.advanceTimersByTime(100)
1297
+ expect(client.isConnectedToRoom).toBe(true)
1298
+ expect(store.has(page.id)).toBe(false)
1205
1299
  })
1206
1300
 
1207
- it('preserves local changes when receiving server patches for other records', () => {
1208
- // Make a local change
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
- // Receive a server patch for a different record
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
- // Both local and server pages should coexist
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
- describe('Solo Mode FPS Optimization', () => {
1251
- it('does not send presence in solo mode', () => {
1252
- presenceMode.set('solo')
1253
- client = createClient()
1254
- socket.mockServerMessage(createConnectMessage())
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
- const presenceRecord = {
1258
- id: 'presence:user1' as any,
1259
- typeName: 'instance_presence',
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
- vi.advanceTimersByTime(2000)
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
- // Should not have sent any presence updates
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('still sends document changes in solo mode', () => {
1273
- presenceMode.set('solo')
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 pageId = PageRecordType.createId()
1279
- store.put([
1280
- PageRecordType.create({
1281
- id: pageId,
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
- vi.advanceTimersByTime(2000)
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
- // Should have sent the document change
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
- describe('Edge Cases', () => {
1297
- beforeEach(() => {
1298
- client = createClient()
1299
- socket.mockServerMessage(createConnectMessage())
1300
- socket.clearSentMessages()
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('handles update-then-delete of server record correctly', () => {
1304
- // Create a page via server
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
- // Update then delete
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
- // Page should not exist locally
1600
+ // page should not exist locally
1337
1601
  expect(store.has(pageId)).toBe(false)
1338
1602
 
1339
- // Should have sent a remove operation
1340
- const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
1341
- const removeMsg = messages.find(
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 ID', () => {
1348
- // Create a page via server
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
- // Delete then recreate with same ID
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
- // Page should exist with new name
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
- // Create a page via server
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
- // Local user updates only the name
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
- // Should send a Patch operation (not Put)
1418
- const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
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
  })