UNPKG

@tldraw/sync-core

Version:

tldraw infinite canvas SDK (multiplayer sync).

182 lines (160 loc) • 5.12 kB
import { computed } from '@tldraw/state' import { RecordId, Store, StoreSchema, UnknownRecord, createRecordType } from '@tldraw/store' import { vi } from 'vitest' import { TLSyncClient, TLSyncErrorCloseEventReason } from '../lib/TLSyncClient' import { RecordOpType } from '../lib/diff' import { TestServer } from './TestServer' import { TestSocketPair } from './TestSocketPair' // @ts-expect-error global.requestAnimationFrame = (cb: () => any) => { cb() } interface Book { typeName: 'book' id: RecordId<Book> title: string } const Book = createRecordType<Book>('book', { scope: 'document', validator: { validate: (record: unknown): Book => { if (typeof record !== 'object' || record === null) { throw new Error('Expected object') } if (!('title' in record)) { throw new Error('Expected title') } if (typeof record.title !== 'string') { throw new Error('Expected title to be a string') } return record as Book }, }, }) const BookWithoutValidator = createRecordType<Book>('book', { scope: 'document', validator: { validate: (record) => record as Book }, }) type Presence = UnknownRecord & { typeName: 'presence' } const presenceType = createRecordType<Presence>('presence', { scope: 'presence', validator: { validate: (record) => record as Presence }, }) const schema = StoreSchema.create<Book | Presence>({ book: Book, presence: presenceType }) const schemaWithoutValidator = StoreSchema.create<Book | Presence>({ book: BookWithoutValidator, presence: presenceType, }) const disposables: Array<() => void> = [] afterEach(() => { for (const dispose of disposables) { dispose() } disposables.length = 0 }) async function makeTestInstance() { const server = new TestServer(schema) const socketPair = new TestSocketPair('test', server) socketPair.connect() const flush = async () => { await Promise.resolve() while (socketPair.getNeedsFlushing()) { socketPair.flushClientSentEvents() socketPair.flushServerSentEvents() } } let onSyncError = vi.fn() const client = await new Promise<TLSyncClient<Book | Presence>>((resolve, reject) => { onSyncError = vi.fn(reject) const client = new TLSyncClient({ store: new Store<Book | Presence, unknown>({ schema: schemaWithoutValidator, props: {} }), socket: socketPair.clientSocket as any, onLoad: resolve, onSyncError, presence: computed('', () => null), }) disposables.push(() => client.close()) flush() }) return { server, socketPair, client, flush, onSyncError, } } it('rejects invalid put operations that create a new document', async () => { const { client, flush, onSyncError, server } = await makeTestInstance() const prevServerDocs = server.room.getSnapshot().documents client.store.put([ { typeName: 'book', id: Book.createId('1'), // @ts-expect-error - deliberate invalid data title: 123 as string, }, ]) await flush() expect(onSyncError).toHaveBeenCalledTimes(1) expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD) expect(server.room.getSnapshot().documents).toStrictEqual(prevServerDocs) }) it('rejects invalid put operations that replace an existing document', async () => { const { client, flush, onSyncError, server } = await makeTestInstance() let prevServerDocs = server.room.getSnapshot().documents const book: Book = { typeName: 'book', id: Book.createId('1'), title: 'Annihilation' } client.store.put([book]) await flush() expect(onSyncError).toHaveBeenCalledTimes(0) expect(server.room.getSnapshot().documents).not.toStrictEqual(prevServerDocs) prevServerDocs = server.room.getSnapshot().documents client.socket.sendMessage({ type: 'push', // @ts-expect-error clientClock is private clientClock: client.clientClock++, diff: { [book.id]: [ RecordOpType.Put, { ...book, // @ts-expect-error - deliberate invalid data title: 123 as string, }, ], }, }) await flush() expect(onSyncError).toHaveBeenCalledTimes(1) expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD) expect(server.room.getSnapshot().documents).toStrictEqual(prevServerDocs) }) it('rejects invalid update operations', async () => { const { client, flush, onSyncError, server } = await makeTestInstance() let prevServerDocs = server.room.getSnapshot().documents // create the book client.store.put([ { typeName: 'book', id: Book.createId('1'), title: 'The silence of the girls', }, ]) await flush() expect(onSyncError).toHaveBeenCalledTimes(0) expect(server.room.getSnapshot().documents).not.toStrictEqual(prevServerDocs) prevServerDocs = server.room.getSnapshot().documents // update the title to be wrong client.store.put([ { typeName: 'book', id: Book.createId('1'), // @ts-expect-error - deliberate invalid data title: 123 as string, }, ]) await flush() expect(onSyncError).toHaveBeenCalledTimes(1) expect(onSyncError).toHaveBeenLastCalledWith(TLSyncErrorCloseEventReason.INVALID_RECORD) expect(server.room.getSnapshot().documents).toStrictEqual(prevServerDocs) })