@tldraw/store
Version:
tldraw infinite canvas SDK (store).
828 lines (680 loc) • 24.3 kB
text/typescript
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
})
})
})