UNPKG

@tldraw/store

Version:

tldraw infinite canvas SDK (store).

628 lines (510 loc) • 18.1 kB
import { atom, computed, RESET_VALUE } from '@tldraw/state' import { beforeEach, describe, expect, it } from 'vitest' import { BaseRecord, RecordId } from './BaseRecord' import { createRecordType } from './RecordType' import { Store } from './Store' import { StoreQueries } from './StoreQueries' import { StoreSchema } from './StoreSchema' // Test record types interface Author extends BaseRecord<'author', RecordId<Author>> { name: string age: number isActive: boolean publishedBooks: number } interface Book extends BaseRecord<'book', RecordId<Book>> { title: string authorId: RecordId<Author> publishedYear: number inStock: boolean rating: number category: string } const Author = createRecordType<Author>('author', { validator: { validate(value) { const author = value as Author if (author.typeName !== 'author') throw Error('Invalid typeName') if (!author.id.startsWith('author:')) throw Error('Invalid id') if (!Number.isFinite(author.age)) throw Error('Invalid age') if (author.age < 0) throw Error('Negative age') if (typeof author.isActive !== 'boolean') throw Error('Invalid isActive') return author }, }, scope: 'document', }).withDefaultProperties(() => ({ age: 25, isActive: true, publishedBooks: 0 })) const Book = createRecordType<Book>('book', { validator: { validate(value) { const book = value as Book if (!book.id.startsWith('book:')) throw Error('Invalid book id') if (book.typeName !== 'book') throw Error('Invalid book typeName') if (typeof book.title !== 'string') throw Error('Invalid title') if (!book.authorId.startsWith('author')) throw Error('Invalid authorId') if (!Number.isFinite(book.publishedYear)) throw Error('Invalid publishedYear') if (typeof book.inStock !== 'boolean') throw Error('Invalid inStock') return book }, }, scope: 'document', }).withDefaultProperties(() => ({ rating: 0, category: 'fiction' })) // Test data const authors = { asimov: Author.create({ name: 'Isaac Asimov', age: 72, publishedBooks: 200 }), gibson: Author.create({ name: 'William Gibson', age: 75, publishedBooks: 15 }), herbert: Author.create({ name: 'Frank Herbert', age: 65, publishedBooks: 30 }), bradbury: Author.create({ name: 'Ray Bradbury', age: 91, publishedBooks: 100, isActive: false }), clarke: Author.create({ name: 'Arthur C. Clarke', age: 90, publishedBooks: 80 }), } const books = { foundation: Book.create({ title: 'Foundation', authorId: authors.asimov.id, publishedYear: 1951, inStock: true, rating: 5, category: 'sci-fi', }), neuromancer: Book.create({ title: 'Neuromancer', authorId: authors.gibson.id, publishedYear: 1984, inStock: true, rating: 5, category: 'cyberpunk', }), dune: Book.create({ title: 'Dune', authorId: authors.herbert.id, publishedYear: 1965, inStock: false, rating: 5, category: 'sci-fi', }), fahrenheit451: Book.create({ title: 'Fahrenheit 451', authorId: authors.bradbury.id, publishedYear: 1953, inStock: true, rating: 4, category: 'dystopian', }), childhood: Book.create({ title: "Childhood's End", authorId: authors.clarke.id, publishedYear: 1953, inStock: true, rating: 4, category: 'sci-fi', }), robotSeries: Book.create({ title: 'I, Robot', authorId: authors.asimov.id, publishedYear: 1950, inStock: false, rating: 4, category: 'sci-fi', }), } let store: Store<Author | Book> let queries: StoreQueries<Author | Book> beforeEach(() => { store = new Store({ props: {}, schema: StoreSchema.create<Author | Book>({ author: Author, book: Book, }), }) queries = store.query // Populate store with test data store.put([...Object.values(authors), ...Object.values(books)]) }) describe('filterHistory method', () => { it('should filter changes by type correctly', () => { const authorHistory = queries.filterHistory('author') const initialEpoch = authorHistory.get() // Add a new author const newAuthor = Author.create({ name: 'New Author', age: 40 }) store.put([newAuthor]) const afterAddEpoch = authorHistory.get() expect(afterAddEpoch).toBeGreaterThan(initialEpoch) // Add a book (should not affect author history) const newBook = Book.create({ title: 'New Book', authorId: newAuthor.id, publishedYear: 2023, inStock: true, rating: 3, }) const beforeBookAdd = authorHistory.lastChangedEpoch store.put([newBook]) // Author history should not change when book is added expect(authorHistory.lastChangedEpoch).toBe(beforeBookAdd) }) it('should handle record updates in filtered history', () => { const authorHistory = queries.filterHistory('author') authorHistory.get() const lastChangedEpoch = authorHistory.lastChangedEpoch // Update an author store.put([{ ...authors.asimov, age: 73 }]) // Access the computed to trigger update authorHistory.get() expect(lastChangedEpoch).toBeLessThan(authorHistory.lastChangedEpoch) const diff = authorHistory.getDiffSince(lastChangedEpoch) expect(diff).not.toBe(RESET_VALUE) if (diff !== RESET_VALUE) { expect(diff).toHaveLength(1) expect(diff[0].updated).toHaveProperty(authors.asimov.id) } }) it('should handle record removals in filtered history', () => { const authorHistory = queries.filterHistory('author') authorHistory.get() const lastChangedEpoch = authorHistory.lastChangedEpoch // Remove an author store.remove([authors.bradbury.id]) // Access the computed to trigger update authorHistory.get() expect(lastChangedEpoch).toBeLessThan(authorHistory.lastChangedEpoch) const diff = authorHistory.getDiffSince(lastChangedEpoch) expect(diff).not.toBe(RESET_VALUE) if (diff !== RESET_VALUE) { expect(diff).toHaveLength(1) expect(diff[0].removed).toHaveProperty(authors.bradbury.id) } }) it('should collapse add and remove operations in same batch', () => { const authorHistory = queries.filterHistory('author') authorHistory.get() const lastEpoch = authorHistory.lastChangedEpoch const tempAuthor = Author.create({ name: 'Temp Author' }) // Add and then remove in same batch store.put([tempAuthor]) store.remove([tempAuthor.id]) // Should not change history if add and remove cancel out const diff = authorHistory.getDiffSince(lastEpoch) expect(diff).toEqual([]) }) it('should handle complex diff sequences', () => { const authorHistory = queries.filterHistory('author') authorHistory.get() const lastEpoch = authorHistory.lastChangedEpoch // Multiple operations const tempAuthor1 = Author.create({ name: 'Temp 1' }) const tempAuthor2 = Author.create({ name: 'Temp 2' }) store.put([tempAuthor1]) store.put([tempAuthor2]) store.put([{ ...tempAuthor1, age: 50 }]) // Update store.remove([tempAuthor2.id]) // Remove const diff = authorHistory.getDiffSince(lastEpoch) expect(diff).not.toBe(RESET_VALUE) if (diff !== RESET_VALUE) { expect(diff).toHaveLength(1) // Should have tempAuthor1 as added (with updated age) expect(diff[0].added).toHaveProperty(tempAuthor1.id) // Should not have tempAuthor2 since it was added then removed expect(diff[0].added).not.toHaveProperty(tempAuthor2.id) expect(diff[0].removed).not.toHaveProperty(tempAuthor2.id) } }) }) describe('index method', () => { it('should create correct index mappings', () => { const nameIndex = queries.index('author', 'name') const index = nameIndex.get() expect(index.get('Isaac Asimov')).toEqual(new Set([authors.asimov.id])) expect(index.get('William Gibson')).toEqual(new Set([authors.gibson.id])) expect(index.get('Nonexistent')).toBeUndefined() }) it('should update indexes when records are added', () => { const nameIndex = queries.index('author', 'name') const _initialIndex = nameIndex.get() const newAuthor = Author.create({ name: 'New Author' }) const lastEpoch = nameIndex.lastChangedEpoch store.put([newAuthor]) // Access the index to trigger the update const updatedIndex = nameIndex.get() expect(nameIndex.lastChangedEpoch).toBeGreaterThan(lastEpoch) expect(updatedIndex.get('New Author')).toEqual(new Set([newAuthor.id])) const diff = nameIndex.getDiffSince(lastEpoch) expect(diff).not.toBe(RESET_VALUE) if (diff !== RESET_VALUE) { expect(diff).toHaveLength(1) expect(diff[0].get('New Author')).toEqual({ added: new Set([newAuthor.id]) }) } }) it('should update indexes when records are updated', () => { const nameIndex = queries.index('author', 'name') nameIndex.get() // Initialize const lastEpoch = nameIndex.lastChangedEpoch // Update author name store.put([{ ...authors.asimov, name: 'Dr. Isaac Asimov' }]) // Access the index to trigger the update const updatedIndex = nameIndex.get() expect(nameIndex.lastChangedEpoch).toBeGreaterThan(lastEpoch) expect(updatedIndex.get('Isaac Asimov')).toBeUndefined() expect(updatedIndex.get('Dr. Isaac Asimov')).toEqual(new Set([authors.asimov.id])) const diff = nameIndex.getDiffSince(lastEpoch) expect(diff).not.toBe(RESET_VALUE) if (diff !== RESET_VALUE) { expect(diff).toHaveLength(1) expect(diff[0].get('Isaac Asimov')).toEqual({ removed: new Set([authors.asimov.id]) }) expect(diff[0].get('Dr. Isaac Asimov')).toEqual({ added: new Set([authors.asimov.id]) }) } }) }) describe('record method', () => { it('should return single matching record', () => { const asimovQuery = queries.record('author', () => ({ name: { eq: 'Isaac Asimov' }, })) expect(asimovQuery.get()).toEqual(authors.asimov) }) it('should return undefined when no match found', () => { const nonexistentQuery = queries.record('author', () => ({ name: { eq: 'Nonexistent Author' }, })) expect(nonexistentQuery.get()).toBeUndefined() }) it('should update reactively when matching record changes', () => { const query = queries.record('author', () => ({ name: { eq: 'Isaac Asimov' }, })) expect(query.get()?.age).toBe(72) // Update the matching record store.put([{ ...authors.asimov, age: 73 }]) expect(query.get()?.age).toBe(73) }) it('should handle reactive query parameters', () => { const targetName = atom('targetName', 'Isaac Asimov') const query = queries.record('author', () => ({ name: { eq: targetName.get() }, })) expect(query.get()).toEqual(authors.asimov) targetName.set('William Gibson') expect(query.get()).toEqual(authors.gibson) }) it('should handle complex query conditions', () => { const query = queries.record('author', () => ({ age: { gt: 70 }, isActive: { eq: true }, })) const result = query.get() expect(result).toBeDefined() expect(result!.age).toBeGreaterThan(70) expect(result!.isActive).toBe(true) }) }) describe('records method', () => { it('should return all records of type when no query provided', () => { const allBooks = queries.records('book') const books = allBooks.get() expect(books).toHaveLength(6) expect(books.every((book) => book.typeName === 'book')).toBe(true) }) it('should filter records by query conditions', () => { const inStockBooks = queries.records('book', () => ({ inStock: { eq: true }, })) const books = inStockBooks.get() expect(books.every((book) => book.inStock === true)).toBe(true) expect(books.length).toBeGreaterThan(0) }) it('should handle multiple query conditions', () => { const query = queries.records('book', () => ({ category: { eq: 'sci-fi' }, rating: { eq: 5 }, })) const books = query.get() expect(books.every((book) => book.category === 'sci-fi' && book.rating === 5)).toBe(true) }) it('should update reactively when records are added', () => { const allBooks = queries.records('book') const initialCount = allBooks.get().length const newBook = Book.create({ title: 'New Book', authorId: authors.asimov.id, publishedYear: 2023, inStock: true, rating: 3, }) store.put([newBook]) expect(allBooks.get()).toHaveLength(initialCount + 1) expect(allBooks.get().some((book) => book.id === newBook.id)).toBe(true) }) it('should update reactively when records are removed', () => { const allBooks = queries.records('book') const initialCount = allBooks.get().length store.remove([books.dune.id]) expect(allBooks.get()).toHaveLength(initialCount - 1) expect(allBooks.get().some((book) => book.id === books.dune.id)).toBe(false) }) it('should update reactively when records are updated', () => { const inStockBooks = queries.records('book', () => ({ inStock: { eq: true }, })) const initialBooks = inStockBooks.get() const initialCount = initialBooks.length // Update a book to be out of stock store.put([{ ...books.foundation, inStock: false }]) const updatedBooks = inStockBooks.get() expect(updatedBooks).toHaveLength(initialCount - 1) expect(updatedBooks.some((book) => book.id === books.foundation.id)).toBe(false) }) it('should handle reactive query parameters', () => { const targetCategory = atom('targetCategory', 'sci-fi') const query = queries.records('book', () => ({ category: { eq: targetCategory.get() }, })) const sciFiBooks = query.get() expect(sciFiBooks.every((book) => book.category === 'sci-fi')).toBe(true) targetCategory.set('cyberpunk') const cyberpunkBooks = query.get() expect(cyberpunkBooks.every((book) => book.category === 'cyberpunk')).toBe(true) expect(cyberpunkBooks).toHaveLength(1) expect(cyberpunkBooks[0]).toEqual(books.neuromancer) }) }) describe('ids method', () => { it('should return set of all record IDs when no query provided', () => { const allBookIds = queries.ids('book') const ids = allBookIds.get() expect(ids).toBeInstanceOf(Set) expect(ids.size).toBe(6) Object.values(books).forEach((book) => { expect(ids.has(book.id)).toBe(true) }) }) it('should filter IDs by query conditions', () => { const highRatedIds = queries.ids('book', () => ({ rating: { eq: 5 }, })) const ids = highRatedIds.get() expect(ids).toBeInstanceOf(Set) // Verify all returned IDs match the criteria ids.forEach((id) => { const book = store.get(id)! expect(book.rating).toBe(5) }) }) it('should update with collection diffs when records change', () => { const inStockIds = queries.ids('book', () => ({ inStock: { eq: true }, })) const _initialIds = inStockIds.get() const lastEpoch = inStockIds.lastChangedEpoch // Add a new book const newBook = Book.create({ title: 'New In-Stock Book', authorId: authors.asimov.id, publishedYear: 2023, inStock: true, rating: 4, }) store.put([newBook]) const updatedIds = inStockIds.get() expect(updatedIds.has(newBook.id)).toBe(true) const diff = inStockIds.getDiffSince(lastEpoch) expect(diff).not.toBe(RESET_VALUE) if (diff !== RESET_VALUE) { expect(diff).toHaveLength(1) expect(diff[0].added?.has(newBook.id)).toBe(true) } }) it('should handle reactive query parameters with from-scratch rebuild', () => { const targetYear = atom('targetYear', 1950) const query = queries.ids('book', () => ({ publishedYear: { gt: targetYear.get() }, })) const initialIds = query.get() const initialSize = initialIds.size // Change query parameter should trigger from-scratch rebuild const lastEpoch = query.lastChangedEpoch targetYear.set(1970) const updatedIds = query.get() expect(query.lastChangedEpoch).toBeGreaterThan(lastEpoch) // Should have fewer results with higher year threshold expect(updatedIds.size).toBeLessThan(initialSize) updatedIds.forEach((id) => { const book = store.get(id)! expect(book.publishedYear).toBeGreaterThan(1970) }) }) it('should efficiently handle incremental updates', () => { const query = queries.ids('book', () => ({ inStock: { eq: true }, })) query.get() // Initialize const lastEpoch = query.lastChangedEpoch // Update a book's stock status store.put([{ ...books.dune, inStock: true }]) const diff = query.getDiffSince(lastEpoch) expect(diff).not.toBe(RESET_VALUE) if (diff !== RESET_VALUE) { expect(diff).toHaveLength(1) expect(diff[0].added?.has(books.dune.id)).toBe(true) } }) }) describe('exec method', () => { it('should execute non-reactive queries', () => { const result = queries.exec('author', { isActive: { eq: true }, }) expect(Array.isArray(result)).toBe(true) expect(result.every((author) => author.isActive === true)).toBe(true) }) it('should handle complex query conditions', () => { const result = queries.exec('book', { rating: { eq: 5 }, category: { eq: 'sci-fi' }, }) expect(result.every((book) => book.rating === 5 && book.category === 'sci-fi')).toBe(true) expect(result.length).toBeGreaterThan(0) }) }) describe('integration with reactive state', () => { it('should work with computed values', () => { const booksByCategory = computed('books-by-category', () => { const categoryIndex = queries.index('book', 'category') const sciFiBooks = categoryIndex.get().get('sci-fi') || new Set() return sciFiBooks.size }) const initialCount = booksByCategory.get() expect(initialCount).toBeGreaterThan(0) // Add a sci-fi book const newSciFiBook = Book.create({ title: 'New Sci-Fi', authorId: authors.asimov.id, publishedYear: 2023, inStock: true, rating: 4, category: 'sci-fi', }) store.put([newSciFiBook]) expect(booksByCategory.get()).toBe(initialCount + 1) }) it('should propagate changes through multiple reactive layers', () => { const authorBookCounts = computed('author-book-counts', () => { const authorIndex = queries.index('book', 'authorId') const counts = new Map() for (const [authorId, bookIds] of authorIndex.get()) { counts.set(authorId, bookIds.size) } return counts }) const asimovInitialCount = authorBookCounts.get().get(authors.asimov.id) || 0 // Add book by Asimov const newAsimovBook = Book.create({ title: 'New Asimov Book', authorId: authors.asimov.id, publishedYear: 2023, inStock: true, rating: 4, }) store.put([newAsimovBook]) expect(authorBookCounts.get().get(authors.asimov.id)).toBe(asimovInitialCount + 1) }) })