@tldraw/sync-core 4.3.0-canary.c7096a59bf3b → 4.3.0-canary.d039f3a1ab8f
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 +239 -57
- package/dist-cjs/index.js +7 -3
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +289 -0
- package/dist-cjs/lib/InMemorySyncStorage.js.map +7 -0
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/TLSocketRoom.js +117 -69
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +7 -0
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +357 -688
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-cjs/lib/TLSyncStorage.js +76 -0
- package/dist-cjs/lib/TLSyncStorage.js.map +7 -0
- package/dist-cjs/lib/recordDiff.js +52 -0
- package/dist-cjs/lib/recordDiff.js.map +7 -0
- package/dist-esm/index.d.mts +239 -57
- package/dist-esm/index.mjs +12 -5
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +274 -0
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +7 -0
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/TLSocketRoom.mjs +121 -70
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +7 -0
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +370 -702
- package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
- package/dist-esm/lib/TLSyncStorage.mjs +56 -0
- package/dist-esm/lib/TLSyncStorage.mjs.map +7 -0
- package/dist-esm/lib/recordDiff.mjs +32 -0
- package/dist-esm/lib/recordDiff.mjs.map +7 -0
- package/package.json +6 -6
- package/src/index.ts +21 -3
- package/src/lib/InMemorySyncStorage.ts +357 -0
- package/src/lib/RoomSession.test.ts +1 -0
- package/src/lib/RoomSession.ts +2 -0
- package/src/lib/TLSocketRoom.ts +228 -114
- package/src/lib/TLSyncClient.ts +12 -0
- package/src/lib/TLSyncRoom.ts +473 -913
- package/src/lib/TLSyncStorage.ts +216 -0
- package/src/lib/recordDiff.ts +73 -0
- package/src/test/InMemorySyncStorage.test.ts +1674 -0
- package/src/test/TLSocketRoom.test.ts +255 -49
- package/src/test/TLSyncRoom.test.ts +1021 -533
- package/src/test/TestServer.ts +12 -1
- package/src/test/customMessages.test.ts +1 -1
- package/src/test/presenceMode.test.ts +6 -6
- package/src/test/upgradeDowngrade.test.ts +282 -8
- package/src/test/validation.test.ts +10 -10
- package/src/test/pruneTombstones.test.ts +0 -178
package/src/test/TestServer.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { StoreSchema, UnknownRecord } from '@tldraw/store'
|
|
2
|
+
import { InMemorySyncStorage } from '../lib/InMemorySyncStorage'
|
|
2
3
|
import { RoomSnapshot, TLSyncRoom } from '../lib/TLSyncRoom'
|
|
3
4
|
import { TestSocketPair } from './TestSocketPair'
|
|
4
5
|
|
|
5
6
|
export class TestServer<R extends UnknownRecord, P = unknown> {
|
|
6
7
|
room: TLSyncRoom<R, undefined>
|
|
8
|
+
storage: InMemorySyncStorage<R>
|
|
7
9
|
constructor(schema: StoreSchema<R, P>, snapshot?: RoomSnapshot) {
|
|
8
|
-
|
|
10
|
+
// Use provided snapshot or create an empty one with the current schema
|
|
11
|
+
this.storage = new InMemorySyncStorage<R>({
|
|
12
|
+
snapshot: snapshot ?? {
|
|
13
|
+
documents: [],
|
|
14
|
+
clock: 0,
|
|
15
|
+
documentClock: 0,
|
|
16
|
+
schema: schema.serialize(),
|
|
17
|
+
},
|
|
18
|
+
})
|
|
19
|
+
this.room = new TLSyncRoom<R, undefined>({ schema, storage: this.storage })
|
|
9
20
|
}
|
|
10
21
|
|
|
11
22
|
connect(socketPair: TestSocketPair<R>): void {
|
|
@@ -12,7 +12,7 @@ const schema = StoreSchema.create<Presence>({ presence: presenceType })
|
|
|
12
12
|
|
|
13
13
|
describe('custom messages', () => {
|
|
14
14
|
it('sends a message to a client', async () => {
|
|
15
|
-
const store = new Store({ schema, props: {} })
|
|
15
|
+
const store = new Store<any, any>({ schema, props: {} })
|
|
16
16
|
|
|
17
17
|
const sessionId = 'test-session-1'
|
|
18
18
|
const server = new TestServer(schema)
|
|
@@ -97,21 +97,21 @@ test('presence is pushed on change when mode is full', () => {
|
|
|
97
97
|
const session = t.server.room.sessions.values().next().value
|
|
98
98
|
expect(session).toBeDefined()
|
|
99
99
|
expect(session?.presenceId).toBeDefined()
|
|
100
|
-
expect(t.server.room.
|
|
100
|
+
expect(t.server.room.presenceStore.get(session!.presenceId!)).toMatchObject({
|
|
101
101
|
name: 'bob',
|
|
102
102
|
age: 10,
|
|
103
103
|
})
|
|
104
104
|
|
|
105
105
|
presenceSignal.set(Presence.create({ name: 'bob', age: 11 }))
|
|
106
106
|
t.flush()
|
|
107
|
-
expect(t.server.room.
|
|
107
|
+
expect(t.server.room.presenceStore.get(session!.presenceId!)).toMatchObject({
|
|
108
108
|
name: 'bob',
|
|
109
109
|
age: 11,
|
|
110
110
|
})
|
|
111
111
|
|
|
112
112
|
presenceSignal.set(Presence.create({ name: 'bob', age: 12 }))
|
|
113
113
|
t.flush()
|
|
114
|
-
expect(t.server.room.
|
|
114
|
+
expect(t.server.room.presenceStore.get(session!.presenceId!)).toMatchObject({
|
|
115
115
|
name: 'bob',
|
|
116
116
|
age: 12,
|
|
117
117
|
})
|
|
@@ -128,21 +128,21 @@ test('presence is only pushed once on connect when mode is solo', () => {
|
|
|
128
128
|
const session = t.server.room.sessions.values().next().value
|
|
129
129
|
expect(session).toBeDefined()
|
|
130
130
|
expect(session?.presenceId).toBeDefined()
|
|
131
|
-
expect(t.server.room.
|
|
131
|
+
expect(t.server.room.presenceStore.get(session!.presenceId!)).toMatchObject({
|
|
132
132
|
name: 'bob',
|
|
133
133
|
age: 10,
|
|
134
134
|
})
|
|
135
135
|
|
|
136
136
|
presenceSignal.set(Presence.create({ name: 'bob', age: 11 }))
|
|
137
137
|
t.flush()
|
|
138
|
-
expect(t.server.room.
|
|
138
|
+
expect(t.server.room.presenceStore.get(session!.presenceId!)).not.toMatchObject({
|
|
139
139
|
name: 'bob',
|
|
140
140
|
age: 11,
|
|
141
141
|
})
|
|
142
142
|
|
|
143
143
|
presenceSignal.set(Presence.create({ name: 'bob', age: 12 }))
|
|
144
144
|
t.flush()
|
|
145
|
-
expect(t.server.room.
|
|
145
|
+
expect(t.server.room.presenceStore.get(session!.presenceId!)).not.toMatchObject({
|
|
146
146
|
name: 'bob',
|
|
147
147
|
age: 12,
|
|
148
148
|
})
|
|
@@ -228,11 +228,11 @@ test('the server can handle receiving v1 stuff from the client', () => {
|
|
|
228
228
|
t.oldClient.store.put([user])
|
|
229
229
|
t.flush()
|
|
230
230
|
|
|
231
|
-
expect(t.server.
|
|
231
|
+
expect(t.server.storage.documents.get(user.id)?.state).toMatchObject({
|
|
232
232
|
name: 'bob',
|
|
233
233
|
birthdate: null,
|
|
234
234
|
})
|
|
235
|
-
expect(t.server.
|
|
235
|
+
expect(t.server.storage.documents.get(user.id)?.state).not.toMatchObject({
|
|
236
236
|
name: 'bob',
|
|
237
237
|
age: 10,
|
|
238
238
|
})
|
|
@@ -254,7 +254,7 @@ test('the server can send v2 stuff to the v1 client', () => {
|
|
|
254
254
|
t.newClient.store.put([user])
|
|
255
255
|
t.flush()
|
|
256
256
|
|
|
257
|
-
expect(t.server.
|
|
257
|
+
expect(t.server.storage.documents.get(user.id)?.state).toMatchObject({
|
|
258
258
|
name: 'bob',
|
|
259
259
|
birthdate: '2022-01-09',
|
|
260
260
|
})
|
|
@@ -287,14 +287,14 @@ test('the server will run schema migrations on a snapshot', () => {
|
|
|
287
287
|
schemaV3
|
|
288
288
|
)
|
|
289
289
|
|
|
290
|
-
expect(t.server.
|
|
290
|
+
expect(t.server.storage.documents.get(bob.id)?.state).toMatchObject({
|
|
291
291
|
name: 'bob',
|
|
292
292
|
birthdate: null,
|
|
293
293
|
})
|
|
294
|
-
expect(t.server.
|
|
294
|
+
expect(t.server.storage.documents.get(joe.id)).toBeUndefined()
|
|
295
295
|
|
|
296
296
|
// there should be someone named steve
|
|
297
|
-
const snapshot = t.server.
|
|
297
|
+
const snapshot = t.server.storage.getSnapshot()
|
|
298
298
|
expect(snapshot.documents.find((u: any) => u.state.name === 'steve')).toBeDefined()
|
|
299
299
|
})
|
|
300
300
|
|
|
@@ -309,7 +309,7 @@ test('clients will receive updates from a snapshot migration upon connection', (
|
|
|
309
309
|
t.newClient.store.put([bob, joe])
|
|
310
310
|
t.flush()
|
|
311
311
|
|
|
312
|
-
const snapshot = t.server.
|
|
312
|
+
const snapshot = t.server.storage.getSnapshot()
|
|
313
313
|
|
|
314
314
|
t.oldSocketPair.disconnect()
|
|
315
315
|
t.newSocketPair.disconnect()
|
|
@@ -333,7 +333,7 @@ test('clients will receive updates from a snapshot migration upon connection', (
|
|
|
333
333
|
newServer.room.handleMessage(id, {
|
|
334
334
|
type: 'connect',
|
|
335
335
|
connectRequestId: 'test',
|
|
336
|
-
lastServerClock: snapshot.clock,
|
|
336
|
+
lastServerClock: snapshot.documentClock ?? snapshot.clock ?? 0,
|
|
337
337
|
protocolVersion: getTlsyncProtocolVersion(),
|
|
338
338
|
schema: schemaV3.serialize(),
|
|
339
339
|
})
|
|
@@ -670,6 +670,280 @@ describe('when the client is too old', () => {
|
|
|
670
670
|
})
|
|
671
671
|
})
|
|
672
672
|
|
|
673
|
+
describe('migration failure during push (TLSyncError handling)', () => {
|
|
674
|
+
// Create a schema where migrations will fail during push
|
|
675
|
+
const UserVersionsWithFailure = createMigrationIds('com.tldraw.user.failure', {
|
|
676
|
+
AddNickname: 1,
|
|
677
|
+
} as const)
|
|
678
|
+
|
|
679
|
+
interface UserWithNickname extends BaseRecord<'user_fail', RecordId<UserWithNickname>> {
|
|
680
|
+
name: string
|
|
681
|
+
nickname: string
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const UserWithNicknameType = createRecordType<UserWithNickname>('user_fail', {
|
|
685
|
+
scope: 'document',
|
|
686
|
+
validator: { validate: (value) => value as UserWithNickname },
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
interface UserWithoutNickname extends BaseRecord<'user_fail', RecordId<UserWithoutNickname>> {
|
|
690
|
+
name: string
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const UserWithoutNicknameType = createRecordType<UserWithoutNickname>('user_fail', {
|
|
694
|
+
scope: 'document',
|
|
695
|
+
validator: { validate: (value) => value as UserWithoutNickname },
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
// Server schema with migration that throws on specific conditions
|
|
699
|
+
// - up() throws when name === 'FAIL_UP_MIGRATION'
|
|
700
|
+
// - down() throws when nickname === 'FAIL_DOWN_MIGRATION'
|
|
701
|
+
const serverSchemaWithThrowingMigration = StoreSchema.create<UserWithNickname>(
|
|
702
|
+
{ user_fail: UserWithNicknameType },
|
|
703
|
+
{
|
|
704
|
+
migrations: [
|
|
705
|
+
createRecordMigrationSequence({
|
|
706
|
+
sequenceId: 'com.tldraw.user.failure',
|
|
707
|
+
recordType: 'user_fail',
|
|
708
|
+
sequence: [
|
|
709
|
+
{
|
|
710
|
+
id: UserVersionsWithFailure.AddNickname,
|
|
711
|
+
up(record: any) {
|
|
712
|
+
// Simulate UP migration failure for specific data
|
|
713
|
+
if (record.name === 'FAIL_UP_MIGRATION') {
|
|
714
|
+
throw new Error('UP migration intentionally failed for testing')
|
|
715
|
+
}
|
|
716
|
+
return {
|
|
717
|
+
...record,
|
|
718
|
+
nickname: record.name.toUpperCase(),
|
|
719
|
+
}
|
|
720
|
+
},
|
|
721
|
+
down(record: any) {
|
|
722
|
+
// Simulate DOWN migration failure for specific data
|
|
723
|
+
if (record.nickname === 'FAIL_DOWN_MIGRATION') {
|
|
724
|
+
throw new Error('DOWN migration intentionally failed for testing')
|
|
725
|
+
}
|
|
726
|
+
const { nickname: _, ...rest } = record
|
|
727
|
+
return rest
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
],
|
|
731
|
+
}),
|
|
732
|
+
],
|
|
733
|
+
}
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
// Client schema (old version without nickname)
|
|
737
|
+
const clientSchemaWithoutNickname = StoreSchema.create<UserWithoutNickname>(
|
|
738
|
+
{ user_fail: UserWithoutNicknameType },
|
|
739
|
+
{
|
|
740
|
+
migrations: [
|
|
741
|
+
createMigrationSequence({
|
|
742
|
+
sequenceId: 'com.tldraw.user.failure',
|
|
743
|
+
sequence: [],
|
|
744
|
+
retroactive: true,
|
|
745
|
+
}),
|
|
746
|
+
],
|
|
747
|
+
}
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
it('rejects session when addDocument UP migration throws during PUT', () => {
|
|
751
|
+
const server = new TestServer(serverSchemaWithThrowingMigration)
|
|
752
|
+
|
|
753
|
+
const sessionId = 'failing-migration-session'
|
|
754
|
+
const socket = mockSocket<UserWithNickname>()
|
|
755
|
+
|
|
756
|
+
server.room.handleNewSession({
|
|
757
|
+
sessionId,
|
|
758
|
+
socket: socket as any,
|
|
759
|
+
meta: undefined,
|
|
760
|
+
isReadonly: false,
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
// Connect with old schema - should succeed since migration has down function
|
|
764
|
+
server.room.handleMessage(sessionId, {
|
|
765
|
+
type: 'connect',
|
|
766
|
+
connectRequestId: 'test',
|
|
767
|
+
lastServerClock: 0,
|
|
768
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
769
|
+
schema: clientSchemaWithoutNickname.serialize(),
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
|
|
773
|
+
;(socket.sendMessage as Mock).mockClear()
|
|
774
|
+
|
|
775
|
+
// Push a record that will cause the UP migration to throw
|
|
776
|
+
const failingRecord = UserWithoutNicknameType.create({
|
|
777
|
+
id: UserWithoutNicknameType.createId('fail'),
|
|
778
|
+
name: 'FAIL_UP_MIGRATION', // This name triggers the UP throw
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
server.room.handleMessage(sessionId, {
|
|
782
|
+
type: 'push',
|
|
783
|
+
clientClock: 1,
|
|
784
|
+
diff: {
|
|
785
|
+
[failingRecord.id]: [RecordOpType.Put, failingRecord as any],
|
|
786
|
+
},
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
// Session should be rejected due to migration failure (TLSyncError)
|
|
790
|
+
expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
it('successfully migrates records when migration does not throw', () => {
|
|
794
|
+
const server = new TestServer(serverSchemaWithThrowingMigration)
|
|
795
|
+
|
|
796
|
+
const sessionId = 'successful-migration-session'
|
|
797
|
+
const socket = mockSocket<UserWithNickname>()
|
|
798
|
+
|
|
799
|
+
server.room.handleNewSession({
|
|
800
|
+
sessionId,
|
|
801
|
+
socket: socket as any,
|
|
802
|
+
meta: undefined,
|
|
803
|
+
isReadonly: false,
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
// Connect with old schema
|
|
807
|
+
server.room.handleMessage(sessionId, {
|
|
808
|
+
type: 'connect',
|
|
809
|
+
connectRequestId: 'test',
|
|
810
|
+
lastServerClock: 0,
|
|
811
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
812
|
+
schema: clientSchemaWithoutNickname.serialize(),
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
|
|
816
|
+
;(socket.sendMessage as Mock).mockClear()
|
|
817
|
+
|
|
818
|
+
// Push a record that will NOT cause the migration to throw
|
|
819
|
+
const validRecord = UserWithoutNicknameType.create({
|
|
820
|
+
id: UserWithoutNicknameType.createId('valid'),
|
|
821
|
+
name: 'ValidName', // This name does not trigger the throw
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
server.room.handleMessage(sessionId, {
|
|
825
|
+
type: 'push',
|
|
826
|
+
clientClock: 1,
|
|
827
|
+
diff: {
|
|
828
|
+
[validRecord.id]: [RecordOpType.Put, validRecord as any],
|
|
829
|
+
},
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
// Session should still be connected
|
|
833
|
+
expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
|
|
834
|
+
|
|
835
|
+
// Record should be migrated and stored
|
|
836
|
+
const storedRecord = server.storage.documents.get(validRecord.id)?.state as UserWithNickname
|
|
837
|
+
expect(storedRecord).toBeDefined()
|
|
838
|
+
expect(storedRecord.nickname).toBe('VALIDNAME') // Uppercase from migration
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
it('rejects session when patchDocument DOWN migration throws', () => {
|
|
842
|
+
// Create a server with a document that has a nickname that will fail DOWN migration
|
|
843
|
+
const existingUser = UserWithNicknameType.create({
|
|
844
|
+
id: UserWithNicknameType.createId('existing_down_fail'),
|
|
845
|
+
name: 'ExistingUser',
|
|
846
|
+
nickname: 'FAIL_DOWN_MIGRATION', // This nickname triggers the DOWN throw
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
const server = new TestServer(serverSchemaWithThrowingMigration, {
|
|
850
|
+
clock: 10,
|
|
851
|
+
documents: [{ state: existingUser, lastChangedClock: 10 }],
|
|
852
|
+
schema: serverSchemaWithThrowingMigration.serialize(),
|
|
853
|
+
tombstones: {},
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
const sessionId = 'patch-down-migration-session'
|
|
857
|
+
const socket = mockSocket<UserWithNickname>()
|
|
858
|
+
|
|
859
|
+
server.room.handleNewSession({
|
|
860
|
+
sessionId,
|
|
861
|
+
socket: socket as any,
|
|
862
|
+
meta: undefined,
|
|
863
|
+
isReadonly: false,
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
// Connect with old schema
|
|
867
|
+
server.room.handleMessage(sessionId, {
|
|
868
|
+
type: 'connect',
|
|
869
|
+
connectRequestId: 'test',
|
|
870
|
+
lastServerClock: 10,
|
|
871
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
872
|
+
schema: clientSchemaWithoutNickname.serialize(),
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
|
|
876
|
+
;(socket.sendMessage as Mock).mockClear()
|
|
877
|
+
|
|
878
|
+
// Patch the existing record - the DOWN migration will fail because nickname is 'FAIL_DOWN_MIGRATION'
|
|
879
|
+
// This tests line 951 in TLSyncRoom.ts
|
|
880
|
+
server.room.handleMessage(sessionId, {
|
|
881
|
+
type: 'push',
|
|
882
|
+
clientClock: 1,
|
|
883
|
+
diff: {
|
|
884
|
+
[existingUser.id]: [RecordOpType.Patch, { name: [ValueOpType.Put, 'NewName'] }],
|
|
885
|
+
},
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
// Session should be rejected due to DOWN migration failure
|
|
889
|
+
expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
it('rejects session when patchDocument UP migration throws (after successful DOWN migration)', () => {
|
|
893
|
+
// Create a server with a document that will pass DOWN migration but fail UP migration
|
|
894
|
+
const existingUser = UserWithNicknameType.create({
|
|
895
|
+
id: UserWithNicknameType.createId('existing_up_fail'),
|
|
896
|
+
name: 'ExistingUser',
|
|
897
|
+
nickname: 'EXISTINGUSER', // Normal nickname, DOWN migration will succeed
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
const server = new TestServer(serverSchemaWithThrowingMigration, {
|
|
901
|
+
clock: 10,
|
|
902
|
+
documents: [{ state: existingUser, lastChangedClock: 10 }],
|
|
903
|
+
schema: serverSchemaWithThrowingMigration.serialize(),
|
|
904
|
+
tombstones: {},
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
const sessionId = 'patch-up-migration-session'
|
|
908
|
+
const socket = mockSocket<UserWithNickname>()
|
|
909
|
+
|
|
910
|
+
server.room.handleNewSession({
|
|
911
|
+
sessionId,
|
|
912
|
+
socket: socket as any,
|
|
913
|
+
meta: undefined,
|
|
914
|
+
isReadonly: false,
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
// Connect with old schema
|
|
918
|
+
server.room.handleMessage(sessionId, {
|
|
919
|
+
type: 'connect',
|
|
920
|
+
connectRequestId: 'test',
|
|
921
|
+
lastServerClock: 10,
|
|
922
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
923
|
+
schema: clientSchemaWithoutNickname.serialize(),
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
|
|
927
|
+
;(socket.sendMessage as Mock).mockClear()
|
|
928
|
+
|
|
929
|
+
// Patch the existing record - changes the name to 'FAIL_UP_MIGRATION'
|
|
930
|
+
// 1. DOWN migration: {name: 'ExistingUser', nickname: 'EXISTINGUSER'} -> {name: 'ExistingUser'} (succeeds)
|
|
931
|
+
// 2. Patch applied: {name: 'ExistingUser'} -> {name: 'FAIL_UP_MIGRATION'}
|
|
932
|
+
// 3. UP migration: {name: 'FAIL_UP_MIGRATION'} -> THROWS
|
|
933
|
+
// This tests line 972 in TLSyncRoom.ts
|
|
934
|
+
server.room.handleMessage(sessionId, {
|
|
935
|
+
type: 'push',
|
|
936
|
+
clientClock: 1,
|
|
937
|
+
diff: {
|
|
938
|
+
[existingUser.id]: [RecordOpType.Patch, { name: [ValueOpType.Put, 'FAIL_UP_MIGRATION'] }],
|
|
939
|
+
},
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
// Session should be rejected due to UP migration failure during patch
|
|
943
|
+
expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
944
|
+
})
|
|
945
|
+
})
|
|
946
|
+
|
|
673
947
|
describe('when the client is the same version', () => {
|
|
674
948
|
function setup() {
|
|
675
949
|
const steve = UserV2.create({
|
|
@@ -95,7 +95,7 @@ async function makeTestInstance() {
|
|
|
95
95
|
it('rejects invalid put operations that create a new document', async () => {
|
|
96
96
|
const { client, flush, onSyncError, server } = await makeTestInstance()
|
|
97
97
|
|
|
98
|
-
const prevServerDocs = server.
|
|
98
|
+
const prevServerDocs = server.storage.getSnapshot().documents
|
|
99
99
|
|
|
100
100
|
client.store.put([
|
|
101
101
|
{
|
|
@@ -109,20 +109,20 @@ it('rejects invalid put operations that create a new document', async () => {
|
|
|
109
109
|
|
|
110
110
|
expect(onSyncError).toHaveBeenCalledTimes(1)
|
|
111
111
|
expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
112
|
-
expect(server.
|
|
112
|
+
expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
|
|
113
113
|
})
|
|
114
114
|
|
|
115
115
|
it('rejects invalid put operations that replace an existing document', async () => {
|
|
116
116
|
const { client, flush, onSyncError, server } = await makeTestInstance()
|
|
117
117
|
|
|
118
|
-
let prevServerDocs = server.
|
|
118
|
+
let prevServerDocs = server.storage.getSnapshot().documents
|
|
119
119
|
const book: Book = { typeName: 'book', id: Book.createId('1'), title: 'Annihilation' }
|
|
120
120
|
client.store.put([book])
|
|
121
121
|
await flush()
|
|
122
122
|
|
|
123
123
|
expect(onSyncError).toHaveBeenCalledTimes(0)
|
|
124
|
-
expect(server.
|
|
125
|
-
prevServerDocs = server.
|
|
124
|
+
expect(server.storage.getSnapshot().documents).not.toStrictEqual(prevServerDocs)
|
|
125
|
+
prevServerDocs = server.storage.getSnapshot().documents
|
|
126
126
|
|
|
127
127
|
client.socket.sendMessage({
|
|
128
128
|
type: 'push',
|
|
@@ -143,13 +143,13 @@ it('rejects invalid put operations that replace an existing document', async ()
|
|
|
143
143
|
|
|
144
144
|
expect(onSyncError).toHaveBeenCalledTimes(1)
|
|
145
145
|
expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
146
|
-
expect(server.
|
|
146
|
+
expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
|
|
147
147
|
})
|
|
148
148
|
|
|
149
149
|
it('rejects invalid update operations', async () => {
|
|
150
150
|
const { client, flush, onSyncError, server } = await makeTestInstance()
|
|
151
151
|
|
|
152
|
-
let prevServerDocs = server.
|
|
152
|
+
let prevServerDocs = server.storage.getSnapshot().documents
|
|
153
153
|
|
|
154
154
|
// create the book
|
|
155
155
|
client.store.put([
|
|
@@ -162,8 +162,8 @@ it('rejects invalid update operations', async () => {
|
|
|
162
162
|
await flush()
|
|
163
163
|
|
|
164
164
|
expect(onSyncError).toHaveBeenCalledTimes(0)
|
|
165
|
-
expect(server.
|
|
166
|
-
prevServerDocs = server.
|
|
165
|
+
expect(server.storage.getSnapshot().documents).not.toStrictEqual(prevServerDocs)
|
|
166
|
+
prevServerDocs = server.storage.getSnapshot().documents
|
|
167
167
|
|
|
168
168
|
// update the title to be wrong
|
|
169
169
|
client.store.put([
|
|
@@ -177,5 +177,5 @@ it('rejects invalid update operations', async () => {
|
|
|
177
177
|
await flush()
|
|
178
178
|
expect(onSyncError).toHaveBeenCalledTimes(1)
|
|
179
179
|
expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
180
|
-
expect(server.
|
|
180
|
+
expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
|
|
181
181
|
})
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
import { BaseRecord, createRecordType, RecordId, StoreSchema } from '@tldraw/store'
|
|
2
|
-
import { TLSyncRoom } from '../lib/TLSyncRoom'
|
|
3
|
-
import { findMin } from '../lib/findMin'
|
|
4
|
-
|
|
5
|
-
interface TestRecord extends BaseRecord<'test', RecordId<TestRecord>> {
|
|
6
|
-
name: string
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
describe('TLSyncRoom pruneTombstones', () => {
|
|
10
|
-
let room: TLSyncRoom<TestRecord, any>
|
|
11
|
-
let TestRecordType: any
|
|
12
|
-
let schema: StoreSchema<TestRecord, any>
|
|
13
|
-
|
|
14
|
-
beforeEach(() => {
|
|
15
|
-
TestRecordType = createRecordType<TestRecord>('test', {
|
|
16
|
-
scope: 'document',
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
schema = StoreSchema.create({
|
|
20
|
-
test: TestRecordType,
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
// Create room with empty snapshot to avoid default documents being converted to tombstones
|
|
24
|
-
room = new TLSyncRoom({
|
|
25
|
-
schema,
|
|
26
|
-
snapshot: {
|
|
27
|
-
clock: 0,
|
|
28
|
-
documents: [],
|
|
29
|
-
},
|
|
30
|
-
})
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
it('should not prune when tombstone count is below threshold', () => {
|
|
34
|
-
// Add some tombstones but below MAX_TOMBSTONES (3000)
|
|
35
|
-
for (let i = 0; i < 100; i++) {
|
|
36
|
-
room.tombstones.set(`doc${i}`, i + 1)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const initialSize = room.tombstones.size
|
|
40
|
-
const initialHistoryClock = room.tombstoneHistoryStartsAtClock
|
|
41
|
-
|
|
42
|
-
// Reset needsPrune flag and call pruneTombstones
|
|
43
|
-
;(room as any).needsPrune = true
|
|
44
|
-
;(room as any).pruneTombstones()
|
|
45
|
-
|
|
46
|
-
// Should not have pruned anything
|
|
47
|
-
expect(room.tombstones.size).toBe(initialSize)
|
|
48
|
-
expect(room.tombstoneHistoryStartsAtClock).toBe(initialHistoryClock)
|
|
49
|
-
|
|
50
|
-
expect(findMin(room.tombstones.values())).toBeGreaterThanOrEqual(
|
|
51
|
-
room.tombstoneHistoryStartsAtClock
|
|
52
|
-
)
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('should prune tombstones when count exceeds threshold', () => {
|
|
56
|
-
// Add more tombstones than MAX_TOMBSTONES
|
|
57
|
-
const totalTombstones = 3200 // Above MAX_TOMBSTONES (3000)
|
|
58
|
-
for (let i = 0; i < totalTombstones; i++) {
|
|
59
|
-
room.tombstones.set(`doc${i}`, i + 1)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const startCock = room.tombstoneHistoryStartsAtClock
|
|
63
|
-
|
|
64
|
-
expect(room.tombstones.size).toBe(totalTombstones)
|
|
65
|
-
|
|
66
|
-
// Reset needsPrune flag and call pruneTombstones
|
|
67
|
-
;(room as any).needsPrune = true
|
|
68
|
-
;(room as any).pruneTombstones()
|
|
69
|
-
|
|
70
|
-
expect(room.tombstones.size).toBeLessThan(totalTombstones)
|
|
71
|
-
expect(room.tombstoneHistoryStartsAtClock).toBeGreaterThan(startCock)
|
|
72
|
-
|
|
73
|
-
expect(room.tombstones.size).toMatchInlineSnapshot(`2700`) // should be about 1500
|
|
74
|
-
expect(room.tombstoneHistoryStartsAtClock).toMatchInlineSnapshot(`501`) // should be about 1700
|
|
75
|
-
|
|
76
|
-
expect(findMin(room.tombstones.values())).toBeGreaterThanOrEqual(
|
|
77
|
-
room.tombstoneHistoryStartsAtClock
|
|
78
|
-
)
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
it('should handle tombstones with same clock value correctly', () => {
|
|
82
|
-
// Add tombstones with some having the same clock values
|
|
83
|
-
const totalTombstones = 3200
|
|
84
|
-
for (let i = 0; i < totalTombstones; i++) {
|
|
85
|
-
// Use clock values that repeat: 1, 1, 1, ..., 2, 2, 2, ..., 320, 320, 320
|
|
86
|
-
const clock = Math.floor(i / 10) + 1
|
|
87
|
-
room.tombstones.set(`doc${i}`, clock)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const startCock = room.tombstoneHistoryStartsAtClock
|
|
91
|
-
|
|
92
|
-
// Reset needsPrune flag and call pruneTombstones
|
|
93
|
-
;(room as any).needsPrune = true
|
|
94
|
-
;(room as any).pruneTombstones()
|
|
95
|
-
|
|
96
|
-
// The algorithm keeps the oldest tombstones (preserving history)
|
|
97
|
-
// With repeating clock values, we have 10 tombstones for each clock 1-320
|
|
98
|
-
// We keep the oldest 200 tombstones (clocks 1-20)
|
|
99
|
-
// We delete the newest 3000 tombstones (clocks 21-320)
|
|
100
|
-
expect(room.tombstones.size).toBeLessThan(totalTombstones)
|
|
101
|
-
expect(room.tombstoneHistoryStartsAtClock).toBeGreaterThan(startCock)
|
|
102
|
-
|
|
103
|
-
expect(room.tombstones.size).toMatchInlineSnapshot(`2700`) // should be about 1500
|
|
104
|
-
expect(room.tombstoneHistoryStartsAtClock).toMatchInlineSnapshot(`51`) // should be about 150
|
|
105
|
-
|
|
106
|
-
expect(findMin(room.tombstones.values())).toBeGreaterThanOrEqual(
|
|
107
|
-
room.tombstoneHistoryStartsAtClock
|
|
108
|
-
)
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
it('should handle edge case where all tombstones have same clock value', () => {
|
|
112
|
-
// Add tombstones all with the same clock value
|
|
113
|
-
const totalTombstones = 3200
|
|
114
|
-
const sameClock = 100
|
|
115
|
-
for (let i = 0; i < totalTombstones; i++) {
|
|
116
|
-
room.tombstones.set(`doc${i}`, sameClock)
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const startClock = room.tombstoneHistoryStartsAtClock
|
|
120
|
-
|
|
121
|
-
// Reset needsPrune flag and call pruneTombstones
|
|
122
|
-
;(room as any).needsPrune = true
|
|
123
|
-
;(room as any).pruneTombstones()
|
|
124
|
-
|
|
125
|
-
// When all tombstones have the same clock value, the algorithm deletes all of them
|
|
126
|
-
// because the while loop advances to the end and all tombstones are marked for deletion
|
|
127
|
-
expect(room.tombstones.size).toBeLessThan(totalTombstones)
|
|
128
|
-
expect(room.tombstoneHistoryStartsAtClock).toBeGreaterThan(startClock)
|
|
129
|
-
|
|
130
|
-
expect(room.tombstones.size).toMatchInlineSnapshot(`0`) // all deleted
|
|
131
|
-
expect(room.tombstoneHistoryStartsAtClock).toMatchInlineSnapshot(`101`) // next clock after deletion
|
|
132
|
-
|
|
133
|
-
expect(findMin(room.tombstones.values())).toBe(null) // findMin returns null for empty collections
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
it('should handle exact threshold case', () => {
|
|
137
|
-
// Add exactly MAX_TOMBSTONES tombstones
|
|
138
|
-
for (let i = 0; i < 3000; i++) {
|
|
139
|
-
room.tombstones.set(`doc${i}`, i + 1)
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const initialSize = room.tombstones.size
|
|
143
|
-
expect(initialSize).toBe(3000)
|
|
144
|
-
|
|
145
|
-
// Reset needsPrune flag and call pruneTombstones
|
|
146
|
-
;(room as any).needsPrune = true
|
|
147
|
-
;(room as any).pruneTombstones()
|
|
148
|
-
|
|
149
|
-
// Should not prune anything since we're exactly at the threshold
|
|
150
|
-
expect(room.tombstones.size).toBe(initialSize)
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
it('should handle very large tombstone counts', () => {
|
|
154
|
-
// Test with a much larger number of tombstones
|
|
155
|
-
const totalTombstones = 10000
|
|
156
|
-
for (let i = 0; i < totalTombstones; i++) {
|
|
157
|
-
room.tombstones.set(`doc${i}`, i + 1)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const startClock = room.tombstoneHistoryStartsAtClock
|
|
161
|
-
|
|
162
|
-
// Reset needsPrune flag and call pruneTombstones
|
|
163
|
-
;(room as any).needsPrune = true
|
|
164
|
-
;(room as any).pruneTombstones()
|
|
165
|
-
|
|
166
|
-
// The algorithm keeps the oldest tombstones (preserving history)
|
|
167
|
-
// With 10000 tombstones, we keep about 7000 and delete about 3000
|
|
168
|
-
expect(room.tombstones.size).toBeLessThan(totalTombstones)
|
|
169
|
-
expect(room.tombstoneHistoryStartsAtClock).toBeGreaterThan(startClock)
|
|
170
|
-
|
|
171
|
-
expect(room.tombstones.size).toMatchInlineSnapshot(`2700`) // should be about 1500
|
|
172
|
-
expect(room.tombstoneHistoryStartsAtClock).toMatchInlineSnapshot(`7301`) // should be about 1500
|
|
173
|
-
|
|
174
|
-
expect(findMin(room.tombstones.values())).toBeGreaterThanOrEqual(
|
|
175
|
-
room.tombstoneHistoryStartsAtClock
|
|
176
|
-
)
|
|
177
|
-
})
|
|
178
|
-
})
|