snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
892
packages/store/src/lib/StoreQueries.test.ts
Normal file
892
packages/store/src/lib/StoreQueries.test.ts
Normal file
@@ -0,0 +1,892 @@
|
||||
import { atom, computed, EMPTY_ARRAY, 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 { StoreSchema } from './StoreSchema'
|
||||
|
||||
// Tests for SPEC.md §11 (filtered history), §12 (indexes), and §13 (id, record, and exec
|
||||
// queries). Rule IDs like [QI3] in test names refer to that document.
|
||||
|
||||
interface Author extends BaseRecord<'author', RecordId<Author>> {
|
||||
name: string
|
||||
age: number
|
||||
}
|
||||
const Author = createRecordType<Author>('author', {
|
||||
validator: {
|
||||
validate(value) {
|
||||
const author = value as Author
|
||||
if (author.typeName !== 'author') throw Error()
|
||||
if (!author.id.startsWith('author:')) throw Error()
|
||||
if (!Number.isFinite(author.age)) throw Error()
|
||||
if (author.age < 0) throw Error()
|
||||
return author
|
||||
},
|
||||
},
|
||||
scope: 'document',
|
||||
}).withDefaultProperties(() => ({ age: 23 }))
|
||||
|
||||
interface Book extends BaseRecord<'book', RecordId<Book>> {
|
||||
title: string
|
||||
authorId: RecordId<Author>
|
||||
}
|
||||
const Book = createRecordType<Book>('book', {
|
||||
validator: {
|
||||
validate(value) {
|
||||
const book = value as Book
|
||||
if (!book.id.startsWith('book:')) throw Error()
|
||||
if (book.typeName !== 'book') throw Error()
|
||||
if (typeof book.title !== 'string') throw Error()
|
||||
if (!book.authorId.startsWith('author')) throw Error()
|
||||
return book
|
||||
},
|
||||
},
|
||||
scope: 'document',
|
||||
})
|
||||
|
||||
const authors = {
|
||||
tolkein: Author.create({ name: 'J.R.R. Tolkein' }),
|
||||
bradbury: Author.create({ name: 'Ray Bradbury' }),
|
||||
davidMitchellSerious: Author.create({ name: 'David Mitchell' }),
|
||||
davidMitchellFunny: Author.create({ name: 'David Mitchell' }),
|
||||
}
|
||||
|
||||
const books = {
|
||||
cloudAtlas: Book.create({ title: 'Cloud Atlas', authorId: authors.davidMitchellSerious.id }),
|
||||
myLifeInComedy: Book.create({
|
||||
title: 'My Life in Comedy',
|
||||
authorId: authors.davidMitchellFunny.id,
|
||||
}),
|
||||
lotr: Book.create({ title: 'Lord of the Rings', authorId: authors.tolkein.id }),
|
||||
farenheit: Book.create({ title: 'Farenheit 451', authorId: authors.bradbury.id }),
|
||||
}
|
||||
|
||||
let store: Store<Author | Book>
|
||||
|
||||
beforeEach(() => {
|
||||
store = new Store({
|
||||
props: {},
|
||||
schema: StoreSchema.create<Author | Book>({
|
||||
author: Author,
|
||||
book: Book,
|
||||
}),
|
||||
})
|
||||
store.put([
|
||||
authors.tolkein,
|
||||
authors.bradbury,
|
||||
books.lotr,
|
||||
books.farenheit,
|
||||
authors.davidMitchellFunny,
|
||||
authors.davidMitchellSerious,
|
||||
books.cloudAtlas,
|
||||
books.myLifeInComedy,
|
||||
])
|
||||
})
|
||||
|
||||
describe('filtered history (QH)', () => {
|
||||
it('[QH1] is cached per type name', () => {
|
||||
expect(store.query.filterHistory('author')).toBe(store.query.filterHistory('author'))
|
||||
})
|
||||
|
||||
it('[QH1] contains only changes to records of the given type', () => {
|
||||
const authorHistory = store.query.filterHistory('author')
|
||||
|
||||
authorHistory.get()
|
||||
|
||||
let lastChangedEpoch = authorHistory.lastChangedEpoch
|
||||
|
||||
expect(authorHistory.getDiffSince(lastChangedEpoch - 1)).toBe(RESET_VALUE)
|
||||
|
||||
// updating an author changes the history
|
||||
store.put([{ ...authors.davidMitchellFunny, age: 30 }])
|
||||
|
||||
authorHistory.get()
|
||||
|
||||
expect(lastChangedEpoch).toBeLessThan(authorHistory.lastChangedEpoch)
|
||||
|
||||
expect(authorHistory.getDiffSince(lastChangedEpoch)).toMatchObject([
|
||||
{ updated: { [authors.davidMitchellFunny.id]: [{ age: 23 }, { age: 30 }] } },
|
||||
])
|
||||
|
||||
lastChangedEpoch = authorHistory.lastChangedEpoch
|
||||
|
||||
// adding and removing authors shows up too
|
||||
const newAuthor = Author.create({ name: 'Stanley Briggs' })
|
||||
store.put([newAuthor])
|
||||
store.remove([authors.bradbury.id])
|
||||
|
||||
expect(authorHistory.getDiffSince(lastChangedEpoch)).toMatchObject([
|
||||
{
|
||||
added: { [newAuthor.id]: { name: 'Stanley Briggs' } },
|
||||
removed: { [authors.bradbury.id]: { name: 'Ray Bradbury' } },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('[QH3] changes to other types produce no observable change', () => {
|
||||
const authorHistory = store.query.filterHistory('author')
|
||||
authorHistory.get()
|
||||
|
||||
const lastChangedEpoch = authorHistory.lastChangedEpoch
|
||||
|
||||
store.put([
|
||||
{ ...books.lotr, title: 'The Lord of the Rings Part I: The Fellowship of the Ring' },
|
||||
])
|
||||
|
||||
authorHistory.get()
|
||||
|
||||
expect(authorHistory.lastChangedEpoch).toEqual(lastChangedEpoch)
|
||||
expect(authorHistory.getDiffSince(lastChangedEpoch)).toEqual([])
|
||||
})
|
||||
|
||||
it('[QH2] changes that cancel out in a window produce no change', () => {
|
||||
const authorHistory = store.query.filterHistory('author')
|
||||
|
||||
const epoch = authorHistory.get()
|
||||
const newAuthor = Author.create({ name: 'Stanley Briggs' })
|
||||
|
||||
store.put([newAuthor])
|
||||
store.put([{ ...newAuthor, age: 38 }])
|
||||
store.remove([newAuthor.id])
|
||||
|
||||
expect(authorHistory.get()).toEqual(epoch)
|
||||
|
||||
store.remove([authors.tolkein.id])
|
||||
store.put([authors.tolkein])
|
||||
|
||||
expect(authorHistory.get()).toEqual(epoch)
|
||||
})
|
||||
|
||||
it('[QH2] update entries are removed when the record is deleted', () => {
|
||||
const authorHistory = store.query.filterHistory('author')
|
||||
|
||||
authorHistory.get()
|
||||
|
||||
const lastChangedEpoch = authorHistory.lastChangedEpoch
|
||||
|
||||
store.put([{ ...authors.davidMitchellFunny, age: 38 }])
|
||||
store.put([{ ...authors.davidMitchellFunny, age: 343 }])
|
||||
store.remove([authors.davidMitchellFunny.id])
|
||||
|
||||
expect(authorHistory.getDiffSince(lastChangedEpoch)).toMatchObject([
|
||||
{
|
||||
// shows the original, not the updated version
|
||||
removed: { [authors.davidMitchellFunny.id]: { age: 23 } },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('[QH2] multiple update entries collapse into one', () => {
|
||||
const authorHistory = store.query.filterHistory('author')
|
||||
|
||||
authorHistory.get()
|
||||
|
||||
const lastChangedEpoch = authorHistory.lastChangedEpoch
|
||||
|
||||
store.put([{ ...authors.davidMitchellFunny, age: 38 }])
|
||||
store.put([{ ...authors.davidMitchellFunny, age: 343 }])
|
||||
|
||||
expect(authorHistory.getDiffSince(lastChangedEpoch)).toMatchObject([
|
||||
{
|
||||
updated: { [authors.davidMitchellFunny.id]: [{ age: 23 }, { age: 343 }] },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('[QH2] an add followed by an update folds into the add', () => {
|
||||
const authorHistory = store.query.filterHistory('author')
|
||||
|
||||
authorHistory.get()
|
||||
|
||||
const lastChangedEpoch = authorHistory.lastChangedEpoch
|
||||
|
||||
const newAuthor = Author.create({ name: 'Stanley Briggs' })
|
||||
|
||||
store.put([newAuthor])
|
||||
store.put([{ ...newAuthor, age: 38 }])
|
||||
|
||||
expect(authorHistory.getDiffSince(lastChangedEpoch)).toMatchObject([
|
||||
{
|
||||
added: { [newAuthor.id]: { age: 38 } },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('[QH2] squashes matching records while ignoring interleaved record types', () => {
|
||||
const authorHistory = store.query.filterHistory('author')
|
||||
authorHistory.get()
|
||||
|
||||
const lastChangedEpoch = authorHistory.lastChangedEpoch
|
||||
const newAuthor = Author.create({ name: 'Stanley Briggs' })
|
||||
const updatedNewAuthor = { ...newAuthor, age: 38 }
|
||||
|
||||
store.put([newAuthor])
|
||||
store.put([updatedNewAuthor, { ...books.lotr, title: 'The Fellowship of the Ring' }])
|
||||
store.put([{ ...authors.davidMitchellFunny, age: 38 }])
|
||||
store.remove([authors.davidMitchellFunny.id])
|
||||
store.remove([authors.tolkein.id])
|
||||
store.put([authors.tolkein])
|
||||
|
||||
expect(authorHistory.getDiffSince(lastChangedEpoch)).toEqual([
|
||||
{
|
||||
added: { [newAuthor.id]: updatedNewAuthor },
|
||||
updated: {},
|
||||
removed: { [authors.davidMitchellFunny.id]: authors.davidMitchellFunny },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('indexes (QI)', () => {
|
||||
it('[QI2] are cached per type and property', () => {
|
||||
expect(store.query.index('author', 'name')).toBe(store.query.index('author', 'name'))
|
||||
expect(store.query.index('author', 'name')).not.toBe(store.query.index('author', 'age'))
|
||||
})
|
||||
|
||||
it('[QI1] map each property value to the ids of records with that value', () => {
|
||||
const authorNameIndex = store.query.index('author', 'name')
|
||||
|
||||
expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(new Set([authors.tolkein.id]))
|
||||
expect(authorNameIndex.get().get('David Mitchell')).toEqual(
|
||||
new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id])
|
||||
)
|
||||
expect(authorNameIndex.get().get('Nonexistent')).toBeUndefined()
|
||||
|
||||
const bookTitleIndex = store.query.index('book', 'title')
|
||||
expect(bookTitleIndex.get().get('Cloud Atlas')).toEqual(new Set([books.cloudAtlas.id]))
|
||||
expect(bookTitleIndex.get().get('Lord of the Rings')).toEqual(new Set([books.lotr.id]))
|
||||
})
|
||||
|
||||
it('[QI1] records whose value is undefined are not indexed', () => {
|
||||
const newAuthor = { ...Author.create({ name: 'No Name' }), name: undefined } as any
|
||||
const index = store.query.index('author', 'name')
|
||||
store.put([newAuthor])
|
||||
expect(index.get().get(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('[QI3] adding records updates the index, with diffs', () => {
|
||||
const authorNameIndex = store.query.index('author', 'name')
|
||||
// deref to make it compute once
|
||||
expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(new Set([authors.tolkein.id]))
|
||||
|
||||
let lastChangedEpoch = authorNameIndex.lastChangedEpoch
|
||||
const newAuthor = Author.create({ name: 'New Author' })
|
||||
|
||||
store.put([newAuthor])
|
||||
|
||||
expect(authorNameIndex.get().get('New Author')).toEqual(new Set([newAuthor.id]))
|
||||
|
||||
const diff = authorNameIndex.getDiffSince(lastChangedEpoch)
|
||||
if (diff === RESET_VALUE) throw new Error('should not be reset')
|
||||
expect(diff).toHaveLength(1)
|
||||
expect(diff[0].get('New Author')).toEqual({
|
||||
added: new Set([newAuthor.id]),
|
||||
})
|
||||
expect(diff[0].size).toBe(1)
|
||||
|
||||
const moreNewAuthors = [
|
||||
Author.create({ name: 'New Author' }),
|
||||
Author.create({ name: 'New Author' }),
|
||||
Author.create({ name: 'New Author' }),
|
||||
]
|
||||
|
||||
lastChangedEpoch = authorNameIndex.lastChangedEpoch
|
||||
store.put(moreNewAuthors)
|
||||
|
||||
expect(authorNameIndex.get().get('New Author')).toEqual(
|
||||
new Set([newAuthor.id, ...moreNewAuthors.map((a) => a.id)])
|
||||
)
|
||||
|
||||
const diff2 = authorNameIndex.getDiffSince(lastChangedEpoch)
|
||||
|
||||
if (diff2 === RESET_VALUE) throw new Error('should not be reset')
|
||||
|
||||
expect(diff2).toHaveLength(1)
|
||||
expect(diff2[0].get('New Author')).toEqual({ added: new Set(moreNewAuthors.map((a) => a.id)) })
|
||||
})
|
||||
|
||||
it('[QI3] updating a record moves its id between value sets', () => {
|
||||
const authorNameIndex = store.query.index('author', 'name')
|
||||
|
||||
expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(new Set([authors.tolkein.id]))
|
||||
|
||||
const lastChangedEpoch = authorNameIndex.lastChangedEpoch
|
||||
|
||||
store.put([{ ...authors.bradbury, name: 'J.R.R. Tolkein' }])
|
||||
|
||||
expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(
|
||||
new Set([authors.tolkein.id, authors.bradbury.id])
|
||||
)
|
||||
|
||||
const diff = authorNameIndex.getDiffSince(lastChangedEpoch)
|
||||
if (diff === RESET_VALUE) throw new Error('should not be reset')
|
||||
expect(diff).toHaveLength(1)
|
||||
expect(diff[0].size).toBe(2)
|
||||
expect(diff[0].get('J.R.R. Tolkein')).toEqual({
|
||||
added: new Set([authors.bradbury.id]),
|
||||
})
|
||||
expect(diff[0].get('Ray Bradbury')).toEqual({
|
||||
removed: new Set([authors.bradbury.id]),
|
||||
})
|
||||
})
|
||||
|
||||
it('[QI3] removing records updates the index; empty value sets are dropped', () => {
|
||||
const authorNameIndex = store.query.index('author', 'name')
|
||||
|
||||
expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(new Set([authors.tolkein.id]))
|
||||
|
||||
let lastChangedEpoch = authorNameIndex.lastChangedEpoch
|
||||
|
||||
store.remove([authors.tolkein.id])
|
||||
|
||||
expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(undefined)
|
||||
|
||||
const diff = authorNameIndex.getDiffSince(lastChangedEpoch)
|
||||
if (diff === RESET_VALUE) throw new Error('should not be reset')
|
||||
expect(diff).toHaveLength(1)
|
||||
expect(diff[0].size).toBe(1)
|
||||
expect(diff[0].get('J.R.R. Tolkein')).toEqual({
|
||||
removed: new Set([authors.tolkein.id]),
|
||||
})
|
||||
|
||||
lastChangedEpoch = authorNameIndex.lastChangedEpoch
|
||||
store.remove([
|
||||
authors.bradbury.id,
|
||||
authors.davidMitchellFunny.id,
|
||||
authors.davidMitchellSerious.id,
|
||||
])
|
||||
|
||||
expect(authorNameIndex.get().get('Ray Bradbury')).toEqual(undefined)
|
||||
expect(authorNameIndex.get().get('David Mitchell')).toEqual(undefined)
|
||||
|
||||
const diff2 = authorNameIndex.getDiffSince(lastChangedEpoch)
|
||||
|
||||
if (diff2 === RESET_VALUE) throw new Error('should not be reset')
|
||||
|
||||
expect(diff2).toHaveLength(1)
|
||||
expect(diff2[0].size).toBe(2)
|
||||
|
||||
expect(diff2[0].get('Ray Bradbury')).toEqual({
|
||||
removed: new Set([authors.bradbury.id]),
|
||||
})
|
||||
expect(diff2[0].get('David Mitchell')).toEqual({
|
||||
removed: new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id]),
|
||||
})
|
||||
})
|
||||
|
||||
it('[QI3] removals and additions for the same value combine in one diff', () => {
|
||||
const authorNameIndex = store.query.index('author', 'name')
|
||||
expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(new Set([authors.tolkein.id]))
|
||||
|
||||
let lastChangedEpoch = authorNameIndex.lastChangedEpoch
|
||||
|
||||
// do remove first
|
||||
store.remove([authors.tolkein.id])
|
||||
const newAuthor = Author.create({ name: 'J.R.R. Tolkein' })
|
||||
store.put([newAuthor])
|
||||
|
||||
expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(new Set([newAuthor.id]))
|
||||
|
||||
const diff = authorNameIndex.getDiffSince(lastChangedEpoch)
|
||||
|
||||
if (diff === RESET_VALUE) throw new Error('should not be reset')
|
||||
|
||||
expect(diff).toHaveLength(1)
|
||||
expect(diff[0].size).toBe(1)
|
||||
|
||||
expect(diff[0].get('J.R.R. Tolkein')).toEqual({
|
||||
removed: new Set([authors.tolkein.id]),
|
||||
added: new Set([newAuthor.id]),
|
||||
})
|
||||
|
||||
lastChangedEpoch = authorNameIndex.lastChangedEpoch
|
||||
// do updates
|
||||
store.put([{ ...authors.davidMitchellFunny, name: 'Ray Bradbury' }])
|
||||
store.put([{ ...authors.bradbury, name: 'David Mitchell' }])
|
||||
|
||||
expect(authorNameIndex.get().get('Ray Bradbury')).toEqual(
|
||||
new Set([authors.davidMitchellFunny.id])
|
||||
)
|
||||
expect(authorNameIndex.get().get('David Mitchell')).toEqual(
|
||||
new Set([authors.bradbury.id, authors.davidMitchellSerious.id])
|
||||
)
|
||||
|
||||
const diff2 = authorNameIndex.getDiffSince(lastChangedEpoch)
|
||||
|
||||
if (diff2 === RESET_VALUE) throw new Error('should not be reset')
|
||||
|
||||
expect(diff2).toHaveLength(1)
|
||||
expect(diff2[0].size).toBe(2)
|
||||
|
||||
expect(diff2[0].get('Ray Bradbury')).toEqual({
|
||||
added: new Set([authors.davidMitchellFunny.id]),
|
||||
removed: new Set([authors.bradbury.id]),
|
||||
})
|
||||
expect(diff2[0].get('David Mitchell')).toEqual({
|
||||
added: new Set([authors.bradbury.id]),
|
||||
removed: new Set([authors.davidMitchellFunny.id]),
|
||||
})
|
||||
})
|
||||
|
||||
it('[QI4] keeps the same map object if the indexed values did not change', () => {
|
||||
const authorNameIndex = store.query.index('author', 'name')
|
||||
|
||||
const value = authorNameIndex.get()
|
||||
const lastChangedEpoch = authorNameIndex.lastChangedEpoch
|
||||
|
||||
// change a property the index does not cover
|
||||
store.put([{ ...authors.tolkein, age: 23 }])
|
||||
store.put([{ ...authors.tolkein, age: 99 }])
|
||||
|
||||
expect(authorNameIndex.get()).toBe(value)
|
||||
expect(authorNameIndex.lastChangedEpoch).toBe(lastChangedEpoch)
|
||||
})
|
||||
|
||||
it('[QI5] a backslash-delimited property indexes a nested path', () => {
|
||||
interface Doc extends BaseRecord<'doc', RecordId<Doc>> {
|
||||
metadata: { sessionId?: string }
|
||||
}
|
||||
const Doc = createRecordType<Doc>('doc', { scope: 'document' })
|
||||
const docStore = new Store({
|
||||
props: {},
|
||||
schema: StoreSchema.create<Doc>({ doc: Doc }),
|
||||
})
|
||||
|
||||
const docA = Doc.create({ metadata: { sessionId: 'alpha' } })
|
||||
const docB = Doc.create({ metadata: { sessionId: 'beta' } })
|
||||
const docNone = Doc.create({ metadata: {} })
|
||||
docStore.put([docA, docB, docNone])
|
||||
|
||||
const index = docStore.query.index('doc', 'metadata\\sessionId')
|
||||
expect(index.get().get('alpha')).toEqual(new Set([docA.id]))
|
||||
expect(index.get().get('beta')).toEqual(new Set([docB.id]))
|
||||
// missing nested values are not indexed
|
||||
expect(index.get().get(undefined)).toBeUndefined()
|
||||
|
||||
// incremental updates work through the nested path
|
||||
docStore.update(docA.id, (d) => ({ ...d, metadata: { sessionId: 'beta' } }))
|
||||
expect(index.get().get('alpha')).toBeUndefined()
|
||||
expect(index.get().get('beta')).toEqual(new Set([docB.id, docA.id]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('id queries (QQ)', () => {
|
||||
it('[QQ1] with no query, contains all ids of the type', () => {
|
||||
const bookQuery = store.query.ids('book')
|
||||
|
||||
expect(bookQuery.get()).toEqual(
|
||||
new Set([books.cloudAtlas.id, books.farenheit.id, books.lotr.id, books.myLifeInComedy.id])
|
||||
)
|
||||
|
||||
const authorQuery = store.query.ids('author')
|
||||
|
||||
expect(authorQuery.get()).toEqual(
|
||||
new Set([
|
||||
authors.bradbury.id,
|
||||
authors.davidMitchellFunny.id,
|
||||
authors.davidMitchellSerious.id,
|
||||
authors.tolkein.id,
|
||||
])
|
||||
)
|
||||
|
||||
const newAuthor = Author.create({ name: 'J.R.R. Tolkein' })
|
||||
const newBook = Book.create({ title: 'The Hobbit', authorId: newAuthor.id })
|
||||
store.put([newAuthor, newBook])
|
||||
|
||||
expect(bookQuery.get()).toContain(newBook.id)
|
||||
expect(authorQuery.get()).toContain(newAuthor.id)
|
||||
})
|
||||
|
||||
it('[QQ1] filters ids by the query expression', () => {
|
||||
const jrr = store.query.ids('author', () => ({
|
||||
name: { eq: 'J.R.R. Tolkein' },
|
||||
}))
|
||||
expect(jrr.get()).toEqual(new Set([authors.tolkein.id]))
|
||||
|
||||
const mitchell = store.query.ids('author', () => ({
|
||||
name: { eq: 'David Mitchell' },
|
||||
}))
|
||||
expect(mitchell.get()).toEqual(
|
||||
new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id])
|
||||
)
|
||||
|
||||
store.put([{ ...authors.davidMitchellFunny, age: 30 }])
|
||||
const mitchell30 = store.query.ids('author', () => ({
|
||||
name: { eq: 'David Mitchell' },
|
||||
age: { eq: 30 },
|
||||
}))
|
||||
expect(mitchell30.get()).toEqual(new Set([authors.davidMitchellFunny.id]))
|
||||
})
|
||||
|
||||
it('[QQ1] emits collection diffs as records change', () => {
|
||||
const mitchell = store.query.ids('author', () => ({
|
||||
name: { eq: 'David Mitchell' },
|
||||
}))
|
||||
|
||||
mitchell.get()
|
||||
const lastEpoch = mitchell.lastChangedEpoch
|
||||
|
||||
const newAuthor = Author.create({ name: 'David Mitchell' })
|
||||
store.put([newAuthor])
|
||||
store.remove([authors.davidMitchellFunny.id])
|
||||
|
||||
const diff = mitchell.getDiffSince(lastEpoch)
|
||||
if (diff === RESET_VALUE) throw new Error('should not be reset')
|
||||
expect(diff).toHaveLength(1)
|
||||
expect(diff[0]).toEqual({
|
||||
added: new Set([newAuthor.id]),
|
||||
removed: new Set([authors.davidMitchellFunny.id]),
|
||||
})
|
||||
})
|
||||
|
||||
it('[QQ2] a reactive query expression rebuilds with a correct diff when it changes', () => {
|
||||
store.put([{ ...authors.davidMitchellFunny, age: 30 }])
|
||||
|
||||
const currentAuthor = atom('currentAuthor', 'David Mitchell')
|
||||
const currentAge = atom('currentAge', 30)
|
||||
|
||||
const mitchell30 = store.query.ids('author', () => ({
|
||||
name: { eq: currentAuthor.get() },
|
||||
age: { eq: currentAge.get() },
|
||||
}))
|
||||
|
||||
expect(mitchell30.get()).toEqual(new Set([authors.davidMitchellFunny.id]))
|
||||
|
||||
let lastChangedEpoch = mitchell30.lastChangedEpoch
|
||||
currentAge.set(23)
|
||||
|
||||
expect(mitchell30.get()).toEqual(new Set([authors.davidMitchellSerious.id]))
|
||||
|
||||
const diff = mitchell30.getDiffSince(lastChangedEpoch)
|
||||
|
||||
if (diff === RESET_VALUE) throw new Error('should not be reset')
|
||||
|
||||
expect(diff).toHaveLength(1)
|
||||
expect(diff[0]).toEqual({
|
||||
added: new Set([authors.davidMitchellSerious.id]),
|
||||
removed: new Set([authors.davidMitchellFunny.id]),
|
||||
})
|
||||
|
||||
currentAuthor.set('J.R.R. Tolkein')
|
||||
|
||||
lastChangedEpoch = mitchell30.lastChangedEpoch
|
||||
|
||||
expect(mitchell30.get()).toEqual(new Set([authors.tolkein.id]))
|
||||
|
||||
const diff2 = mitchell30.getDiffSince(lastChangedEpoch)
|
||||
|
||||
if (diff2 === RESET_VALUE) throw new Error('should not be reset')
|
||||
|
||||
expect(diff2).toHaveLength(1)
|
||||
expect(diff2[0]).toEqual({
|
||||
added: new Set([authors.tolkein.id]),
|
||||
removed: new Set([authors.davidMitchellSerious.id]),
|
||||
})
|
||||
})
|
||||
|
||||
it('[QQ1] supports not-equals matches', () => {
|
||||
store.put([{ ...authors.davidMitchellFunny, age: 30 }])
|
||||
const mitchell = store.query.ids('author', () => ({
|
||||
name: { neq: 'David Mitchell' },
|
||||
}))
|
||||
|
||||
expect(mitchell.get()).toEqual(new Set([authors.tolkein.id, authors.bradbury.id]))
|
||||
|
||||
const ageNot23 = store.query.ids('author', () => ({
|
||||
age: { neq: 23 },
|
||||
}))
|
||||
|
||||
expect(ageNot23.get()).toEqual(new Set([authors.davidMitchellFunny.id]))
|
||||
})
|
||||
|
||||
it('[QQ1] updates as records are added, removed, and updated', () => {
|
||||
const mitchell = store.query.ids('author', () => ({
|
||||
name: { eq: 'David Mitchell' },
|
||||
age: { neq: 30 },
|
||||
}))
|
||||
|
||||
expect(mitchell.get()).toEqual(
|
||||
new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id])
|
||||
)
|
||||
|
||||
store.put([{ ...authors.davidMitchellFunny, age: 30 }])
|
||||
expect(mitchell.get()).toEqual(new Set([authors.davidMitchellSerious.id]))
|
||||
|
||||
store.put([{ ...authors.davidMitchellFunny, age: 23 }])
|
||||
expect(mitchell.get()).toEqual(
|
||||
new Set([authors.davidMitchellSerious.id, authors.davidMitchellFunny.id])
|
||||
)
|
||||
|
||||
store.remove([authors.davidMitchellFunny.id])
|
||||
expect(mitchell.get()).toEqual(new Set([authors.davidMitchellSerious.id]))
|
||||
|
||||
const newAuthor = Author.create({ name: 'David Mitchell' })
|
||||
store.put([newAuthor])
|
||||
expect(mitchell.get()).toEqual(new Set([authors.davidMitchellSerious.id, newAuthor.id]))
|
||||
})
|
||||
|
||||
it('[QQ3] does not change if unrelated records are added, updated, or removed', () => {
|
||||
const mitchell = store.query.ids('author', () => ({
|
||||
name: { eq: 'David Mitchell' },
|
||||
}))
|
||||
|
||||
expect(mitchell.get()).toEqual(
|
||||
new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id])
|
||||
)
|
||||
|
||||
const lastChangedEpoch = mitchell.lastChangedEpoch
|
||||
|
||||
const newAuthor = Author.create({ name: 'William Shakespeare' })
|
||||
store.put([newAuthor])
|
||||
|
||||
mitchell.get()
|
||||
expect(mitchell.lastChangedEpoch).toEqual(lastChangedEpoch)
|
||||
|
||||
store.remove([authors.tolkein.id])
|
||||
|
||||
mitchell.get()
|
||||
expect(mitchell.lastChangedEpoch).toEqual(lastChangedEpoch)
|
||||
})
|
||||
|
||||
it('[QQ3] does not change if matching records change in irrelevant ways', () => {
|
||||
const mitchell = store.query.ids('author', () => ({
|
||||
name: { eq: 'David Mitchell' },
|
||||
}))
|
||||
|
||||
expect(mitchell.get()).toEqual(
|
||||
new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id])
|
||||
)
|
||||
|
||||
const lastChangedEpoch = mitchell.lastChangedEpoch
|
||||
|
||||
store.put([{ ...authors.davidMitchellFunny, age: 30 }])
|
||||
|
||||
mitchell.get()
|
||||
expect(mitchell.lastChangedEpoch).toEqual(lastChangedEpoch)
|
||||
|
||||
// a change that does affect the query, just to check
|
||||
store.put([{ ...authors.davidMitchellFunny, name: 'steve' }])
|
||||
|
||||
mitchell.get()
|
||||
expect(mitchell.lastChangedEpoch).toBeGreaterThan(lastChangedEpoch)
|
||||
})
|
||||
|
||||
it('[QQ3] round trips (remove/add-back, add/remove) leave the set unchanged', () => {
|
||||
const mitchell = store.query.ids('author', () => ({
|
||||
name: { eq: 'David Mitchell' },
|
||||
}))
|
||||
|
||||
expect(mitchell.get()).toEqual(
|
||||
new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id])
|
||||
)
|
||||
|
||||
const lastChangedEpoch = mitchell.lastChangedEpoch
|
||||
|
||||
store.remove([authors.davidMitchellFunny.id])
|
||||
store.put([authors.davidMitchellFunny])
|
||||
|
||||
mitchell.get()
|
||||
expect(mitchell.lastChangedEpoch).toEqual(lastChangedEpoch)
|
||||
|
||||
const newMitchell = Author.create({ name: 'David Mitchell' })
|
||||
store.put([newMitchell])
|
||||
store.remove([newMitchell.id])
|
||||
|
||||
mitchell.get()
|
||||
expect(mitchell.lastChangedEpoch).toEqual(lastChangedEpoch)
|
||||
})
|
||||
})
|
||||
|
||||
const bookComparator = (a: Book, b: Book) => a.title.localeCompare(b.title)
|
||||
|
||||
describe('record queries (QQ)', () => {
|
||||
it('[QQ4] records() returns all matching records', () => {
|
||||
const allBooks = store.query.records('book')
|
||||
|
||||
expect(allBooks.get().sort(bookComparator)).toEqual(
|
||||
[books.cloudAtlas, books.farenheit, books.lotr, books.myLifeInComedy].sort(bookComparator)
|
||||
)
|
||||
|
||||
const farenheit = store.query.records('book', () => ({
|
||||
title: { eq: 'Farenheit 451' },
|
||||
}))
|
||||
expect(farenheit.get()).toEqual([books.farenheit])
|
||||
|
||||
const funnyGuide = Book.create({
|
||||
title: 'How to be a funny man',
|
||||
authorId: authors.davidMitchellFunny.id,
|
||||
})
|
||||
store.put([funnyGuide])
|
||||
const mitchell = store.query.records('book', () => ({
|
||||
authorId: { eq: authors.davidMitchellFunny.id },
|
||||
}))
|
||||
expect(mitchell.get().sort(bookComparator)).toEqual(
|
||||
[books.myLifeInComedy, funnyGuide].sort(bookComparator)
|
||||
)
|
||||
})
|
||||
|
||||
it('[QQ4] records() keeps the same array when the members did not change', () => {
|
||||
const allBooks = store.query.records('book')
|
||||
const value = allBooks.get()
|
||||
|
||||
// updating an author does not change the book array members
|
||||
store.put([{ ...authors.tolkein, age: 50 }])
|
||||
expect(allBooks.get()).toBe(value)
|
||||
})
|
||||
|
||||
it('[QQ4] records() supports reactive query expressions', () => {
|
||||
const currentAuthor = atom('currentAuthor', authors.davidMitchellFunny.id)
|
||||
const booksQuery = store.query.records('book', () => ({
|
||||
authorId: { eq: currentAuthor.get() },
|
||||
}))
|
||||
|
||||
expect(booksQuery.get()).toEqual([books.myLifeInComedy])
|
||||
|
||||
currentAuthor.set(authors.tolkein.id)
|
||||
|
||||
expect(booksQuery.get()).toEqual([books.lotr])
|
||||
})
|
||||
|
||||
it('[QQ4] record() returns one matching record or undefined', () => {
|
||||
const farenheit = store.query.record('book', () => ({
|
||||
title: { eq: 'Farenheit 451' },
|
||||
}))
|
||||
expect(farenheit.get()).toEqual(books.farenheit)
|
||||
|
||||
const nonexistent = store.query.record('book', () => ({
|
||||
title: { eq: 'No Such Book' },
|
||||
}))
|
||||
expect(nonexistent.get()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('[QQ4] record() updates reactively', () => {
|
||||
const tolkein = store.query.record('author', () => ({
|
||||
name: { eq: 'J.R.R. Tolkein' },
|
||||
}))
|
||||
expect(tolkein.get()?.age).toBe(23)
|
||||
|
||||
store.put([{ ...authors.tolkein, age: 81 }])
|
||||
expect(tolkein.get()?.age).toBe(81)
|
||||
|
||||
store.remove([authors.tolkein.id])
|
||||
expect(tolkein.get()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('[QQ5] exec runs a one-shot, non-reactive query', () => {
|
||||
const mitchells = store.query.exec('author', { name: { eq: 'David Mitchell' } })
|
||||
expect(new Set(mitchells)).toEqual(
|
||||
new Set([authors.davidMitchellFunny, authors.davidMitchellSerious])
|
||||
)
|
||||
|
||||
// not reactive: subsequent store changes don't affect the returned array
|
||||
store.put([Author.create({ name: 'David Mitchell' })])
|
||||
expect(mitchells).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('[QQ5] exec returns the shared empty array when nothing matches', () => {
|
||||
expect(store.query.exec('author', { name: { eq: 'No Such Author' } })).toBe(EMPTY_ARRAY)
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration with reactive state (QI, QQ)', () => {
|
||||
it('[QI3] computed values see index updates', () => {
|
||||
const tolkeinBookCount = computed('tolkein-books', () => {
|
||||
const byAuthor = store.query.index('book', 'authorId')
|
||||
return byAuthor.get().get(authors.tolkein.id)?.size ?? 0
|
||||
})
|
||||
|
||||
expect(tolkeinBookCount.get()).toBe(1)
|
||||
|
||||
store.put([Book.create({ title: 'The Hobbit', authorId: authors.tolkein.id })])
|
||||
expect(tolkeinBookCount.get()).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('queries across rolled-back transactions (QH, QQ)', () => {
|
||||
it('[QQ2] the query creator is called without arguments', () => {
|
||||
const args: unknown[][] = []
|
||||
const ids = store.query.ids('book', (...rest: unknown[]) => {
|
||||
args.push(rest)
|
||||
return {}
|
||||
})
|
||||
ids.get()
|
||||
store.put([Book.create({ title: 'Another', authorId: authors.bradbury.id })])
|
||||
ids.get()
|
||||
for (const call of args) expect(call).toEqual([])
|
||||
})
|
||||
|
||||
// `read` stands in for a side effect that queries the store mid-operation.
|
||||
function readDuringRolledBackPut(read: () => void) {
|
||||
const phantom = Book.create({ title: 'Phantom', authorId: authors.tolkein.id })
|
||||
expect(() =>
|
||||
store.atomic(() => {
|
||||
store.put([phantom])
|
||||
read()
|
||||
throw new Error('abort')
|
||||
})
|
||||
).toThrow('abort')
|
||||
expect(store.get(phantom.id)).toBeUndefined()
|
||||
}
|
||||
|
||||
const initialBookIds = Object.values(books).map((book) => book.id)
|
||||
|
||||
it('[QH4] the filtered history value strictly increases across a rolled-back transaction', () => {
|
||||
const bookHistory = store.query.filterHistory('book')
|
||||
const values = [bookHistory.get()]
|
||||
|
||||
readDuringRolledBackPut(() => values.push(bookHistory.get()))
|
||||
store.put([Book.create({ title: 'First', authorId: authors.bradbury.id })])
|
||||
values.push(bookHistory.get())
|
||||
store.put([Book.create({ title: 'Second', authorId: authors.bradbury.id })])
|
||||
values.push(bookHistory.get())
|
||||
|
||||
expect(values.slice(1).map((value, i) => value > values[i])).toEqual([true, true, true])
|
||||
})
|
||||
|
||||
it('[QH4] indexes and queries read during a rolled-back transaction are rebuilt afterwards', () => {
|
||||
const bookIds = store.query.ids('book')
|
||||
const booksByAuthor = store.query.index('book', 'authorId')
|
||||
let counterSeenByQueries = -1
|
||||
|
||||
readDuringRolledBackPut(() => {
|
||||
expect(bookIds.get().size).toBe(5)
|
||||
booksByAuthor.get()
|
||||
counterSeenByQueries = store.history.get()
|
||||
})
|
||||
|
||||
const real = Book.create({ title: 'Real', authorId: authors.bradbury.id })
|
||||
store.put([real])
|
||||
// a stale cache only goes unnoticed when the counter lands on the value the queries last saw
|
||||
expect(store.history.get()).toBe(counterSeenByQueries)
|
||||
|
||||
expect(bookIds.get()).toEqual(new Set([...initialBookIds, real.id]))
|
||||
expect(booksByAuthor.get().get(authors.tolkein.id)).toEqual(new Set([books.lotr.id]))
|
||||
expect(booksByAuthor.get().get(authors.bradbury.id)).toEqual(
|
||||
new Set([books.farenheit.id, real.id])
|
||||
)
|
||||
})
|
||||
|
||||
it('[QH4] indexes and queries keep picking up changes after being rebuilt', () => {
|
||||
const bookIds = store.query.ids('book')
|
||||
const booksByAuthor = store.query.index('book', 'authorId')
|
||||
|
||||
readDuringRolledBackPut(() => {
|
||||
bookIds.get()
|
||||
booksByAuthor.get()
|
||||
})
|
||||
const first = Book.create({ title: 'First', authorId: authors.bradbury.id })
|
||||
store.put([first])
|
||||
bookIds.get()
|
||||
booksByAuthor.get()
|
||||
|
||||
// the rebuild left the filtered history ahead of the store's counter, so this change lands
|
||||
// on the value it last reported
|
||||
const second = Book.create({ title: 'Second', authorId: authors.bradbury.id })
|
||||
store.put([second])
|
||||
|
||||
expect(bookIds.get()).toEqual(new Set([...initialBookIds, first.id, second.id]))
|
||||
expect(booksByAuthor.get().get(authors.bradbury.id)).toEqual(
|
||||
new Set([books.farenheit.id, first.id, second.id])
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user