kopia lustrzana https://github.com/Tldraw/Tldraw
514 wiersze
15 KiB
TypeScript
514 wiersze
15 KiB
TypeScript
import { atom, EffectScheduler, RESET_VALUE } from '@tldraw/state'
|
|
import { BaseRecord, IdOf, RecordId, UnknownRecord } from '../BaseRecord'
|
|
import { executeQuery } from '../executeQuery'
|
|
import { createRecordType } from '../RecordType'
|
|
import { CollectionDiff, Store } from '../Store'
|
|
import { RSIndexDiff } from '../StoreQueries'
|
|
import { StoreSchema } from '../StoreSchema'
|
|
|
|
interface Author extends BaseRecord<'author', RecordId<Author>> {
|
|
name: AuthorName
|
|
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: BookName
|
|
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 bookComparator = (a: Book, b: Book) => a.id.localeCompare(b.id)
|
|
|
|
function rng(seed: number) {
|
|
return () => {
|
|
seed = (seed * 9301 + 49297) % 233280
|
|
return seed / 233280
|
|
}
|
|
}
|
|
|
|
type Record = Author | Book
|
|
|
|
type Op =
|
|
| { readonly type: 'add'; readonly record: Record }
|
|
| { readonly type: 'delete'; readonly id: IdOf<Record> }
|
|
| { readonly type: 'update'; readonly record: Record }
|
|
| { readonly type: 'set_book_name_query_param'; readonly bookName: BookName }
|
|
| { readonly type: 'set_author_id_query_param'; readonly authorId: IdOf<Author> }
|
|
|
|
const BOOK_NAMES = [
|
|
'Breakfast of Champions',
|
|
'Slaughterhouse-Five',
|
|
"Cat's Cradle",
|
|
'The Sirens of Titan',
|
|
'Mother Night',
|
|
'Galapagos',
|
|
'God Bless You, Mr. Rosewater',
|
|
'Welcome to the Monkey House',
|
|
'Bluebeard',
|
|
'Hocus Pocus',
|
|
'The Day the Earth Stood Still',
|
|
'The Long Dry',
|
|
'The Crying of Lot 49',
|
|
"Gravity's Rainbow",
|
|
'Player Piano',
|
|
'Infinite Jest',
|
|
'The Caves of Steel',
|
|
'The Naked Sun',
|
|
'The Robots of Dawn',
|
|
'The Stars My Destination',
|
|
'Stoner',
|
|
'The Martian Chronicles',
|
|
]
|
|
|
|
type BookName = (typeof BOOK_NAMES)[number]
|
|
|
|
const AUTHOR_NAMES = [
|
|
'Kurt Vonnegut',
|
|
'Douglas Adams',
|
|
'Isaac Asimov',
|
|
'Ray Bradbury',
|
|
'Robert A. Heinlein',
|
|
'Arthur C. Clarke',
|
|
'Frank Herbert',
|
|
'J.R.R. Tolkien',
|
|
'Philip K. Dick',
|
|
'Ursula K. Le Guin',
|
|
'William Gibson',
|
|
'Neil Gaiman',
|
|
'Terry Pratchett',
|
|
'George R.R. Martin',
|
|
'Stephen King',
|
|
'Tad Williams',
|
|
'Robert Jordan',
|
|
'Brandon Sanderson',
|
|
'Terry Brooks',
|
|
'Anne McCaffrey',
|
|
'Robert A. Heinlein',
|
|
]
|
|
|
|
type AuthorName = (typeof AUTHOR_NAMES)[number]
|
|
|
|
function getRandomBookName(getRandomNumber: () => number) {
|
|
return BOOK_NAMES[Math.floor(getRandomNumber() * 11)]
|
|
}
|
|
|
|
function getRandomAuthorName(getRandomNumber: () => number) {
|
|
return AUTHOR_NAMES[Math.floor(getRandomNumber() * 11)]
|
|
}
|
|
|
|
function getRandomBookFromStore(store: Store<Book | Author>, getRandomNumber: () => number): Book {
|
|
const books = store.allRecords().filter((record): record is Book => record.typeName === 'book')
|
|
return books[Math.floor(getRandomNumber() * books.length)]
|
|
}
|
|
|
|
function getRandomAuthorFromStore(
|
|
store: Store<Book | Author>,
|
|
getRandomNumber: () => number
|
|
): Author {
|
|
const authors = store
|
|
.allRecords()
|
|
.filter((record): record is Author => record.typeName === 'author')
|
|
return authors[Math.floor(getRandomNumber() * authors.length)]
|
|
}
|
|
|
|
function getRandomBookOp(
|
|
store: Store<Book | Author>,
|
|
getRandomNumber: () => number,
|
|
deletedRecords: Set<Book | Author>
|
|
): Op {
|
|
const num = Math.floor(getRandomNumber() * 5)
|
|
switch (num) {
|
|
case 0: {
|
|
if (getRandomNumber() < 0.3) {
|
|
const deleted = [...deletedRecords].find((record) => record.typeName === 'book')
|
|
if (deleted) {
|
|
deletedRecords.delete(deleted)
|
|
return { type: 'add', record: deleted }
|
|
}
|
|
}
|
|
const author = getRandomAuthorFromStore(store, getRandomNumber)
|
|
if (author) {
|
|
return {
|
|
type: 'add',
|
|
record: Book.create({
|
|
id: Book.createId(getRandomNumber().toString()),
|
|
authorId: author.id,
|
|
title: getRandomBookName(getRandomNumber),
|
|
}),
|
|
}
|
|
} else {
|
|
return getRandomAuthorOp(store, getRandomNumber, deletedRecords)
|
|
}
|
|
}
|
|
case 1: {
|
|
const bookToDelete = getRandomBookFromStore(store, getRandomNumber)
|
|
if (bookToDelete) {
|
|
return { type: 'delete', id: bookToDelete.id }
|
|
}
|
|
return getRandomBookOp(store, getRandomNumber, deletedRecords)
|
|
}
|
|
case 2:
|
|
case 3: {
|
|
const bookToUpdate = getRandomBookFromStore(store, getRandomNumber)
|
|
if (!bookToUpdate) return getRandomBookOp(store, getRandomNumber, deletedRecords)
|
|
return {
|
|
type: 'update',
|
|
record: { ...bookToUpdate, title: getRandomBookName(getRandomNumber) },
|
|
}
|
|
}
|
|
case 4: {
|
|
const bookToUpdate = getRandomBookFromStore(store, getRandomNumber)
|
|
if (!bookToUpdate) return getRandomBookOp(store, getRandomNumber, deletedRecords)
|
|
const authorToReassignBookTo = getRandomAuthorFromStore(store, getRandomNumber)
|
|
if (!authorToReassignBookTo) return getRandomBookOp(store, getRandomNumber, deletedRecords)
|
|
return {
|
|
type: 'update',
|
|
record: { ...bookToUpdate, authorId: authorToReassignBookTo.id },
|
|
}
|
|
}
|
|
default:
|
|
throw new Error('unreachable: ' + num)
|
|
}
|
|
}
|
|
|
|
function getRandomAuthorOp(
|
|
store: Store<Book | Author>,
|
|
getRandomNumber: () => number,
|
|
deletedRecords: Set<Book | Author>
|
|
): Op {
|
|
const num = Math.floor(getRandomNumber() * 5)
|
|
switch (num) {
|
|
case 0: {
|
|
if (getRandomNumber() < 0.3) {
|
|
const deleted = [...deletedRecords].find((record) => record.typeName === 'author')
|
|
if (deleted) {
|
|
deletedRecords.delete(deleted)
|
|
return { type: 'add', record: deleted }
|
|
}
|
|
}
|
|
return {
|
|
type: 'add',
|
|
record: Author.create({
|
|
id: Author.createId(getRandomNumber().toString()),
|
|
name: getRandomAuthorName(getRandomNumber),
|
|
}),
|
|
}
|
|
}
|
|
case 1: {
|
|
const authorToDelete = getRandomAuthorFromStore(store, getRandomNumber)
|
|
if (authorToDelete) {
|
|
return { type: 'delete', id: authorToDelete.id }
|
|
}
|
|
return getRandomAuthorOp(store, getRandomNumber, deletedRecords)
|
|
}
|
|
case 2:
|
|
case 3: {
|
|
const authorToUpdate = getRandomAuthorFromStore(store, getRandomNumber)
|
|
if (!authorToUpdate) return getRandomAuthorOp(store, getRandomNumber, deletedRecords)
|
|
return {
|
|
type: 'update',
|
|
record: {
|
|
...authorToUpdate,
|
|
name: getRandomAuthorName(getRandomNumber),
|
|
},
|
|
}
|
|
}
|
|
case 4: {
|
|
const authorToUpdate = getRandomAuthorFromStore(store, getRandomNumber)
|
|
if (!authorToUpdate) return getRandomAuthorOp(store, getRandomNumber, deletedRecords)
|
|
return {
|
|
type: 'update',
|
|
record: {
|
|
...authorToUpdate,
|
|
age: Math.floor(getRandomNumber() * 100),
|
|
},
|
|
}
|
|
}
|
|
default:
|
|
throw new Error('unreachable: ' + num)
|
|
}
|
|
}
|
|
|
|
function getRandomOp(
|
|
store: Store<Book | Author>,
|
|
getRandomNumber: () => number,
|
|
deletedRecords: Set<Book | Author>
|
|
): Op {
|
|
const num = Math.floor(getRandomNumber() * 3)
|
|
switch (num) {
|
|
case 0:
|
|
return getRandomBookOp(store, getRandomNumber, deletedRecords)
|
|
case 1:
|
|
return getRandomAuthorOp(store, getRandomNumber, deletedRecords)
|
|
case 2: {
|
|
if (getRandomNumber() > 0.5) {
|
|
const authorId = getRandomAuthorFromStore(store, getRandomNumber)?.id
|
|
if (authorId) {
|
|
return { type: 'set_author_id_query_param', authorId: authorId }
|
|
}
|
|
return getRandomOp(store, getRandomNumber, deletedRecords)
|
|
} else {
|
|
return { type: 'set_book_name_query_param', bookName: getRandomBookName(getRandomNumber) }
|
|
}
|
|
}
|
|
default:
|
|
throw new Error('unreachable: ' + num)
|
|
}
|
|
}
|
|
|
|
function recreateIndexFromDiffs(diffs: RSIndexDiff<any>[]) {
|
|
const result = new Map<string, Set<IdOf<UnknownRecord>>>()
|
|
for (const diff of diffs) {
|
|
for (const [key, changes] of diff) {
|
|
const index = result.get(key) || new Set<IdOf<UnknownRecord>>()
|
|
if (changes.added) {
|
|
for (const id of changes.added) {
|
|
index.add(id)
|
|
}
|
|
}
|
|
if (changes.removed) {
|
|
for (const id of changes.removed) {
|
|
index.delete(id)
|
|
}
|
|
}
|
|
if (index.size === 0) {
|
|
result.delete(key)
|
|
} else {
|
|
result.set(key, index)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
function reacreateSetFromDiffs<T>(diffs: CollectionDiff<T>[]) {
|
|
const result = new Set<T>()
|
|
for (const diff of diffs) {
|
|
if (diff.added) {
|
|
for (const item of diff.added) {
|
|
result.add(item)
|
|
}
|
|
}
|
|
if (diff.removed) {
|
|
for (const item of diff.removed) {
|
|
result.delete(item)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
const NUM_OPS = 200
|
|
|
|
function runTest(seed: number) {
|
|
const store = new Store({
|
|
props: {},
|
|
schema: StoreSchema.create<Book | Author>({
|
|
book: Book,
|
|
author: Author,
|
|
}),
|
|
})
|
|
store.onBeforeDelete = (record) => {
|
|
if (record.typeName === 'author') {
|
|
const books = store.query.index('book', 'authorId').get().get(record.id)
|
|
if (books) store.remove([...books])
|
|
}
|
|
}
|
|
const getRandomNumber = rng(seed)
|
|
const authorNameIndex = store.query.index('author', 'name')
|
|
const authorIdIndex = store.query.index('book', 'authorId')
|
|
|
|
const authorNameIndexDiffs: RSIndexDiff<Author>[] = []
|
|
const authorIdIndexDiffs: RSIndexDiff<Book>[] = []
|
|
|
|
const authorIdQueryParam = atom('authorId', Author.createId('does-not-exist'))
|
|
const bookTitleQueryParam = atom('bookTitle', getRandomBookName(getRandomNumber))
|
|
|
|
const booksByAuthorQuery = store.query.records('book', () => ({
|
|
authorId: { eq: authorIdQueryParam.get() },
|
|
}))
|
|
|
|
const booksByTitleQuery = store.query.records('book', () => ({
|
|
title: { eq: bookTitleQueryParam.get() },
|
|
}))
|
|
|
|
const authorNameQueryParam = atom('authorName', getRandomAuthorName(getRandomNumber))
|
|
const authorIdsByNameQuery = store.query.ids('author', () => ({
|
|
name: { neq: authorNameQueryParam.get() },
|
|
}))
|
|
|
|
const ops = []
|
|
|
|
const deletedRecords = new Set<Book | Author>()
|
|
|
|
try {
|
|
let latestBooksByAuthorQueryResult: Book[] = []
|
|
let latestBooksByTitleQueryResult: Book[] = []
|
|
let latestAuthorIdsByNameQueryResult: Set<RecordId<Author>> = new Set()
|
|
const authorIdsByNameDiffs: CollectionDiff<RecordId<Author>>[] = []
|
|
const effect = new EffectScheduler('', (lastReactedEpoch: number) => {
|
|
const authorNameIndexDiff = authorNameIndex.getDiffSince(lastReactedEpoch)
|
|
const authorIdIndexDiff = authorIdIndex.getDiffSince(lastReactedEpoch)
|
|
const authorIdsByNameDiff = authorIdsByNameQuery.getDiffSince(lastReactedEpoch)
|
|
latestBooksByAuthorQueryResult = booksByAuthorQuery.get()
|
|
latestBooksByTitleQueryResult = booksByTitleQuery.get()
|
|
latestAuthorIdsByNameQueryResult = authorIdsByNameQuery.get()
|
|
if (
|
|
authorNameIndexDiff === RESET_VALUE ||
|
|
authorIdIndexDiff === RESET_VALUE ||
|
|
authorIdsByNameDiff == RESET_VALUE
|
|
) {
|
|
if (ops.length !== 0) {
|
|
throw new Error('unexpected RESET_VALUE ' + ops.length)
|
|
}
|
|
return
|
|
}
|
|
authorNameIndexDiffs.push(...authorNameIndexDiff)
|
|
authorIdIndexDiffs.push(...authorIdIndexDiff)
|
|
authorIdsByNameDiffs.push(...authorIdsByNameDiff)
|
|
})
|
|
effect.execute()
|
|
|
|
for (let i = 0; i < NUM_OPS; i++) {
|
|
const op = getRandomOp(store, getRandomNumber, deletedRecords)
|
|
ops.push(op)
|
|
switch (op.type) {
|
|
case 'add':
|
|
case 'update': {
|
|
store.put([op.record])
|
|
break
|
|
}
|
|
case 'delete': {
|
|
const r = store.get(op.id)
|
|
if (!r) throw new Error('record not found: ' + op.id)
|
|
deletedRecords.add(r)
|
|
store.remove([op.id])
|
|
|
|
if (op.id === 'book:0.5525377229080933') {
|
|
store.query.index('book', 'title').get()
|
|
}
|
|
break
|
|
}
|
|
case 'set_author_id_query_param': {
|
|
authorIdQueryParam.set(op.authorId)
|
|
break
|
|
}
|
|
case 'set_book_name_query_param': {
|
|
bookTitleQueryParam.set(op.bookName)
|
|
break
|
|
}
|
|
default:
|
|
throw new Error('unreachable runTest')
|
|
}
|
|
|
|
// don't check on every op, in case some logic breaks when there are multiple diff entries
|
|
if (getRandomNumber() > 0.5) {
|
|
effect.execute()
|
|
// these tests create a version of the index from scratch and check it against
|
|
// the incrementally-updated version to make sure the logic matches.
|
|
const authorNameIndexFromScratch = store.query
|
|
.__uncached_createIndex('author', 'name')
|
|
.get()
|
|
const authorIdIndexFromScratch = store.query
|
|
.__uncached_createIndex('book', 'authorId')
|
|
.get()
|
|
expect(authorNameIndex.get()).toEqual(authorNameIndexFromScratch)
|
|
expect(authorIdIndex.get()).toEqual(authorIdIndexFromScratch)
|
|
// these tests recreate the index from scratch based on the diffs so far and
|
|
// check it against the gold standard version to make sure the diff logic matches.
|
|
expect(recreateIndexFromDiffs(authorNameIndexDiffs)).toEqual(authorNameIndexFromScratch)
|
|
expect(recreateIndexFromDiffs(authorIdIndexDiffs)).toEqual(authorIdIndexFromScratch)
|
|
// these tests check the query results against filtering the whole record store from scratch
|
|
expect(latestBooksByAuthorQueryResult.sort(bookComparator)).toEqual(
|
|
store
|
|
.allRecords()
|
|
.filter(
|
|
(r): r is Book => r.typeName === 'book' && r.authorId === authorIdQueryParam.get()
|
|
)
|
|
.sort(bookComparator)
|
|
)
|
|
expect(new Set(latestBooksByAuthorQueryResult.map((b) => b.id))).toEqual(
|
|
executeQuery(store.query, 'book', { authorId: { eq: authorIdQueryParam.get() } })
|
|
)
|
|
expect(latestBooksByTitleQueryResult.sort(bookComparator)).toEqual(
|
|
store
|
|
.allRecords()
|
|
.filter(
|
|
(r): r is Book => r.typeName === 'book' && r.title === bookTitleQueryParam.get()
|
|
)
|
|
.sort(bookComparator)
|
|
)
|
|
expect(new Set(latestBooksByTitleQueryResult.map((b) => b.id))).toEqual(
|
|
executeQuery(store.query, 'book', { title: { eq: bookTitleQueryParam.get() } })
|
|
)
|
|
expect(latestAuthorIdsByNameQueryResult).toEqual(
|
|
new Set(
|
|
store
|
|
.allRecords()
|
|
.filter(
|
|
(r): r is Author => r.typeName === 'author' && r.name !== authorNameQueryParam.get()
|
|
)
|
|
.map((r) => r.id)
|
|
)
|
|
)
|
|
expect(latestAuthorIdsByNameQueryResult).toEqual(
|
|
executeQuery(store.query, 'author', { name: { neq: authorNameQueryParam.get() } })
|
|
)
|
|
// this test checks that the authorIdsByName set matches what you get when you reassemble it from the diffs
|
|
expect(reacreateSetFromDiffs(authorIdsByNameDiffs)).toEqual(
|
|
latestAuthorIdsByNameQueryResult
|
|
)
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error(ops)
|
|
throw e
|
|
}
|
|
}
|
|
|
|
const NUM_TESTS = 100
|
|
for (let i = 0; i < NUM_TESTS; i++) {
|
|
const seed = Math.floor(Math.random() * 1000000)
|
|
test('with seed ' + seed, () => {
|
|
runTest(seed)
|
|
})
|
|
}
|
|
|
|
// regression tests
|
|
test('(regression) with seed 128383', () => {
|
|
runTest(128383)
|
|
})
|
|
|
|
test('(regression) with seed 894844', () => {
|
|
runTest(894844)
|
|
})
|
|
|
|
test('(regression) with seed 436077', () => {
|
|
runTest(436077)
|
|
})
|