@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.
- package/DOCS.md +662 -0
- package/README.md +9 -1
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/TLSocketRoom.js +4 -1
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +2 -3
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +3 -2
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/TLSocketRoom.mjs +4 -1
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +2 -4
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +3 -2
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/package.json +9 -8
- package/src/lib/ClientWebSocketAdapter.test.ts +486 -508
- package/src/lib/DurableObjectSqliteSyncWrapper.test.ts +102 -0
- package/src/lib/InMemorySyncStorage.test.ts +294 -0
- package/src/lib/MicrotaskNotifier.test.ts +79 -254
- package/src/lib/{NodeSqliteSyncWrapper.test.ts → NodeSqliteWrapper.test.ts} +29 -25
- package/src/lib/SQLiteSyncStorage.test.ts +239 -0
- package/src/lib/ServerSocketAdapter.test.ts +60 -199
- package/src/lib/TLSocketRoom.ts +6 -1
- package/src/lib/TLSyncClient.test.ts +1127 -846
- package/src/lib/TLSyncClient.ts +4 -4
- package/src/lib/TLSyncRoom.ts +5 -2
- package/src/lib/TLSyncStorage.test.ts +225 -0
- package/src/lib/chunk.test.ts +372 -0
- package/src/lib/diff.test.ts +885 -0
- package/src/lib/interval.test.ts +43 -0
- package/src/lib/protocol.test.ts +43 -0
- package/src/lib/recordDiff.test.ts +159 -0
- package/src/test/TLSocketRoom.test.ts +1109 -894
- package/src/test/TLSyncRoom.test.ts +1735 -893
- package/src/test/storageContractSuite.ts +618 -0
- package/src/test/upgradeDowngrade.test.ts +137 -37
- package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
- package/src/lib/RoomSession.test.ts +0 -101
- package/src/lib/computeTombstonePruning.test.ts +0 -352
- package/src/lib/server-types.test.ts +0 -44
- package/src/test/InMemorySyncStorage.test.ts +0 -1780
- package/src/test/SQLiteSyncStorage.test.ts +0 -1485
- package/src/test/chunk.test.ts +0 -385
- package/src/test/customMessages.test.ts +0 -36
- package/src/test/diff.test.ts +0 -784
- package/src/test/presenceMode.test.ts +0 -149
- package/src/test/validation.test.ts +0 -186
|
@@ -218,7 +218,7 @@ class TestInstance {
|
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
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 = '
|
|
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.
|
|
413
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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('
|
|
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
|
-
})
|