UNPKG

@tldraw/store

Version:

tldraw infinite canvas SDK (store).

828 lines (680 loc) • 24.3 kB
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BaseRecord, RecordId } from './BaseRecord' import { RecordsDiff } from './RecordsDiff' import { createRecordType } from './RecordType' import { createComputedCache, Store } from './Store' import { StoreSchema } from './StoreSchema' // Test record types interface Book extends BaseRecord<'book', RecordId<Book>> { title: string author: RecordId<Author> numPages: number genre?: string inStock?: boolean } const Book = createRecordType<Book>('book', { validator: { validate: (record: unknown): Book => { if (!record || typeof record !== 'object') { throw new Error('Book record must be an object') } const r = record as any if (typeof r.id !== 'string' || !r.id.startsWith('book:')) { throw new Error('Book must have valid id starting with "book:"') } if (r.typeName !== 'book') { throw new Error('Book typeName must be "book"') } if (typeof r.title !== 'string' || r.title.trim().length === 0) { throw new Error('Book must have non-empty title string') } if (typeof r.author !== 'string' || !r.author.startsWith('author:')) { throw new Error('Book must have valid author RecordId') } if (typeof r.numPages !== 'number' || r.numPages < 1) { throw new Error('Book numPages must be positive number') } if (r.genre !== undefined && typeof r.genre !== 'string') { throw new Error('Book genre must be string if provided') } if (r.inStock !== undefined && typeof r.inStock !== 'boolean') { throw new Error('Book inStock must be boolean if provided') } return r as Book }, }, scope: 'document', }).withDefaultProperties(() => ({ inStock: true, numPages: 100, })) interface Author extends BaseRecord<'author', RecordId<Author>> { name: string isPseudonym: boolean birthYear?: number } const Author = createRecordType<Author>('author', { validator: { validate: (record: unknown): Author => { if (!record || typeof record !== 'object') { throw new Error('Author record must be an object') } const r = record as any if (typeof r.id !== 'string' || !r.id.startsWith('author:')) { throw new Error('Author must have valid id starting with "author:"') } if (r.typeName !== 'author') { throw new Error('Author typeName must be "author"') } if (typeof r.name !== 'string' || r.name.trim().length === 0) { throw new Error('Author must have non-empty name string') } if (typeof r.isPseudonym !== 'boolean') { throw new Error('Author isPseudonym must be boolean') } if ( r.birthYear !== undefined && (typeof r.birthYear !== 'number' || r.birthYear < 1000 || r.birthYear > 2100) ) { throw new Error('Author birthYear must be reasonable year number if provided') } return r as Author }, }, scope: 'document', }).withDefaultProperties(() => ({ isPseudonym: false, })) interface Visit extends BaseRecord<'visit', RecordId<Visit>> { visitorName: string booksInBasket: RecordId<Book>[] timestamp: number } const Visit = createRecordType<Visit>('visit', { validator: { validate: (record: unknown): Visit => { if (!record || typeof record !== 'object') { throw new Error('Visit record must be an object') } const r = record as any if (typeof r.id !== 'string' || !r.id.startsWith('visit:')) { throw new Error('Visit must have valid id starting with "visit:"') } if (r.typeName !== 'visit') { throw new Error('Visit typeName must be "visit"') } if (typeof r.visitorName !== 'string' || r.visitorName.trim().length === 0) { throw new Error('Visit must have non-empty visitorName string') } if (!Array.isArray(r.booksInBasket)) { throw new Error('Visit booksInBasket must be an array') } for (const bookId of r.booksInBasket) { if (typeof bookId !== 'string' || !bookId.startsWith('book:')) { throw new Error('Visit booksInBasket must contain valid book RecordIds') } } if (typeof r.timestamp !== 'number' || r.timestamp < 0) { throw new Error('Visit timestamp must be non-negative number') } return r as Visit }, }, scope: 'session', }).withDefaultProperties(() => ({ visitorName: 'Anonymous', booksInBasket: [], timestamp: Date.now(), })) interface Cursor extends BaseRecord<'cursor', RecordId<Cursor>> { x: number y: number userId: string } const Cursor = createRecordType<Cursor>('cursor', { validator: { validate: (record: unknown): Cursor => { if (!record || typeof record !== 'object') { throw new Error('Cursor record must be an object') } const r = record as any if (typeof r.id !== 'string' || !r.id.startsWith('cursor:')) { throw new Error('Cursor must have valid id starting with "cursor:"') } if (r.typeName !== 'cursor') { throw new Error('Cursor typeName must be "cursor"') } if (typeof r.x !== 'number' || !isFinite(r.x)) { throw new Error('Cursor x must be finite number') } if (typeof r.y !== 'number' || !isFinite(r.y)) { throw new Error('Cursor y must be finite number') } if (typeof r.userId !== 'string' || r.userId.trim().length === 0) { throw new Error('Cursor must have non-empty userId string') } return r as Cursor }, }, scope: 'presence', }) type LibraryType = Book | Author | Visit | Cursor describe('Store', () => { let store: Store<LibraryType> beforeEach(() => { store = new Store({ props: {}, schema: StoreSchema.create<LibraryType>({ book: Book, author: Author, visit: Visit, cursor: Cursor, }), }) }) afterEach(() => { store.dispose() }) describe('basic record operations', () => { it('puts records into the store', () => { const author = Author.create({ name: 'J.R.R. Tolkien' }) const book = Book.create({ title: 'The Hobbit', author: author.id, numPages: 310, }) store.put([author, book]) expect(store.get(author.id)).toEqual(author) expect(store.get(book.id)).toEqual(book) }) it('updates existing records', () => { const author = Author.create({ name: 'J.R.R. Tolkien' }) store.put([author]) const updatedAuthor = { ...author, name: 'John Ronald Reuel Tolkien' } store.put([updatedAuthor]) expect(store.get(author.id)?.name).toBe('John Ronald Reuel Tolkien') }) it('removes records from the store', () => { const author = Author.create({ name: 'J.R.R. Tolkien' }) const book = Book.create({ title: 'The Hobbit', author: author.id, numPages: 310, }) store.put([author, book]) expect(store.has(author.id)).toBe(true) expect(store.has(book.id)).toBe(true) store.remove([author.id, book.id]) expect(store.has(author.id)).toBe(false) expect(store.has(book.id)).toBe(false) }) it('updates records using update method', () => { const author = Author.create({ name: 'J.R.R. Tolkien' }) store.put([author]) store.update(author.id, (current) => ({ ...current, name: 'John Ronald Reuel Tolkien', })) expect(store.get(author.id)?.name).toBe('John Ronald Reuel Tolkien') }) it('clears all records', () => { const author = Author.create({ name: 'J.R.R. Tolkien' }) const book = Book.create({ title: 'The Hobbit', author: author.id, numPages: 310, }) store.put([author, book]) expect(store.allRecords()).toHaveLength(2) store.clear() expect(store.allRecords()).toHaveLength(0) }) }) describe('atomic operations', () => { it('performs atomic operations', () => { const author = Author.create({ name: 'J.R.R. Tolkien' }) const book = Book.create({ title: 'The Hobbit', author: author.id, numPages: 310, }) const result = store.atomic(() => { store.put([author]) store.put([book]) return 'completed' }) expect(result).toBe('completed') expect(store.get(author.id)).toEqual(author) expect(store.get(book.id)).toEqual(book) }) it('handles nested atomic operations', () => { const author = Author.create({ name: 'J.R.R. Tolkien' }) const book = Book.create({ title: 'The Hobbit', author: author.id, numPages: 310, }) store.atomic(() => { store.put([author]) store.atomic(() => { store.put([book]) }) }) expect(store.get(author.id)).toEqual(author) expect(store.get(book.id)).toEqual(book) }) }) describe('history tracking', () => { it('extracts changes from operations', () => { const author = Author.create({ name: 'J.R.R. Tolkien' }) const book = Book.create({ title: 'The Hobbit', author: author.id, numPages: 310, }) const changes = store.extractingChanges(() => { store.put([author, book]) }) expect(changes.added).toHaveProperty(author.id) expect(changes.added).toHaveProperty(book.id) expect(Object.keys(changes.updated)).toHaveLength(0) expect(Object.keys(changes.removed)).toHaveLength(0) }) it('extracts update changes', () => { const author = Author.create({ name: 'J.R.R. Tolkien' }) store.put([author]) const changes = store.extractingChanges(() => { store.update(author.id, (a) => ({ ...a, name: 'John Ronald Reuel Tolkien' })) }) expect(Object.keys(changes.added)).toHaveLength(0) expect(changes.updated).toHaveProperty(author.id) expect(Object.keys(changes.removed)).toHaveLength(0) }) it('extracts removal changes', () => { const author = Author.create({ name: 'J.R.R. Tolkien' }) store.put([author]) const changes = store.extractingChanges(() => { store.remove([author.id]) }) expect(Object.keys(changes.added)).toHaveLength(0) expect(Object.keys(changes.updated)).toHaveLength(0) expect(changes.removed).toHaveProperty(author.id) }) }) describe('listeners', () => { it('adds and removes listeners', async () => { const listener = vi.fn() const removeListener = store.listen(listener) const author = Author.create({ name: 'J.R.R. Tolkien' }) store.put([author]) // Wait for async history flush await new Promise((resolve) => requestAnimationFrame(resolve)) expect(listener).toHaveBeenCalledTimes(1) expect(listener).toHaveBeenCalledWith( expect.objectContaining({ source: 'user', changes: expect.objectContaining({ added: expect.objectContaining({ [author.id]: author, }), }), }) ) removeListener() const book = Book.create({ title: 'The Hobbit', author: author.id, numPages: 310, }) store.put([book]) // Wait for async history flush await new Promise((resolve) => requestAnimationFrame(resolve)) // Should still be called only once expect(listener).toHaveBeenCalledTimes(1) }) it('filters listeners by source', async () => { const userListener = vi.fn() const remoteListener = vi.fn() store.listen(userListener, { source: 'user', scope: 'all' }) store.listen(remoteListener, { source: 'remote', scope: 'all' }) const author = Author.create({ name: 'J.R.R. Tolkien' }) // User change store.put([author]) await new Promise((resolve) => requestAnimationFrame(resolve)) expect(userListener).toHaveBeenCalledTimes(1) expect(remoteListener).not.toHaveBeenCalled() // Remote change store.mergeRemoteChanges(() => { const book = Book.create({ title: 'The Hobbit', author: author.id, numPages: 310, }) store.put([book]) }) await new Promise((resolve) => requestAnimationFrame(resolve)) expect(userListener).toHaveBeenCalledTimes(1) expect(remoteListener).toHaveBeenCalledTimes(1) }) it('filters listeners by scope', async () => { const documentListener = vi.fn() const sessionListener = vi.fn() const presenceListener = vi.fn() store.listen(documentListener, { source: 'all', scope: 'document' }) store.listen(sessionListener, { source: 'all', scope: 'session' }) store.listen(presenceListener, { source: 'all', scope: 'presence' }) const author = Author.create({ name: 'J.R.R. Tolkien' }) // document scope const visit = Visit.create({ visitorName: 'John Doe' }) // session scope const cursor = Cursor.create({ x: 100, y: 200, userId: 'user1' }) // presence scope store.put([author, visit, cursor]) await new Promise((resolve) => requestAnimationFrame(resolve)) expect(documentListener).toHaveBeenCalledTimes(1) expect(sessionListener).toHaveBeenCalledTimes(1) expect(presenceListener).toHaveBeenCalledTimes(1) // Check that each listener only received records from their scope expect(documentListener.mock.calls[0][0].changes.added).toHaveProperty(author.id) expect(documentListener.mock.calls[0][0].changes.added).not.toHaveProperty(visit.id) expect(documentListener.mock.calls[0][0].changes.added).not.toHaveProperty(cursor.id) expect(sessionListener.mock.calls[0][0].changes.added).not.toHaveProperty(author.id) expect(sessionListener.mock.calls[0][0].changes.added).toHaveProperty(visit.id) expect(sessionListener.mock.calls[0][0].changes.added).not.toHaveProperty(cursor.id) expect(presenceListener.mock.calls[0][0].changes.added).not.toHaveProperty(author.id) expect(presenceListener.mock.calls[0][0].changes.added).not.toHaveProperty(visit.id) expect(presenceListener.mock.calls[0][0].changes.added).toHaveProperty(cursor.id) }) it('flushes history before adding listeners', async () => { const author = Author.create({ name: 'J.R.R. Tolkien' }) store.put([author]) // Add listener after changes const listener = vi.fn() store.listen(listener) // Should not receive historical changes await new Promise((resolve) => requestAnimationFrame(resolve)) expect(listener).not.toHaveBeenCalled() // Should receive new changes const book = Book.create({ title: 'The Hobbit', author: author.id, numPages: 310, }) store.put([book]) await new Promise((resolve) => requestAnimationFrame(resolve)) expect(listener).toHaveBeenCalledTimes(1) }) }) describe('remote changes', () => { it('merges remote changes with correct source', async () => { const listener = vi.fn() store.listen(listener) const author = Author.create({ name: 'J.R.R. Tolkien' }) store.mergeRemoteChanges(() => { store.put([author]) }) await new Promise((resolve) => requestAnimationFrame(resolve)) expect(listener).toHaveBeenCalledWith(expect.objectContaining({ source: 'remote' })) }) it('ensures store is usable after remote changes', () => { const author = Author.create({ name: 'J.R.R. Tolkien' }) store.mergeRemoteChanges(() => { store.put([author]) }) expect(store.get(author.id)).toEqual(author) }) it('throws error when merging remote changes during atomic operation', () => { expect(() => { store.atomic(() => { store.mergeRemoteChanges(() => { // This should throw }) }) }).toThrow('Cannot merge remote changes while in atomic operation') }) }) describe('serialization and snapshots', () => { beforeEach(() => { const author = Author.create({ name: 'J.R.R. Tolkien' }) const book = Book.create({ title: 'The Hobbit', author: author.id, numPages: 310, }) const visit = Visit.create({ visitorName: 'John Doe' }) const cursor = Cursor.create({ x: 100, y: 200, userId: 'user1' }) store.put([author, book, visit, cursor]) }) it('serializes store with document scope by default', () => { const serialized = store.serialize() const records = Object.values(serialized) // Should include document records only expect(records.some((r) => r.typeName === 'author')).toBe(true) expect(records.some((r) => r.typeName === 'book')).toBe(true) expect(records.some((r) => r.typeName === 'visit')).toBe(false) expect(records.some((r) => r.typeName === 'cursor')).toBe(false) }) it('serializes store with specific scope', () => { const sessionSerialized = store.serialize('session') const sessionRecords = Object.values(sessionSerialized) expect(sessionRecords.some((r) => r.typeName === 'visit')).toBe(true) expect(sessionRecords.some((r) => r.typeName === 'author')).toBe(false) }) it('serializes store with all scopes', () => { const allSerialized = store.serialize('all') const allRecords = Object.values(allSerialized) expect(allRecords.some((r) => r.typeName === 'author')).toBe(true) expect(allRecords.some((r) => r.typeName === 'book')).toBe(true) expect(allRecords.some((r) => r.typeName === 'visit')).toBe(true) expect(allRecords.some((r) => r.typeName === 'cursor')).toBe(true) }) it('creates and loads store snapshots', () => { const snapshot = store.getStoreSnapshot() expect(snapshot).toHaveProperty('store') expect(snapshot).toHaveProperty('schema') const newStore = new Store({ props: {}, schema: StoreSchema.create<LibraryType>({ book: Book, author: Author, visit: Visit, cursor: Cursor, }), }) newStore.loadStoreSnapshot(snapshot) // Should have same document records (default scope) const originalDocumentRecords = store.serialize('document') const newDocumentRecords = newStore.serialize('document') expect(newDocumentRecords).toEqual(originalDocumentRecords) newStore.dispose() }) it('migrates snapshots', () => { const snapshot = store.getStoreSnapshot() const migratedSnapshot = store.migrateSnapshot(snapshot) expect(migratedSnapshot).toEqual(snapshot) // No migrations needed }) it('throws error on migration failure', () => { const invalidSnapshot = { store: {}, schema: { version: -1 }, // Invalid version } as any expect(() => store.migrateSnapshot(invalidSnapshot)).toThrow() }) }) describe('computed caches', () => { it('creates and uses computed cache', () => { const computeExpensiveData = vi.fn((book: Book) => `expensive-${book.title}`) const cache = store.createComputedCache('expensiveBook', computeExpensiveData) const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien'), numPages: 310, }) store.put([book]) const result1 = cache.get(book.id) expect(result1).toBe('expensive-The Hobbit') expect(computeExpensiveData).toHaveBeenCalledTimes(1) // Should use cached result const result2 = cache.get(book.id) expect(result2).toBe('expensive-The Hobbit') expect(computeExpensiveData).toHaveBeenCalledTimes(1) }) it('invalidates cache when record changes', () => { const computeExpensiveData = vi.fn((book: Book) => `expensive-${book.title}`) const cache = store.createComputedCache('expensiveBook', computeExpensiveData) const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien'), numPages: 310, }) store.put([book]) cache.get(book.id) expect(computeExpensiveData).toHaveBeenCalledTimes(1) // Update the book store.update(book.id, (b) => ({ ...b, title: 'The Hobbit: Updated' })) // Should recompute const result = cache.get(book.id) expect(result).toBe('expensive-The Hobbit: Updated') expect(computeExpensiveData).toHaveBeenCalledTimes(2) }) }) describe('standalone createComputedCache', () => { it('works with store objects', () => { const derive = vi.fn( (context: { store: Store<LibraryType> }, book: Book) => `${book.title}-${context.store.id}` ) const cache = createComputedCache('standalone', derive) const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien'), numPages: 310, }) store.put([book]) const result = cache.get({ store }, book.id) expect(result).toBe(`The Hobbit-${store.id}`) expect(derive).toHaveBeenCalledTimes(1) }) it('works directly with store instances', () => { const derive = vi.fn((store: Store<LibraryType>, book: Book) => `${book.title}-${store.id}`) const cache = createComputedCache('standalone', derive) const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien'), numPages: 310, }) store.put([book]) const result = cache.get(store, book.id) expect(result).toBe(`The Hobbit-${store.id}`) expect(derive).toHaveBeenCalledTimes(1) }) }) describe('diff application', () => { it('applies diffs to the store', () => { const author = Author.create({ name: 'J.R.R. Tolkien' }) const book = Book.create({ title: 'The Hobbit', author: author.id, numPages: 310, }) const diff: RecordsDiff<LibraryType> = { added: { [author.id]: author, [book.id]: book, }, updated: {}, removed: {}, } store.applyDiff(diff) expect(store.get(author.id)).toEqual(author) expect(store.get(book.id)).toEqual(book) }) it('applies diffs with updates', () => { const author = Author.create({ name: 'J.R.R. Tolkien' }) store.put([author]) const updatedAuthor = { ...author, name: 'John Ronald Reuel Tolkien' } const diff: RecordsDiff<LibraryType> = { added: {}, updated: { [author.id]: [author, updatedAuthor], }, removed: {}, } store.applyDiff(diff) expect(store.get(author.id)?.name).toBe('John Ronald Reuel Tolkien') }) it('applies diffs with removals', () => { const author = Author.create({ name: 'J.R.R. Tolkien' }) store.put([author]) const diff: RecordsDiff<LibraryType> = { added: {}, updated: {}, removed: { [author.id]: author, }, } store.applyDiff(diff) expect(store.has(author.id)).toBe(false) }) }) describe('validation', () => { it('validates records during operations', () => { expect(() => { store.put([ { id: Book.createId('test1'), typeName: 'book', title: '', // Invalid: empty title author: Author.createId('tolkien'), numPages: 100, inStock: true, } as any, ]) }).toThrow('Book must have non-empty title string') }) }) describe('side effects integration', () => { it('integrates with side effects for put operations', () => { const beforeCreate = vi.fn((record: Author) => record) const afterCreate = vi.fn() store.sideEffects.registerBeforeCreateHandler('author', beforeCreate) store.sideEffects.registerAfterCreateHandler('author', afterCreate) const author = Author.create({ name: 'J.R.R. Tolkien' }) store.put([author]) expect(beforeCreate).toHaveBeenCalledWith(author, 'user') expect(afterCreate).toHaveBeenCalledWith(author, 'user') }) it('integrates with side effects for update operations', () => { const beforeChange = vi.fn((prev: Author, next: Author) => next) const afterChange = vi.fn() const author = Author.create({ name: 'J.R.R. Tolkien' }) store.put([author]) store.sideEffects.registerBeforeChangeHandler('author', beforeChange) store.sideEffects.registerAfterChangeHandler('author', afterChange) const updatedAuthor = { ...author, name: 'John Ronald Reuel Tolkien' } store.put([updatedAuthor]) expect(beforeChange).toHaveBeenCalledWith(author, updatedAuthor, 'user') expect(afterChange).toHaveBeenCalledWith(author, updatedAuthor, 'user') }) it('integrates with side effects for remove operations', () => { const beforeDelete = vi.fn() const afterDelete = vi.fn() const author = Author.create({ name: 'J.R.R. Tolkien' }) store.put([author]) store.sideEffects.registerBeforeDeleteHandler('author', beforeDelete) store.sideEffects.registerAfterDeleteHandler('author', afterDelete) store.remove([author.id]) expect(beforeDelete).toHaveBeenCalledWith(author, 'user') expect(afterDelete).toHaveBeenCalledWith(author, 'user') }) it('can prevent deletion with beforeDelete handler', () => { const beforeDelete = vi.fn().mockReturnValue(false) const afterDelete = vi.fn() store.sideEffects.registerBeforeDeleteHandler('author', beforeDelete) store.sideEffects.registerAfterDeleteHandler('author', afterDelete) const author = Author.create({ name: 'Protected Author' }) store.put([author]) // Try to delete - should be prevented store.remove([author.id]) expect(beforeDelete).toHaveBeenCalledWith(author, 'user') expect(afterDelete).not.toHaveBeenCalled() expect(store.has(author.id)).toBe(true) // Should still exist }) }) })