@tldraw/sync-core 3.16.0-internal.51e99e128bd4 → 3.16.0-internal.71f83a8a571b
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/index.d.ts +14 -0
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +16 -3
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +11 -3
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +28 -2
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/protocol.js.map +1 -1
- package/dist-esm/index.d.mts +14 -0
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +16 -3
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +11 -3
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +28 -2
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/protocol.mjs.map +1 -1
- package/package.json +6 -6
- package/src/index.ts +1 -0
- package/src/lib/TLSocketRoom.ts +16 -2
- package/src/lib/TLSyncClient.ts +20 -3
- package/src/lib/TLSyncRoom.ts +31 -3
- package/src/lib/protocol.ts +1 -0
- package/src/test/TLSocketRoom.test.ts +141 -0
- package/src/test/TLSyncRoom.test.ts +195 -1
- package/src/test/TestSocketPair.ts +8 -0
- package/src/test/customMessages.test.ts +36 -0
|
@@ -4,6 +4,7 @@ import { vi } from 'vitest'
|
|
|
4
4
|
import { WebSocketMinimal } from '../lib/ServerSocketAdapter'
|
|
5
5
|
import { TLSocketRoom } from '../lib/TLSocketRoom'
|
|
6
6
|
import { RecordOpType } from '../lib/diff'
|
|
7
|
+
import { getTlsyncProtocolVersion } from '../lib/protocol'
|
|
7
8
|
|
|
8
9
|
function getStore() {
|
|
9
10
|
const schema = createTLSchema()
|
|
@@ -266,4 +267,144 @@ describe(TLSocketRoom, () => {
|
|
|
266
267
|
await addPage(room)
|
|
267
268
|
expect(called).toEqual(2)
|
|
268
269
|
})
|
|
270
|
+
|
|
271
|
+
it('sends custom messages', async () => {
|
|
272
|
+
const json = JSON.stringify
|
|
273
|
+
const store = getStore()
|
|
274
|
+
const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
|
|
275
|
+
|
|
276
|
+
const sessionId = 'test-session-1'
|
|
277
|
+
const send = vi.fn()
|
|
278
|
+
|
|
279
|
+
// Add session to the room
|
|
280
|
+
const mockSocket: WebSocketMinimal = { send, close: vi.fn(), readyState: WebSocket.OPEN }
|
|
281
|
+
room.handleSocketConnect({ sessionId, socket: mockSocket })
|
|
282
|
+
|
|
283
|
+
// Send connect message to establish the session
|
|
284
|
+
const connect = {
|
|
285
|
+
type: 'connect' as const,
|
|
286
|
+
connectRequestId: 'connect-1',
|
|
287
|
+
lastServerClock: 0,
|
|
288
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
289
|
+
schema: store.schema.serialize(),
|
|
290
|
+
}
|
|
291
|
+
room.handleSocketMessage(sessionId, json(connect))
|
|
292
|
+
|
|
293
|
+
room.sendCustomMessage(sessionId, 'hello world')
|
|
294
|
+
expect(send.mock.lastCall).toEqual([json({ type: 'custom', data: 'hello world' })])
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
describe('Room state resetting behavior', () => {
|
|
298
|
+
it('sets documentClock to oldRoom.clock + 1 when resetting room state', () => {
|
|
299
|
+
const store = getStore()
|
|
300
|
+
store.ensureStoreIsUsable()
|
|
301
|
+
const room = new TLSocketRoom({
|
|
302
|
+
initialSnapshot: store.getStoreSnapshot(),
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
// Load a snapshot to increment the clock
|
|
306
|
+
const snapshot = store.getStoreSnapshot()
|
|
307
|
+
room.loadSnapshot(snapshot)
|
|
308
|
+
|
|
309
|
+
const oldClock = room.getCurrentSnapshot().clock
|
|
310
|
+
expect(oldClock).toBe(1)
|
|
311
|
+
|
|
312
|
+
// Reset with a new snapshot
|
|
313
|
+
const newSnapshot = store.getStoreSnapshot()
|
|
314
|
+
room.loadSnapshot(newSnapshot)
|
|
315
|
+
|
|
316
|
+
const newSnapshotResult = room.getCurrentSnapshot()
|
|
317
|
+
expect(newSnapshotResult.documentClock).toBe(oldClock + 1)
|
|
318
|
+
expect(newSnapshotResult.clock).toBe(oldClock + 1)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('updates all documents lastChangedClock when resetting', () => {
|
|
322
|
+
const store = getStore()
|
|
323
|
+
store.ensureStoreIsUsable()
|
|
324
|
+
const room = new TLSocketRoom({
|
|
325
|
+
initialSnapshot: store.getStoreSnapshot(),
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// Get initial clock
|
|
329
|
+
const initialClock = room.getCurrentSnapshot().clock
|
|
330
|
+
|
|
331
|
+
// Reset with a new snapshot
|
|
332
|
+
const newSnapshot = store.getStoreSnapshot()
|
|
333
|
+
room.loadSnapshot(newSnapshot)
|
|
334
|
+
|
|
335
|
+
const result = room.getCurrentSnapshot()
|
|
336
|
+
expect(result.clock).toBe(initialClock + 1)
|
|
337
|
+
|
|
338
|
+
// All documents should have updated lastChangedClock
|
|
339
|
+
for (const doc of result.documents) {
|
|
340
|
+
expect(doc.lastChangedClock).toBe(initialClock + 1)
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('preserves existing tombstones with original clock values', async () => {
|
|
345
|
+
// Create a room with initial state
|
|
346
|
+
const store = getStore()
|
|
347
|
+
store.ensureStoreIsUsable()
|
|
348
|
+
const testPageId = PageRecordType.createId('test_page')
|
|
349
|
+
store.put([
|
|
350
|
+
PageRecordType.create({ id: testPageId, name: 'Test Page', index: ZERO_INDEX_KEY }),
|
|
351
|
+
])
|
|
352
|
+
const room = new TLSocketRoom({
|
|
353
|
+
initialSnapshot: store.getStoreSnapshot(),
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
await room.updateStore((store) => {
|
|
357
|
+
store.delete(testPageId)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
const deletionClock = room.getCurrentDocumentClock()
|
|
361
|
+
expect(room.getCurrentSnapshot().tombstones).toEqual({
|
|
362
|
+
[testPageId]: deletionClock,
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
room.loadSnapshot(room.getCurrentSnapshot())
|
|
366
|
+
|
|
367
|
+
expect(room.getCurrentSnapshot().tombstones).toEqual({
|
|
368
|
+
[testPageId]: deletionClock,
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
expect(room.getCurrentSnapshot().documentClock).toBe(deletionClock + 1)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('handles empty snapshot reset correctly', () => {
|
|
375
|
+
const store = getStore()
|
|
376
|
+
// Don't call ensureStoreIsUsable to get an empty snapshot
|
|
377
|
+
const room = new TLSocketRoom({
|
|
378
|
+
initialSnapshot: store.getStoreSnapshot(),
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
const oldClock = room.getCurrentSnapshot().clock
|
|
382
|
+
|
|
383
|
+
// Reset with empty snapshot
|
|
384
|
+
const emptySnapshot = store.getStoreSnapshot()
|
|
385
|
+
room.loadSnapshot(emptySnapshot)
|
|
386
|
+
|
|
387
|
+
const result = room.getCurrentSnapshot()
|
|
388
|
+
expect(result.documentClock).toBe(oldClock + 1)
|
|
389
|
+
expect(result.clock).toBe(oldClock + 1)
|
|
390
|
+
expect(result.documents).toHaveLength(0)
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('preserves schema when resetting room state', () => {
|
|
394
|
+
const store = getStore()
|
|
395
|
+
store.ensureStoreIsUsable()
|
|
396
|
+
const room = new TLSocketRoom({
|
|
397
|
+
initialSnapshot: store.getStoreSnapshot(),
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
const originalSchema = room.getCurrentSnapshot().schema
|
|
401
|
+
|
|
402
|
+
// Reset with a new snapshot
|
|
403
|
+
const newSnapshot = store.getStoreSnapshot()
|
|
404
|
+
room.loadSnapshot(newSnapshot)
|
|
405
|
+
|
|
406
|
+
const result = room.getCurrentSnapshot()
|
|
407
|
+
expect(result.schema).toEqual(originalSchema)
|
|
408
|
+
})
|
|
409
|
+
})
|
|
269
410
|
})
|
|
@@ -44,6 +44,17 @@ const records = [
|
|
|
44
44
|
].sort(compareById)
|
|
45
45
|
|
|
46
46
|
const makeSnapshot = (records: TLRecord[], others: Partial<RoomSnapshot> = {}) => ({
|
|
47
|
+
documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
|
|
48
|
+
clock: 0,
|
|
49
|
+
documentClock: 0,
|
|
50
|
+
...others,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Helper to create legacy snapshots without documentClock field
|
|
54
|
+
const makeLegacySnapshot = (
|
|
55
|
+
records: TLRecord[],
|
|
56
|
+
others: Partial<Omit<RoomSnapshot, 'documentClock'>> = {}
|
|
57
|
+
) => ({
|
|
47
58
|
documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
|
|
48
59
|
clock: 0,
|
|
49
60
|
...others,
|
|
@@ -158,7 +169,7 @@ describe('TLSyncRoom', () => {
|
|
|
158
169
|
|
|
159
170
|
const room = new TLSyncRoom({
|
|
160
171
|
schema,
|
|
161
|
-
snapshot: makeSnapshot([...records, oldArrow], {
|
|
172
|
+
snapshot: makeSnapshot([...records, oldArrow as any], {
|
|
162
173
|
schema: oldSerializedSchema,
|
|
163
174
|
}),
|
|
164
175
|
})
|
|
@@ -720,4 +731,187 @@ describe('isReadonly', () => {
|
|
|
720
731
|
}
|
|
721
732
|
`)
|
|
722
733
|
})
|
|
734
|
+
|
|
735
|
+
describe('Backward compatibility with existing snapshots', () => {
|
|
736
|
+
it('can load snapshot without documentClock field', () => {
|
|
737
|
+
const legacySnapshot = makeLegacySnapshot(records)
|
|
738
|
+
|
|
739
|
+
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
740
|
+
schema,
|
|
741
|
+
snapshot: legacySnapshot,
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
// Room should load successfully without errors
|
|
745
|
+
expect(room.getSnapshot().documents.length).toBe(2)
|
|
746
|
+
|
|
747
|
+
// documentClock should be calculated from existing data
|
|
748
|
+
const snapshot = room.getSnapshot()
|
|
749
|
+
expect(snapshot.documentClock).toBe(0) // max lastChangedClock from documents
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
it('calculates documentClock correctly from documents with different lastChangedClock values', () => {
|
|
753
|
+
const legacySnapshot = makeLegacySnapshot(records, {
|
|
754
|
+
documents: [
|
|
755
|
+
{ state: records[0], lastChangedClock: 5 },
|
|
756
|
+
{ state: records[1], lastChangedClock: 10 },
|
|
757
|
+
],
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
761
|
+
schema,
|
|
762
|
+
snapshot: legacySnapshot,
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
const snapshot = room.getSnapshot()
|
|
766
|
+
expect(snapshot.documentClock).toBe(10) // max lastChangedClock
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
it('calculates documentClock correctly from tombstones', () => {
|
|
770
|
+
const legacySnapshot = makeLegacySnapshot(records, {
|
|
771
|
+
documents: [{ state: records[0], lastChangedClock: 3 }],
|
|
772
|
+
tombstones: {
|
|
773
|
+
'shape:deleted1': 7,
|
|
774
|
+
'shape:deleted2': 12,
|
|
775
|
+
},
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
779
|
+
schema,
|
|
780
|
+
snapshot: legacySnapshot,
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
const snapshot = room.getSnapshot()
|
|
784
|
+
expect(snapshot.documentClock).toBe(12) // max of document (3) and tombstones (7, 12)
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
it('handles empty snapshot gracefully', () => {
|
|
788
|
+
const emptyLegacySnapshot = makeLegacySnapshot([], {
|
|
789
|
+
documents: [],
|
|
790
|
+
tombstones: {},
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
794
|
+
schema,
|
|
795
|
+
snapshot: emptyLegacySnapshot,
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
const snapshot = room.getSnapshot()
|
|
799
|
+
expect(snapshot.documentClock).toBe(0) // no documents or tombstones
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
it('handles snapshot with only tombstones', () => {
|
|
803
|
+
const legacySnapshot = makeLegacySnapshot([], {
|
|
804
|
+
documents: [],
|
|
805
|
+
tombstones: {
|
|
806
|
+
'shape:deleted1': 5,
|
|
807
|
+
'shape:deleted2': 8,
|
|
808
|
+
},
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
812
|
+
schema,
|
|
813
|
+
snapshot: legacySnapshot,
|
|
814
|
+
})
|
|
815
|
+
|
|
816
|
+
const snapshot = room.getSnapshot()
|
|
817
|
+
expect(snapshot.documentClock).toBe(8) // max tombstone clock
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
it('preserves explicit documentClock when present', () => {
|
|
821
|
+
const snapshotWithDocumentClock = makeSnapshot(records, {
|
|
822
|
+
documentClock: 15,
|
|
823
|
+
})
|
|
824
|
+
|
|
825
|
+
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
826
|
+
schema,
|
|
827
|
+
snapshot: snapshotWithDocumentClock,
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
const snapshot = room.getSnapshot()
|
|
831
|
+
expect(snapshot.documentClock).toBe(15) // should preserve explicit value
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
describe('Document clock initialization logic', () => {
|
|
835
|
+
it('sets documentClock to room clock when migrations run (didIncrementClock = true)', () => {
|
|
836
|
+
// Create a schema with a migration that will update documents
|
|
837
|
+
const schemaWithMigration = createTLSchema({
|
|
838
|
+
migrations: [
|
|
839
|
+
{
|
|
840
|
+
sequenceId: 'test-migration',
|
|
841
|
+
retroactive: false,
|
|
842
|
+
sequence: [
|
|
843
|
+
{
|
|
844
|
+
id: 'test-migration/1',
|
|
845
|
+
scope: 'record',
|
|
846
|
+
filter: (record: any) => record.typeName === 'document',
|
|
847
|
+
up: (record: any) => {
|
|
848
|
+
// Modify the record to trigger clock increment
|
|
849
|
+
return { ...record, meta: { ...record.meta, migrated: true } }
|
|
850
|
+
},
|
|
851
|
+
},
|
|
852
|
+
],
|
|
853
|
+
},
|
|
854
|
+
],
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
const snapshotWithDocumentClock = makeSnapshot(records, {
|
|
858
|
+
documentClock: 5,
|
|
859
|
+
clock: 10,
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
const onDataChange = vi.fn()
|
|
863
|
+
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
864
|
+
schema: schemaWithMigration,
|
|
865
|
+
snapshot: snapshotWithDocumentClock,
|
|
866
|
+
onDataChange,
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
// Migration should have run, incrementing the clock
|
|
870
|
+
expect(room.getSnapshot().clock).toBe(11)
|
|
871
|
+
expect(room.getSnapshot().documentClock).toBe(11)
|
|
872
|
+
expect(onDataChange).toHaveBeenCalled()
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
it('preserves documentClock from snapshot when no migrations run (didIncrementClock = false)', () => {
|
|
876
|
+
const snapshotWithDocumentClock = makeSnapshot(records, {
|
|
877
|
+
documentClock: 15,
|
|
878
|
+
clock: 20,
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
const onDataChange = vi.fn()
|
|
882
|
+
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
883
|
+
schema,
|
|
884
|
+
snapshot: snapshotWithDocumentClock,
|
|
885
|
+
onDataChange,
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
// No migrations should have run
|
|
889
|
+
expect(room.getSnapshot().documentClock).toBe(15)
|
|
890
|
+
expect(room.getSnapshot().clock).toBe(20)
|
|
891
|
+
expect(onDataChange).not.toHaveBeenCalled()
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
it('calculates documentClock when snapshot lacks documentClock field (didIncrementClock = false)', () => {
|
|
895
|
+
const legacySnapshot = makeLegacySnapshot(records, {
|
|
896
|
+
documents: [
|
|
897
|
+
{ state: records[0], lastChangedClock: 7 },
|
|
898
|
+
{ state: records[1], lastChangedClock: 12 },
|
|
899
|
+
],
|
|
900
|
+
clock: 15,
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
const onDataChange = vi.fn()
|
|
904
|
+
const room = new TLSyncRoom<TLRecord, undefined>({
|
|
905
|
+
schema,
|
|
906
|
+
snapshot: legacySnapshot,
|
|
907
|
+
onDataChange,
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
// Should calculate from existing data
|
|
911
|
+
expect(room.getSnapshot().documentClock).toBe(12) // max lastChangedClock
|
|
912
|
+
expect(room.getSnapshot().clock).toBe(15) // clock from snapshot
|
|
913
|
+
expect(onDataChange).not.toHaveBeenCalled()
|
|
914
|
+
})
|
|
915
|
+
})
|
|
916
|
+
})
|
|
723
917
|
})
|
|
@@ -28,6 +28,14 @@ export class TestSocketPair<R extends UnknownRecord> {
|
|
|
28
28
|
})
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
async flushAllEvents() {
|
|
32
|
+
await Promise.resolve()
|
|
33
|
+
while (this.getNeedsFlushing()) {
|
|
34
|
+
this.flushClientSentEvents()
|
|
35
|
+
this.flushServerSentEvents()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
getNeedsFlushing() {
|
|
32
40
|
return this.serverSentEventQueue.length > 0 || this.clientSentEventQueue.length > 0
|
|
33
41
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { atom, createRecordType, Store, StoreSchema, UnknownRecord } from 'tldraw'
|
|
2
|
+
import { TLSyncClient } from '../lib/TLSyncClient'
|
|
3
|
+
import { TestServer } from './TestServer'
|
|
4
|
+
import { TestSocketPair } from './TestSocketPair'
|
|
5
|
+
|
|
6
|
+
type Presence = UnknownRecord & { typeName: 'presence' }
|
|
7
|
+
const presenceType = createRecordType<Presence>('presence', {
|
|
8
|
+
scope: 'presence',
|
|
9
|
+
validator: { validate: (record) => record as Presence },
|
|
10
|
+
})
|
|
11
|
+
const schema = StoreSchema.create<Presence>({ presence: presenceType })
|
|
12
|
+
|
|
13
|
+
describe('custom messages', () => {
|
|
14
|
+
it('sends a message to a client', async () => {
|
|
15
|
+
const store = new Store({ schema, props: {} })
|
|
16
|
+
|
|
17
|
+
const sessionId = 'test-session-1'
|
|
18
|
+
const server = new TestServer(schema)
|
|
19
|
+
const socketPair = new TestSocketPair(sessionId, server)
|
|
20
|
+
socketPair.connect()
|
|
21
|
+
|
|
22
|
+
const onCustomMessageReceived = vi.fn()
|
|
23
|
+
new TLSyncClient({
|
|
24
|
+
store,
|
|
25
|
+
socket: socketPair.clientSocket,
|
|
26
|
+
onCustomMessageReceived,
|
|
27
|
+
onLoad: vi.fn(),
|
|
28
|
+
onSyncError: vi.fn(),
|
|
29
|
+
presence: atom('', null),
|
|
30
|
+
})
|
|
31
|
+
await socketPair.flushAllEvents()
|
|
32
|
+
server.room.sendCustomMessage(sessionId, 'hello world')
|
|
33
|
+
await socketPair.flushAllEvents()
|
|
34
|
+
expect(onCustomMessageReceived.mock.lastCall).toEqual(['hello world'])
|
|
35
|
+
})
|
|
36
|
+
})
|