@tldraw/sync-core 5.2.0-canary.4a316fdfb2bb → 5.2.0-canary.4e00299c3e28

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
@@ -218,7 +218,7 @@ class TestInstance {
218
218
  }
219
219
  }
220
220
 
221
- test('the server can handle receiving v1 stuff from the client', () => {
221
+ it('[MG2] the server can handle receiving v1 stuff from the client', () => {
222
222
  const t = new TestInstance()
223
223
  t.oldSocketPair.connect()
224
224
  t.newSocketPair.connect()
@@ -244,7 +244,7 @@ test('the server can handle receiving v1 stuff from the client', () => {
244
244
  expect(t.newClient.store.get(user.id as any)).not.toMatchObject({ name: 'bob', age: 10 })
245
245
  })
246
246
 
247
- test('the server can send v2 stuff to the v1 client', () => {
247
+ it('[MG1] the server can send v2 stuff to the v1 client', () => {
248
248
  const t = new TestInstance()
249
249
  t.oldSocketPair.connect()
250
250
  t.newSocketPair.connect()
@@ -269,7 +269,7 @@ test('the server can send v2 stuff to the v1 client', () => {
269
269
  })
270
270
  })
271
271
 
272
- test('the server will run schema migrations on a snapshot', () => {
272
+ it('[RC3] the server will run schema migrations on a snapshot', () => {
273
273
  const bob = UserV1.create({ name: 'bob', age: 10 })
274
274
  // joe will be deleted
275
275
  const joe = UserV1.create({ name: 'joe', age: 10 })
@@ -298,7 +298,7 @@ test('the server will run schema migrations on a snapshot', () => {
298
298
  expect(snapshot.documents.find((u: any) => u.state.name === 'steve')).toBeDefined()
299
299
  })
300
300
 
301
- test('clients will receive updates from a snapshot migration upon connection', () => {
301
+ it('[MG1] clients will receive updates from a snapshot migration upon connection', () => {
302
302
  const t = new TestInstance()
303
303
  t.oldSocketPair.connect()
304
304
  t.newSocketPair.connect()
@@ -347,7 +347,7 @@ test('clients will receive updates from a snapshot migration upon connection', (
347
347
  })
348
348
  })
349
349
 
350
- test('out-of-date clients will receive incompatibility errors', () => {
350
+ it('[HS3] out-of-date clients will receive incompatibility errors', () => {
351
351
  const v3server = new TestServer(schemaV3)
352
352
 
353
353
  const id = 'test_upgrade_v2'
@@ -365,7 +365,7 @@ test('out-of-date clients will receive incompatibility errors', () => {
365
365
  expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
366
366
  })
367
367
 
368
- test('clients using an out-of-date protocol will receive compatibility errors', () => {
368
+ it('[HS2] clients using an out-of-date protocol will receive compatibility errors', () => {
369
369
  const actualVersion = getTlsyncProtocolVersion()
370
370
  mockGetTlsyncProtocolVersion.mockReturnValue(actualVersion + 1)
371
371
  try {
@@ -390,14 +390,12 @@ test('clients using an out-of-date protocol will receive compatibility errors',
390
390
  }
391
391
  })
392
392
 
393
- // this can be deleted when the protocol gets to v7
394
- test('v5 special case should allow connections', () => {
395
- const actualVersion = getTlsyncProtocolVersion()
396
- if (actualVersion > 6) return
397
-
393
+ // protocol v5 is treated as v6: accepted, but with the legacy rejection scheme
394
+ // and without string-append support
395
+ it('[HS2] v5 special case should allow connections', () => {
398
396
  const v2server = new TestServer(schemaV2)
399
397
 
400
- const id = 'test_upgrade_v3'
398
+ const id = 'test_upgrade_v5'
401
399
  const socket = mockSocket()
402
400
 
403
401
  v2server.room.handleNewSession({ sessionId: id, socket, meta: undefined, isReadonly: false })
@@ -409,24 +407,21 @@ test('v5 special case should allow connections', () => {
409
407
  schema: schemaV2.serialize(),
410
408
  })
411
409
 
412
- expect(socket.sendMessage).toHaveBeenCalledWith({
413
- connectRequestId: 'test',
414
- diff: {},
415
- hydrationType: 'wipe_all',
416
- protocolVersion: 6,
417
- schema: {
418
- schemaVersion: 2,
419
- sequences: {
420
- 'com.tldraw.user': 1,
421
- },
422
- },
423
- serverClock: 1,
410
+ expect(socket.close).not.toHaveBeenCalled()
411
+ expect((socket.sendMessage as Mock).mock.calls[0][0]).toMatchObject({
424
412
  type: 'connect',
413
+ connectRequestId: 'test',
414
+ protocolVersion: getTlsyncProtocolVersion(),
425
415
  isReadonly: false,
426
- } satisfies TLSocketServerSentEvent<RV2>)
416
+ })
417
+
418
+ const session = v2server.room.sessions.get(id) as any
419
+ expect(session?.state).toBe('connected')
420
+ expect(session?.requiresLegacyRejection).toBe(true)
421
+ expect(session?.supportsStringAppend).toBe(false)
427
422
  })
428
423
 
429
- test('clients using a too-new protocol will receive compatibility errors', () => {
424
+ it('[HS2] clients using a too-new protocol will receive compatibility errors', () => {
430
425
  const v2server = new TestServer(schemaV2)
431
426
 
432
427
  const id = 'test_upgrade_v3'
@@ -444,7 +439,7 @@ test('clients using a too-new protocol will receive compatibility errors', () =>
444
439
  expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.SERVER_TOO_OLD)
445
440
  })
446
441
 
447
- test('when the client is too new it cannot connect', () => {
442
+ it('[HS3] when the client is too new it cannot connect', () => {
448
443
  const steve = UserV1.create({ id: UserV1.createId('steve'), name: 'steve', age: 23 })
449
444
  const jeff = UserV1.create({ id: UserV1.createId('jeff'), name: 'jeff', age: 23 })
450
445
  const annie = UserV1.create({ id: UserV1.createId('annie'), name: 'annie', age: 23 })
@@ -607,8 +602,8 @@ describe('when the client is too old', () => {
607
602
  data = setup()
608
603
  })
609
604
 
610
- it('allows deletions from v1 client', () => {
611
- data.v2Server.room.handleMessage(data.v2Id, {
605
+ it('[MG2] allows deletions from v1 client', () => {
606
+ data.v2Server.room.handleMessage(data.v1Id, {
612
607
  type: 'push',
613
608
  clientClock: 1,
614
609
  diff: {
@@ -616,7 +611,7 @@ describe('when the client is too old', () => {
616
611
  },
617
612
  })
618
613
 
619
- expect(data.v2SendMessage).toHaveBeenCalledWith({
614
+ expect(data.v1SendMessage).toHaveBeenCalledWith({
620
615
  type: 'data',
621
616
  data: [
622
617
  {
@@ -627,9 +622,22 @@ describe('when the client is too old', () => {
627
622
  },
628
623
  ],
629
624
  } satisfies TLSocketServerSentEvent<RV2>)
625
+
626
+ expect(data.v2SendMessage).toHaveBeenCalledWith({
627
+ type: 'data',
628
+ data: [
629
+ {
630
+ type: 'patch',
631
+ diff: {
632
+ [data.steve.id]: [RecordOpType.Remove],
633
+ },
634
+ serverClock: 11,
635
+ },
636
+ ],
637
+ } satisfies TLSocketServerSentEvent<RV2>)
630
638
  })
631
639
 
632
- it('can handle patches from older clients', () => {
640
+ it('[MG2] can handle patches from older clients', () => {
633
641
  data.v2Server.room.handleMessage(data.v1Id, {
634
642
  type: 'push',
635
643
  clientClock: 1,
@@ -670,7 +678,7 @@ describe('when the client is too old', () => {
670
678
  })
671
679
  })
672
680
 
673
- describe('migration failure during push (TLSyncError handling)', () => {
681
+ describe('migration failure (TLSyncError handling)', () => {
674
682
  let consoleSpy: ReturnType<typeof vi.spyOn>
675
683
  beforeEach(() => {
676
684
  consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -755,7 +763,7 @@ describe('migration failure during push (TLSyncError handling)', () => {
755
763
  }
756
764
  )
757
765
 
758
- it('rejects session when addDocument UP migration throws during PUT', () => {
766
+ it('[MG2] rejects session when addDocument up migration throws during put', () => {
759
767
  const server = new TestServer(serverSchemaWithThrowingMigration)
760
768
 
761
769
  const sessionId = 'failing-migration-session'
@@ -798,7 +806,7 @@ describe('migration failure during push (TLSyncError handling)', () => {
798
806
  expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
799
807
  })
800
808
 
801
- it('successfully migrates records when migration does not throw', () => {
809
+ it('[MG2] successfully migrates records when migration does not throw', () => {
802
810
  const server = new TestServer(serverSchemaWithThrowingMigration)
803
811
 
804
812
  const sessionId = 'successful-migration-session'
@@ -846,7 +854,7 @@ describe('migration failure during push (TLSyncError handling)', () => {
846
854
  expect(storedRecord.nickname).toBe('VALIDNAME') // Uppercase from migration
847
855
  })
848
856
 
849
- it('rejects session when patchDocument DOWN migration throws', () => {
857
+ it('[MG2] rejects session when patchDocument down migration throws', () => {
850
858
  // Create a server with a document that has a nickname that will fail DOWN migration
851
859
  const existingUser = UserWithNicknameType.create({
852
860
  id: UserWithNicknameType.createId('existing_down_fail'),
@@ -897,7 +905,7 @@ describe('migration failure during push (TLSyncError handling)', () => {
897
905
  expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
898
906
  })
899
907
 
900
- it('rejects session when patchDocument UP migration throws (after successful DOWN migration)', () => {
908
+ it('[MG2] rejects session when patchDocument up migration throws (after successful down migration)', () => {
901
909
  // Create a server with a document that will pass DOWN migration but fail UP migration
902
910
  const existingUser = UserWithNicknameType.create({
903
911
  id: UserWithNicknameType.createId('existing_up_fail'),
@@ -950,6 +958,84 @@ describe('migration failure during push (TLSyncError handling)', () => {
950
958
  // Session should be rejected due to UP migration failure during patch
951
959
  expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
952
960
  })
961
+
962
+ it('[MG1] rejects only the affected session when down migration fails during broadcast', () => {
963
+ const server = new TestServer(serverSchemaWithThrowingMigration)
964
+
965
+ // An old-schema client whose outgoing diffs require down migration
966
+ const oldId = 'broadcast-old-schema-session'
967
+ const oldSocket = mockSocket<UserWithNickname>()
968
+ server.room.handleNewSession({
969
+ sessionId: oldId,
970
+ socket: oldSocket as any,
971
+ meta: undefined,
972
+ isReadonly: false,
973
+ })
974
+ server.room.handleMessage(oldId, {
975
+ type: 'connect',
976
+ connectRequestId: 'test',
977
+ lastServerClock: 0,
978
+ protocolVersion: getTlsyncProtocolVersion(),
979
+ schema: clientSchemaWithoutNickname.serialize(),
980
+ })
981
+
982
+ // An up-to-date client that pushes a record whose down migration throws
983
+ const newId = 'broadcast-new-schema-session'
984
+ const newSocket = mockSocket<UserWithNickname>()
985
+ server.room.handleNewSession({
986
+ sessionId: newId,
987
+ socket: newSocket as any,
988
+ meta: undefined,
989
+ isReadonly: false,
990
+ })
991
+ server.room.handleMessage(newId, {
992
+ type: 'connect',
993
+ connectRequestId: 'test',
994
+ lastServerClock: 0,
995
+ protocolVersion: getTlsyncProtocolVersion(),
996
+ schema: serverSchemaWithThrowingMigration.serialize(),
997
+ })
998
+
999
+ expect(server.room.sessions.get(oldId)?.state).toBe('connected')
1000
+ expect(server.room.sessions.get(newId)?.state).toBe('connected')
1001
+ ;(oldSocket.sendMessage as Mock).mockClear()
1002
+ ;(newSocket.sendMessage as Mock).mockClear()
1003
+
1004
+ const failingRecord = UserWithNicknameType.create({
1005
+ id: UserWithNicknameType.createId('broadcast_fail'),
1006
+ name: 'BroadcastUser',
1007
+ nickname: 'FAIL_DOWN_MIGRATION', // triggers the DOWN throw when broadcast to old client
1008
+ })
1009
+
1010
+ server.room.handleMessage(newId, {
1011
+ type: 'push',
1012
+ clientClock: 1,
1013
+ diff: {
1014
+ [failingRecord.id]: [RecordOpType.Put, failingRecord as any],
1015
+ },
1016
+ })
1017
+
1018
+ // The push itself succeeds: the record is stored and the pusher gets a commit
1019
+ expect(server.storage.documents.get(failingRecord.id)?.state).toMatchObject({
1020
+ nickname: 'FAIL_DOWN_MIGRATION',
1021
+ })
1022
+ expect(newSocket.close).not.toHaveBeenCalled()
1023
+ expect(newSocket.sendMessage).toHaveBeenCalledWith({
1024
+ type: 'data',
1025
+ data: [
1026
+ {
1027
+ type: 'push_result',
1028
+ action: 'commit',
1029
+ clientClock: 1,
1030
+ serverClock: 1,
1031
+ },
1032
+ ],
1033
+ })
1034
+
1035
+ // Only the old-schema session is rejected, because broadcasting the put to it
1036
+ // requires a down migration that throws
1037
+ expect(oldSocket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
1038
+ })
953
1039
  })
954
1040
 
955
1041
  describe('when the client is the same version', () => {
@@ -1045,7 +1131,21 @@ describe('when the client is the same version', () => {
1045
1131
  data = setup()
1046
1132
  })
1047
1133
 
1048
- it('sends minimal patches', () => {
1134
+ it('[MG3] accepts client with same schema version', () => {
1135
+ // both clients connected with a deep-equal (but not reference-equal) serialized
1136
+ // schema; they are accepted (see setup assertions) and share the server's schema
1137
+ // object so migration is skipped entirely
1138
+ const aSession = data.v2Server.room.sessions.get(data.aId) as any
1139
+ const bSession = data.v2Server.room.sessions.get(data.bId) as any
1140
+ expect(aSession?.state).toBe('connected')
1141
+ expect(bSession?.state).toBe('connected')
1142
+ expect(aSession?.serializedSchema).toBe(data.v2Server.room.serializedSchema)
1143
+ expect(bSession?.serializedSchema).toBe(data.v2Server.room.serializedSchema)
1144
+ expect(aSession?.requiresDownMigrations).toBe(false)
1145
+ expect(bSession?.requiresDownMigrations).toBe(false)
1146
+ })
1147
+
1148
+ it('[MG3] sends minimal patches', () => {
1049
1149
  data.v2Server.room.handleMessage(data.aId, {
1050
1150
  type: 'push',
1051
1151
  clientClock: 1,
@@ -1,270 +0,0 @@
1
- import { DatabaseSync } from 'node:sqlite'
2
- import { RecordId, StoreSchema } from 'tldraw'
3
- import { beforeEach, describe, expect, it } from 'vitest'
4
- import { NodeSqliteWrapper } from './NodeSqliteWrapper'
5
- import { migrateSqliteSyncStorage, SQLiteSyncStorage } from './SQLiteSyncStorage'
6
- import { RoomSnapshot } from './TLSyncRoom'
7
-
8
- // Simple record type for testing
9
- interface TestRecord {
10
- id: RecordId<TestRecord>
11
- typeName: string
12
- name: string
13
- value: number
14
- }
15
-
16
- type ID = RecordId<TestRecord>
17
-
18
- // SQLiteSyncStorage uses multi-statement DDL in constructor which node:sqlite
19
- // doesn't support (prepare() only handles one statement). We need to initialize
20
- // the tables separately before creating the storage.
21
- function initializeTables(db: DatabaseSync) {
22
- migrateSqliteSyncStorage(new NodeSqliteWrapper(db))
23
- }
24
-
25
- const defaultSnapshot: RoomSnapshot = {
26
- documents: [],
27
- tombstones: {},
28
- schema: StoreSchema.create({}).serialize(),
29
- }
30
-
31
- describe('NodeSqliteSyncWrapper + SQLiteSyncStorage integration', () => {
32
- let db: DatabaseSync
33
- let sql: NodeSqliteWrapper
34
- let storage: SQLiteSyncStorage<TestRecord>
35
-
36
- beforeEach(() => {
37
- db = new DatabaseSync(':memory:')
38
- initializeTables(db)
39
- sql = new NodeSqliteWrapper(db)
40
- // Pass undefined snapshot since we already initialized the tables
41
- storage = new SQLiteSyncStorage<TestRecord>({
42
- sql,
43
- snapshot: defaultSnapshot,
44
- })
45
- })
46
-
47
- describe('basic operations', () => {
48
- it('can set and get a record', () => {
49
- const record: TestRecord = { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 }
50
-
51
- storage.transaction((txn) => {
52
- txn.set(record.id, record)
53
- })
54
-
55
- const result = storage.transaction((txn) => txn.get('test-1'))
56
- expect(result.result).toEqual(record)
57
- })
58
-
59
- it('can delete a record', () => {
60
- const record: TestRecord = { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 }
61
-
62
- storage.transaction((txn) => {
63
- txn.set(record.id, record)
64
- })
65
-
66
- storage.transaction((txn) => {
67
- txn.delete('test-1')
68
- })
69
-
70
- const result = storage.transaction((txn) => txn.get('test-1'))
71
- expect(result.result).toBeUndefined()
72
- })
73
-
74
- it('increments clock on mutations', () => {
75
- expect(storage.getClock()).toBe(0)
76
-
77
- storage.transaction((txn) => {
78
- txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
79
- })
80
-
81
- expect(storage.getClock()).toBe(1)
82
-
83
- storage.transaction((txn) => {
84
- txn.set('test-2', { id: 'test-2' as ID, typeName: 'test', name: 'Bob', value: 200 })
85
- })
86
-
87
- expect(storage.getClock()).toBe(2)
88
- })
89
-
90
- it('only increments clock once per transaction', () => {
91
- storage.transaction((txn) => {
92
- txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
93
- txn.set('test-2', { id: 'test-2' as ID, typeName: 'test', name: 'Bob', value: 200 })
94
- txn.set('test-3', { id: 'test-3' as ID, typeName: 'test', name: 'Charlie', value: 300 })
95
- })
96
-
97
- expect(storage.getClock()).toBe(1)
98
- })
99
- })
100
-
101
- describe('iteration', () => {
102
- it('can iterate over entries', () => {
103
- storage.transaction((txn) => {
104
- txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
105
- txn.set('test-2', { id: 'test-2' as ID, typeName: 'test', name: 'Bob', value: 200 })
106
- })
107
-
108
- const entries = storage.transaction((txn) => [...txn.entries()])
109
- expect(entries.result).toHaveLength(2)
110
- expect(entries.result.map(([id]) => id).sort()).toEqual(['test-1', 'test-2'])
111
- })
112
-
113
- it('can iterate over keys', () => {
114
- storage.transaction((txn) => {
115
- txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
116
- txn.set('test-2', { id: 'test-2' as ID, typeName: 'test', name: 'Bob', value: 200 })
117
- })
118
-
119
- const keys = storage.transaction((txn) => [...txn.keys()])
120
- expect(keys.result.sort()).toEqual(['test-1', 'test-2'])
121
- })
122
-
123
- it('can iterate over values', () => {
124
- storage.transaction((txn) => {
125
- txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
126
- txn.set('test-2', { id: 'test-2' as ID, typeName: 'test', name: 'Bob', value: 200 })
127
- })
128
-
129
- const values = storage.transaction((txn) => [...txn.values()])
130
- expect(values.result).toHaveLength(2)
131
- expect(values.result.map((v) => v.name).sort()).toEqual(['Alice', 'Bob'])
132
- })
133
- })
134
-
135
- describe('snapshots', () => {
136
- it('can get a snapshot', () => {
137
- storage.transaction((txn) => {
138
- txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
139
- })
140
-
141
- const snapshot = storage.getSnapshot()
142
-
143
- expect(snapshot.documentClock).toBe(1)
144
- expect(snapshot.documents).toHaveLength(1)
145
- expect(snapshot.documents[0].state).toEqual({
146
- id: 'test-1',
147
- typeName: 'test',
148
- name: 'Alice',
149
- value: 100,
150
- })
151
- expect(snapshot.schema).toEqual(defaultSnapshot.schema)
152
- })
153
-
154
- it('includes tombstones in snapshot', () => {
155
- storage.transaction((txn) => {
156
- txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
157
- })
158
-
159
- storage.transaction((txn) => {
160
- txn.delete('test-1')
161
- })
162
-
163
- const snapshot = storage.getSnapshot()
164
-
165
- expect(snapshot.documents).toHaveLength(0)
166
- expect(snapshot.tombstones).toEqual({ 'test-1': 2 })
167
- })
168
- })
169
-
170
- describe('getChangesSince', () => {
171
- it('returns changes since a given clock', () => {
172
- storage.transaction((txn) => {
173
- txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
174
- })
175
-
176
- const clock1 = storage.getClock()
177
-
178
- storage.transaction((txn) => {
179
- txn.set('test-2', { id: 'test-2' as ID, typeName: 'test', name: 'Bob', value: 200 })
180
- })
181
-
182
- const changes = storage.transaction((txn) => txn.getChangesSince(clock1))
183
-
184
- expect(changes.result?.diff.puts).toHaveProperty('test-2')
185
- expect(changes.result?.diff.puts).not.toHaveProperty('test-1')
186
- })
187
-
188
- it('includes deletes in changes', () => {
189
- storage.transaction((txn) => {
190
- txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
191
- })
192
-
193
- const clock1 = storage.getClock()
194
-
195
- storage.transaction((txn) => {
196
- txn.delete('test-1')
197
- })
198
-
199
- const changes = storage.transaction((txn) => txn.getChangesSince(clock1))
200
-
201
- expect(changes.result?.diff.deletes).toContain('test-1')
202
- })
203
-
204
- it('returns undefined when no changes', () => {
205
- storage.transaction((txn) => {
206
- txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
207
- })
208
-
209
- const clock = storage.getClock()
210
-
211
- const changes = storage.transaction((txn) => txn.getChangesSince(clock))
212
-
213
- expect(changes.result).toBeUndefined()
214
- })
215
- })
216
-
217
- describe('transaction rollback', () => {
218
- it('rolls back on error', () => {
219
- storage.transaction((txn) => {
220
- txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
221
- })
222
-
223
- expect(() => {
224
- storage.transaction((txn) => {
225
- txn.set('test-2', { id: 'test-2' as ID, typeName: 'test', name: 'Bob', value: 200 })
226
- throw new Error('oops')
227
- })
228
- }).toThrow('oops')
229
-
230
- // test-2 should not exist
231
- const result = storage.transaction((txn) => txn.get('test-2'))
232
- expect(result.result).toBeUndefined()
233
-
234
- // test-1 should still exist
235
- const result1 = storage.transaction((txn) => txn.get('test-1'))
236
- expect(result1.result?.name).toBe('Alice')
237
- })
238
- })
239
-
240
- describe('onChange', () => {
241
- it('notifies on changes', async () => {
242
- const changes: { id?: string; documentClock: number }[] = []
243
- storage.onChange((change) => {
244
- changes.push(change)
245
- })
246
-
247
- storage.transaction((txn) => {
248
- txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
249
- })
250
-
251
- // onChange uses microtask, so wait for it
252
- await new Promise((resolve) => setTimeout(resolve, 0))
253
-
254
- expect(changes).toHaveLength(1)
255
- expect(changes[0].documentClock).toBe(1)
256
- })
257
- })
258
-
259
- describe('hasBeenInitialized', () => {
260
- it('returns true for initialized storage', () => {
261
- expect(SQLiteSyncStorage.hasBeenInitialized(sql)).toBe(true)
262
- })
263
-
264
- it('returns false for uninitialized storage', () => {
265
- const freshDb = new DatabaseSync(':memory:')
266
- const freshWrapper = new NodeSqliteWrapper(freshDb)
267
- expect(SQLiteSyncStorage.hasBeenInitialized(freshWrapper)).toBe(false)
268
- })
269
- })
270
- })
@@ -1,101 +0,0 @@
1
- import { SerializedSchema } from '@tldraw/store'
2
- import { TLRecord } from '@tldraw/tlschema'
3
- import { describe, expect, it, vi } from 'vitest'
4
- import {
5
- RoomSession,
6
- RoomSessionState,
7
- SESSION_IDLE_TIMEOUT,
8
- SESSION_REMOVAL_WAIT_TIME,
9
- SESSION_START_WAIT_TIME,
10
- } from './RoomSession'
11
- import { TLRoomSocket } from './TLSyncRoom'
12
-
13
- // Mock socket implementation for testing
14
- const createMockSocket = (): TLRoomSocket<TLRecord> => ({
15
- isOpen: true,
16
- sendMessage: vi.fn(),
17
- close: vi.fn(),
18
- })
19
-
20
- // Mock serialized schema for testing
21
- const mockSerializedSchema: SerializedSchema = {
22
- schemaVersion: 1,
23
- storeVersion: 1,
24
- recordVersions: {},
25
- }
26
-
27
- describe('RoomSession timeout constants', () => {
28
- it('should have logical timeout ordering for session management', () => {
29
- // This test ensures the timeout constants have a logical relationship
30
- // that supports proper session lifecycle management:
31
- // - Quick cleanup for disconnected sessions
32
- // - Reasonable wait time for initial connections
33
- // - Patient timeout for active sessions
34
- expect(SESSION_REMOVAL_WAIT_TIME).toBeLessThan(SESSION_START_WAIT_TIME)
35
- expect(SESSION_START_WAIT_TIME).toBeLessThan(SESSION_IDLE_TIMEOUT)
36
- })
37
- })
38
-
39
- describe('RoomSession state transitions', () => {
40
- const baseSessionData = {
41
- sessionId: 'test-session-id',
42
- presenceId: 'test-presence-id',
43
- socket: createMockSocket(),
44
- meta: { userId: 'test-user' },
45
- isReadonly: false,
46
- requiresLegacyRejection: false,
47
- }
48
-
49
- it('should support complete session lifecycle', () => {
50
- // Test that sessions can progress through their full lifecycle
51
- // This validates the discriminated union works correctly for state management
52
-
53
- // Start in awaiting state
54
- const initialSession: RoomSession<TLRecord, { userId: string }> = {
55
- state: RoomSessionState.AwaitingConnectMessage,
56
- sessionStartTime: Date.now(),
57
- supportsStringAppend: true,
58
- ...baseSessionData,
59
- }
60
-
61
- // Progress to connected state (simulates successful connection)
62
- const connectedSession: RoomSession<TLRecord, { userId: string }> = {
63
- state: RoomSessionState.Connected,
64
- sessionId: initialSession.sessionId,
65
- presenceId: initialSession.presenceId,
66
- socket: initialSession.socket,
67
- meta: initialSession.meta,
68
- isReadonly: initialSession.isReadonly,
69
- requiresLegacyRejection: initialSession.requiresLegacyRejection,
70
- serializedSchema: mockSerializedSchema,
71
- requiresDownMigrations: false,
72
- supportsStringAppend: true,
73
- lastInteractionTime: Date.now(),
74
- debounceTimer: null,
75
- outstandingDataMessages: [],
76
- }
77
-
78
- // End in awaiting removal state (simulates disconnection)
79
- const removalSession: RoomSession<TLRecord, { userId: string }> = {
80
- state: RoomSessionState.AwaitingRemoval,
81
- sessionId: connectedSession.sessionId,
82
- presenceId: connectedSession.presenceId,
83
- socket: connectedSession.socket,
84
- meta: connectedSession.meta,
85
- isReadonly: connectedSession.isReadonly,
86
- requiresLegacyRejection: connectedSession.requiresLegacyRejection,
87
- supportsStringAppend: connectedSession.supportsStringAppend,
88
- cancellationTime: Date.now(),
89
- }
90
-
91
- // Verify session ID remains consistent across state changes
92
- // This is critical for tracking sessions through their lifecycle
93
- expect(initialSession.sessionId).toBe(connectedSession.sessionId)
94
- expect(connectedSession.sessionId).toBe(removalSession.sessionId)
95
-
96
- // Verify state-specific properties are present when expected
97
- expect(initialSession.state).toBe(RoomSessionState.AwaitingConnectMessage)
98
- expect(connectedSession.state).toBe(RoomSessionState.Connected)
99
- expect(removalSession.state).toBe(RoomSessionState.AwaitingRemoval)
100
- })
101
- })