@tldraw/sync-core 4.2.0 → 4.2.2
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 +483 -58
- package/dist-cjs/index.js +13 -3
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js +55 -0
- package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js.map +7 -0
- package/dist-cjs/lib/InMemorySyncStorage.js +287 -0
- package/dist-cjs/lib/InMemorySyncStorage.js.map +7 -0
- package/dist-cjs/lib/MicrotaskNotifier.js +50 -0
- package/dist-cjs/lib/MicrotaskNotifier.js.map +7 -0
- package/dist-cjs/lib/NodeSqliteWrapper.js +48 -0
- package/dist-cjs/lib/NodeSqliteWrapper.js.map +7 -0
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/SQLiteSyncStorage.js +428 -0
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +7 -0
- 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/chunk.js +2 -2
- package/dist-cjs/lib/chunk.js.map +1 -1
- package/dist-cjs/lib/recordDiff.js +52 -0
- package/dist-cjs/lib/recordDiff.js.map +7 -0
- package/dist-esm/index.d.mts +483 -58
- package/dist-esm/index.mjs +20 -5
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs +35 -0
- package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs.map +7 -0
- package/dist-esm/lib/InMemorySyncStorage.mjs +272 -0
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +7 -0
- package/dist-esm/lib/MicrotaskNotifier.mjs +30 -0
- package/dist-esm/lib/MicrotaskNotifier.mjs.map +7 -0
- package/dist-esm/lib/NodeSqliteWrapper.mjs +28 -0
- package/dist-esm/lib/NodeSqliteWrapper.mjs.map +7 -0
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/SQLiteSyncStorage.mjs +414 -0
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +7 -0
- 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/chunk.mjs +2 -2
- package/dist-esm/lib/chunk.mjs.map +1 -1
- package/dist-esm/lib/recordDiff.mjs +32 -0
- package/dist-esm/lib/recordDiff.mjs.map +7 -0
- package/package.json +12 -11
- package/src/index.ts +32 -3
- package/src/lib/ClientWebSocketAdapter.test.ts +3 -0
- package/src/lib/DurableObjectSqliteSyncWrapper.ts +95 -0
- package/src/lib/InMemorySyncStorage.ts +387 -0
- package/src/lib/MicrotaskNotifier.test.ts +429 -0
- package/src/lib/MicrotaskNotifier.ts +38 -0
- package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +270 -0
- package/src/lib/NodeSqliteSyncWrapper.test.ts +272 -0
- package/src/lib/NodeSqliteWrapper.ts +99 -0
- package/src/lib/RoomSession.test.ts +1 -0
- package/src/lib/RoomSession.ts +2 -0
- package/src/lib/SQLiteSyncStorage.ts +627 -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/chunk.ts +2 -2
- package/src/lib/computeTombstonePruning.test.ts +352 -0
- package/src/lib/recordDiff.ts +73 -0
- package/src/test/FuzzEditor.ts +4 -5
- package/src/test/InMemorySyncStorage.test.ts +1684 -0
- package/src/test/SQLiteSyncStorage.test.ts +1378 -0
- package/src/test/TLSocketRoom.test.ts +255 -49
- package/src/test/TLSyncRoom.test.ts +1024 -534
- 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/syncFuzz.test.ts +2 -4
- package/src/test/upgradeDowngrade.test.ts +290 -8
- package/src/test/validation.test.ts +15 -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
|
})
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Editor,
|
|
3
|
-
TLArrowBinding,
|
|
4
|
-
TLArrowShape,
|
|
5
3
|
TLRecord,
|
|
6
4
|
TLStore,
|
|
7
5
|
computed,
|
|
@@ -111,9 +109,9 @@ let totalNumShapes = 0
|
|
|
111
109
|
let totalNumPages = 0
|
|
112
110
|
|
|
113
111
|
function arrowsAreSound(editor: Editor) {
|
|
114
|
-
const arrows = editor.getCurrentPageShapes().filter((s)
|
|
112
|
+
const arrows = editor.getCurrentPageShapes().filter((s) => s.type === 'arrow')
|
|
115
113
|
for (const arrow of arrows) {
|
|
116
|
-
const bindings = editor.getBindingsFromShape
|
|
114
|
+
const bindings = editor.getBindingsFromShape(arrow, 'arrow')
|
|
117
115
|
const terminalsSeen = new Set()
|
|
118
116
|
for (const binding of bindings) {
|
|
119
117
|
if (terminalsSeen.has(binding.props.terminal)) {
|
|
@@ -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,288 @@ describe('when the client is too old', () => {
|
|
|
670
670
|
})
|
|
671
671
|
})
|
|
672
672
|
|
|
673
|
+
describe('migration failure during push (TLSyncError handling)', () => {
|
|
674
|
+
let consoleSpy: ReturnType<typeof vi.spyOn>
|
|
675
|
+
beforeEach(() => {
|
|
676
|
+
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
677
|
+
})
|
|
678
|
+
afterEach(() => {
|
|
679
|
+
consoleSpy.mockRestore()
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
// Create a schema where migrations will fail during push
|
|
683
|
+
const UserVersionsWithFailure = createMigrationIds('com.tldraw.user.failure', {
|
|
684
|
+
AddNickname: 1,
|
|
685
|
+
} as const)
|
|
686
|
+
|
|
687
|
+
interface UserWithNickname extends BaseRecord<'user_fail', RecordId<UserWithNickname>> {
|
|
688
|
+
name: string
|
|
689
|
+
nickname: string
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const UserWithNicknameType = createRecordType<UserWithNickname>('user_fail', {
|
|
693
|
+
scope: 'document',
|
|
694
|
+
validator: { validate: (value) => value as UserWithNickname },
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
interface UserWithoutNickname extends BaseRecord<'user_fail', RecordId<UserWithoutNickname>> {
|
|
698
|
+
name: string
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const UserWithoutNicknameType = createRecordType<UserWithoutNickname>('user_fail', {
|
|
702
|
+
scope: 'document',
|
|
703
|
+
validator: { validate: (value) => value as UserWithoutNickname },
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
// Server schema with migration that throws on specific conditions
|
|
707
|
+
// - up() throws when name === 'FAIL_UP_MIGRATION'
|
|
708
|
+
// - down() throws when nickname === 'FAIL_DOWN_MIGRATION'
|
|
709
|
+
const serverSchemaWithThrowingMigration = StoreSchema.create<UserWithNickname>(
|
|
710
|
+
{ user_fail: UserWithNicknameType },
|
|
711
|
+
{
|
|
712
|
+
migrations: [
|
|
713
|
+
createRecordMigrationSequence({
|
|
714
|
+
sequenceId: 'com.tldraw.user.failure',
|
|
715
|
+
recordType: 'user_fail',
|
|
716
|
+
sequence: [
|
|
717
|
+
{
|
|
718
|
+
id: UserVersionsWithFailure.AddNickname,
|
|
719
|
+
up(record: any) {
|
|
720
|
+
// Simulate UP migration failure for specific data
|
|
721
|
+
if (record.name === 'FAIL_UP_MIGRATION') {
|
|
722
|
+
throw new Error('UP migration intentionally failed for testing')
|
|
723
|
+
}
|
|
724
|
+
return {
|
|
725
|
+
...record,
|
|
726
|
+
nickname: record.name.toUpperCase(),
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
down(record: any) {
|
|
730
|
+
// Simulate DOWN migration failure for specific data
|
|
731
|
+
if (record.nickname === 'FAIL_DOWN_MIGRATION') {
|
|
732
|
+
throw new Error('DOWN migration intentionally failed for testing')
|
|
733
|
+
}
|
|
734
|
+
const { nickname: _, ...rest } = record
|
|
735
|
+
return rest
|
|
736
|
+
},
|
|
737
|
+
},
|
|
738
|
+
],
|
|
739
|
+
}),
|
|
740
|
+
],
|
|
741
|
+
}
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
// Client schema (old version without nickname)
|
|
745
|
+
const clientSchemaWithoutNickname = StoreSchema.create<UserWithoutNickname>(
|
|
746
|
+
{ user_fail: UserWithoutNicknameType },
|
|
747
|
+
{
|
|
748
|
+
migrations: [
|
|
749
|
+
createMigrationSequence({
|
|
750
|
+
sequenceId: 'com.tldraw.user.failure',
|
|
751
|
+
sequence: [],
|
|
752
|
+
retroactive: true,
|
|
753
|
+
}),
|
|
754
|
+
],
|
|
755
|
+
}
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
it('rejects session when addDocument UP migration throws during PUT', () => {
|
|
759
|
+
const server = new TestServer(serverSchemaWithThrowingMigration)
|
|
760
|
+
|
|
761
|
+
const sessionId = 'failing-migration-session'
|
|
762
|
+
const socket = mockSocket<UserWithNickname>()
|
|
763
|
+
|
|
764
|
+
server.room.handleNewSession({
|
|
765
|
+
sessionId,
|
|
766
|
+
socket: socket as any,
|
|
767
|
+
meta: undefined,
|
|
768
|
+
isReadonly: false,
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
// Connect with old schema - should succeed since migration has down function
|
|
772
|
+
server.room.handleMessage(sessionId, {
|
|
773
|
+
type: 'connect',
|
|
774
|
+
connectRequestId: 'test',
|
|
775
|
+
lastServerClock: 0,
|
|
776
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
777
|
+
schema: clientSchemaWithoutNickname.serialize(),
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
|
|
781
|
+
;(socket.sendMessage as Mock).mockClear()
|
|
782
|
+
|
|
783
|
+
// Push a record that will cause the UP migration to throw
|
|
784
|
+
const failingRecord = UserWithoutNicknameType.create({
|
|
785
|
+
id: UserWithoutNicknameType.createId('fail'),
|
|
786
|
+
name: 'FAIL_UP_MIGRATION', // This name triggers the UP throw
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
server.room.handleMessage(sessionId, {
|
|
790
|
+
type: 'push',
|
|
791
|
+
clientClock: 1,
|
|
792
|
+
diff: {
|
|
793
|
+
[failingRecord.id]: [RecordOpType.Put, failingRecord as any],
|
|
794
|
+
},
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
// Session should be rejected due to migration failure (TLSyncError)
|
|
798
|
+
expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
it('successfully migrates records when migration does not throw', () => {
|
|
802
|
+
const server = new TestServer(serverSchemaWithThrowingMigration)
|
|
803
|
+
|
|
804
|
+
const sessionId = 'successful-migration-session'
|
|
805
|
+
const socket = mockSocket<UserWithNickname>()
|
|
806
|
+
|
|
807
|
+
server.room.handleNewSession({
|
|
808
|
+
sessionId,
|
|
809
|
+
socket: socket as any,
|
|
810
|
+
meta: undefined,
|
|
811
|
+
isReadonly: false,
|
|
812
|
+
})
|
|
813
|
+
|
|
814
|
+
// Connect with old schema
|
|
815
|
+
server.room.handleMessage(sessionId, {
|
|
816
|
+
type: 'connect',
|
|
817
|
+
connectRequestId: 'test',
|
|
818
|
+
lastServerClock: 0,
|
|
819
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
820
|
+
schema: clientSchemaWithoutNickname.serialize(),
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
|
|
824
|
+
;(socket.sendMessage as Mock).mockClear()
|
|
825
|
+
|
|
826
|
+
// Push a record that will NOT cause the migration to throw
|
|
827
|
+
const validRecord = UserWithoutNicknameType.create({
|
|
828
|
+
id: UserWithoutNicknameType.createId('valid'),
|
|
829
|
+
name: 'ValidName', // This name does not trigger the throw
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
server.room.handleMessage(sessionId, {
|
|
833
|
+
type: 'push',
|
|
834
|
+
clientClock: 1,
|
|
835
|
+
diff: {
|
|
836
|
+
[validRecord.id]: [RecordOpType.Put, validRecord as any],
|
|
837
|
+
},
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
// Session should still be connected
|
|
841
|
+
expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
|
|
842
|
+
|
|
843
|
+
// Record should be migrated and stored
|
|
844
|
+
const storedRecord = server.storage.documents.get(validRecord.id)?.state as UserWithNickname
|
|
845
|
+
expect(storedRecord).toBeDefined()
|
|
846
|
+
expect(storedRecord.nickname).toBe('VALIDNAME') // Uppercase from migration
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
it('rejects session when patchDocument DOWN migration throws', () => {
|
|
850
|
+
// Create a server with a document that has a nickname that will fail DOWN migration
|
|
851
|
+
const existingUser = UserWithNicknameType.create({
|
|
852
|
+
id: UserWithNicknameType.createId('existing_down_fail'),
|
|
853
|
+
name: 'ExistingUser',
|
|
854
|
+
nickname: 'FAIL_DOWN_MIGRATION', // This nickname triggers the DOWN throw
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
const server = new TestServer(serverSchemaWithThrowingMigration, {
|
|
858
|
+
clock: 10,
|
|
859
|
+
documents: [{ state: existingUser, lastChangedClock: 10 }],
|
|
860
|
+
schema: serverSchemaWithThrowingMigration.serialize(),
|
|
861
|
+
tombstones: {},
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
const sessionId = 'patch-down-migration-session'
|
|
865
|
+
const socket = mockSocket<UserWithNickname>()
|
|
866
|
+
|
|
867
|
+
server.room.handleNewSession({
|
|
868
|
+
sessionId,
|
|
869
|
+
socket: socket as any,
|
|
870
|
+
meta: undefined,
|
|
871
|
+
isReadonly: false,
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
// Connect with old schema
|
|
875
|
+
server.room.handleMessage(sessionId, {
|
|
876
|
+
type: 'connect',
|
|
877
|
+
connectRequestId: 'test',
|
|
878
|
+
lastServerClock: 10,
|
|
879
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
880
|
+
schema: clientSchemaWithoutNickname.serialize(),
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
|
|
884
|
+
;(socket.sendMessage as Mock).mockClear()
|
|
885
|
+
|
|
886
|
+
// Patch the existing record - the DOWN migration will fail because nickname is 'FAIL_DOWN_MIGRATION'
|
|
887
|
+
// This tests line 951 in TLSyncRoom.ts
|
|
888
|
+
server.room.handleMessage(sessionId, {
|
|
889
|
+
type: 'push',
|
|
890
|
+
clientClock: 1,
|
|
891
|
+
diff: {
|
|
892
|
+
[existingUser.id]: [RecordOpType.Patch, { name: [ValueOpType.Put, 'NewName'] }],
|
|
893
|
+
},
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
// Session should be rejected due to DOWN migration failure
|
|
897
|
+
expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
it('rejects session when patchDocument UP migration throws (after successful DOWN migration)', () => {
|
|
901
|
+
// Create a server with a document that will pass DOWN migration but fail UP migration
|
|
902
|
+
const existingUser = UserWithNicknameType.create({
|
|
903
|
+
id: UserWithNicknameType.createId('existing_up_fail'),
|
|
904
|
+
name: 'ExistingUser',
|
|
905
|
+
nickname: 'EXISTINGUSER', // Normal nickname, DOWN migration will succeed
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
const server = new TestServer(serverSchemaWithThrowingMigration, {
|
|
909
|
+
clock: 10,
|
|
910
|
+
documents: [{ state: existingUser, lastChangedClock: 10 }],
|
|
911
|
+
schema: serverSchemaWithThrowingMigration.serialize(),
|
|
912
|
+
tombstones: {},
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
const sessionId = 'patch-up-migration-session'
|
|
916
|
+
const socket = mockSocket<UserWithNickname>()
|
|
917
|
+
|
|
918
|
+
server.room.handleNewSession({
|
|
919
|
+
sessionId,
|
|
920
|
+
socket: socket as any,
|
|
921
|
+
meta: undefined,
|
|
922
|
+
isReadonly: false,
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
// Connect with old schema
|
|
926
|
+
server.room.handleMessage(sessionId, {
|
|
927
|
+
type: 'connect',
|
|
928
|
+
connectRequestId: 'test',
|
|
929
|
+
lastServerClock: 10,
|
|
930
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
931
|
+
schema: clientSchemaWithoutNickname.serialize(),
|
|
932
|
+
})
|
|
933
|
+
|
|
934
|
+
expect(server.room.sessions.get(sessionId)?.state).toBe('connected')
|
|
935
|
+
;(socket.sendMessage as Mock).mockClear()
|
|
936
|
+
|
|
937
|
+
// Patch the existing record - changes the name to 'FAIL_UP_MIGRATION'
|
|
938
|
+
// 1. DOWN migration: {name: 'ExistingUser', nickname: 'EXISTINGUSER'} -> {name: 'ExistingUser'} (succeeds)
|
|
939
|
+
// 2. Patch applied: {name: 'ExistingUser'} -> {name: 'FAIL_UP_MIGRATION'}
|
|
940
|
+
// 3. UP migration: {name: 'FAIL_UP_MIGRATION'} -> THROWS
|
|
941
|
+
// This tests line 972 in TLSyncRoom.ts
|
|
942
|
+
server.room.handleMessage(sessionId, {
|
|
943
|
+
type: 'push',
|
|
944
|
+
clientClock: 1,
|
|
945
|
+
diff: {
|
|
946
|
+
[existingUser.id]: [RecordOpType.Patch, { name: [ValueOpType.Put, 'FAIL_UP_MIGRATION'] }],
|
|
947
|
+
},
|
|
948
|
+
})
|
|
949
|
+
|
|
950
|
+
// Session should be rejected due to UP migration failure during patch
|
|
951
|
+
expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
952
|
+
})
|
|
953
|
+
})
|
|
954
|
+
|
|
673
955
|
describe('when the client is the same version', () => {
|
|
674
956
|
function setup() {
|
|
675
957
|
const steve = UserV2.create({
|
|
@@ -50,7 +50,12 @@ const schemaWithoutValidator = StoreSchema.create<Book | Presence>({
|
|
|
50
50
|
})
|
|
51
51
|
|
|
52
52
|
const disposables: Array<() => void> = []
|
|
53
|
+
let consoleSpy: ReturnType<typeof vi.spyOn>
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
56
|
+
})
|
|
53
57
|
afterEach(() => {
|
|
58
|
+
consoleSpy.mockRestore()
|
|
54
59
|
for (const dispose of disposables) {
|
|
55
60
|
dispose()
|
|
56
61
|
}
|
|
@@ -95,7 +100,7 @@ async function makeTestInstance() {
|
|
|
95
100
|
it('rejects invalid put operations that create a new document', async () => {
|
|
96
101
|
const { client, flush, onSyncError, server } = await makeTestInstance()
|
|
97
102
|
|
|
98
|
-
const prevServerDocs = server.
|
|
103
|
+
const prevServerDocs = server.storage.getSnapshot().documents
|
|
99
104
|
|
|
100
105
|
client.store.put([
|
|
101
106
|
{
|
|
@@ -109,20 +114,20 @@ it('rejects invalid put operations that create a new document', async () => {
|
|
|
109
114
|
|
|
110
115
|
expect(onSyncError).toHaveBeenCalledTimes(1)
|
|
111
116
|
expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
112
|
-
expect(server.
|
|
117
|
+
expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
|
|
113
118
|
})
|
|
114
119
|
|
|
115
120
|
it('rejects invalid put operations that replace an existing document', async () => {
|
|
116
121
|
const { client, flush, onSyncError, server } = await makeTestInstance()
|
|
117
122
|
|
|
118
|
-
let prevServerDocs = server.
|
|
123
|
+
let prevServerDocs = server.storage.getSnapshot().documents
|
|
119
124
|
const book: Book = { typeName: 'book', id: Book.createId('1'), title: 'Annihilation' }
|
|
120
125
|
client.store.put([book])
|
|
121
126
|
await flush()
|
|
122
127
|
|
|
123
128
|
expect(onSyncError).toHaveBeenCalledTimes(0)
|
|
124
|
-
expect(server.
|
|
125
|
-
prevServerDocs = server.
|
|
129
|
+
expect(server.storage.getSnapshot().documents).not.toStrictEqual(prevServerDocs)
|
|
130
|
+
prevServerDocs = server.storage.getSnapshot().documents
|
|
126
131
|
|
|
127
132
|
client.socket.sendMessage({
|
|
128
133
|
type: 'push',
|
|
@@ -143,13 +148,13 @@ it('rejects invalid put operations that replace an existing document', async ()
|
|
|
143
148
|
|
|
144
149
|
expect(onSyncError).toHaveBeenCalledTimes(1)
|
|
145
150
|
expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
146
|
-
expect(server.
|
|
151
|
+
expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
|
|
147
152
|
})
|
|
148
153
|
|
|
149
154
|
it('rejects invalid update operations', async () => {
|
|
150
155
|
const { client, flush, onSyncError, server } = await makeTestInstance()
|
|
151
156
|
|
|
152
|
-
let prevServerDocs = server.
|
|
157
|
+
let prevServerDocs = server.storage.getSnapshot().documents
|
|
153
158
|
|
|
154
159
|
// create the book
|
|
155
160
|
client.store.put([
|
|
@@ -162,8 +167,8 @@ it('rejects invalid update operations', async () => {
|
|
|
162
167
|
await flush()
|
|
163
168
|
|
|
164
169
|
expect(onSyncError).toHaveBeenCalledTimes(0)
|
|
165
|
-
expect(server.
|
|
166
|
-
prevServerDocs = server.
|
|
170
|
+
expect(server.storage.getSnapshot().documents).not.toStrictEqual(prevServerDocs)
|
|
171
|
+
prevServerDocs = server.storage.getSnapshot().documents
|
|
167
172
|
|
|
168
173
|
// update the title to be wrong
|
|
169
174
|
client.store.put([
|
|
@@ -177,5 +182,5 @@ it('rejects invalid update operations', async () => {
|
|
|
177
182
|
await flush()
|
|
178
183
|
expect(onSyncError).toHaveBeenCalledTimes(1)
|
|
179
184
|
expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
180
|
-
expect(server.
|
|
185
|
+
expect(server.storage.getSnapshot().documents).toStrictEqual(prevServerDocs)
|
|
181
186
|
})
|