@tldraw/store
Version:
tldraw infinite canvas SDK (store).
678 lines (620 loc) • 21.7 kB
text/typescript
import {
Atom,
computed,
Computed,
EMPTY_ARRAY,
isUninitialized,
RESET_VALUE,
withDiff,
} from '@tldraw/state'
import { areArraysShallowEqual, isEqual, objectMapValues } from '@tldraw/utils'
import { AtomMap } from './AtomMap'
import { IdOf, UnknownRecord } from './BaseRecord'
import { executeQuery, objectMatchesQuery, QueryExpression } from './executeQuery'
import { IncrementalSetConstructor } from './IncrementalSetConstructor'
import { RecordsDiff } from './RecordsDiff'
import { diffSets } from './setUtils'
import { CollectionDiff } from './Store'
/**
* A type representing the diff of changes to a reactive store index.
* Maps property values to the collection differences for record IDs that have that property value.
*
* @example
* ```ts
* // For an index on book titles, the diff might look like:
* const titleIndexDiff: RSIndexDiff<Book, 'title'> = new Map([
* ['The Lathe of Heaven', { added: new Set(['book:1']), removed: new Set() }],
* ['Animal Farm', { added: new Set(), removed: new Set(['book:2']) }]
* ])
* ```
*
* @public
*/
export type RSIndexDiff<R extends UnknownRecord> = Map<any, CollectionDiff<IdOf<R>>>
/**
* A type representing a reactive store index as a map from property values to sets of record IDs.
* This is used to efficiently look up records by a specific property value.
*
* @example
* ```ts
* // Index mapping book titles to the IDs of books with that title
* const titleIndex: RSIndexMap<Book, 'title'> = new Map([
* ['The Lathe of Heaven', new Set(['book:1'])],
* ['Animal Farm', new Set(['book:2', 'book:3'])]
* ])
* ```
*
* @public
*/
export type RSIndexMap<R extends UnknownRecord> = Map<any, Set<IdOf<R>>>
/**
* A reactive computed index that provides efficient lookups of records by property values.
* Returns a computed value containing an RSIndexMap with diffs for change tracking.
*
* @example
* ```ts
* // Create an index on book authors
* const authorIndex: RSIndex<Book, 'authorId'> = store.query.index('book', 'authorId')
*
* // Get all books by a specific author
* const leguinBooks = authorIndex.get().get('author:leguin')
* ```
*
* @public
*/
export type RSIndex<R extends UnknownRecord> = Computed<RSIndexMap<R>, RSIndexDiff<R>>
/**
* A class that provides reactive querying capabilities for a record store.
* Offers methods to create indexes, filter records, and perform efficient lookups with automatic cache management.
* All queries are reactive and will automatically update when the underlying store data changes.
*
* @example
* ```ts
* // Create a store with books
* const store = new Store({ schema: StoreSchema.create({ book: Book, author: Author }) })
*
* // Get reactive queries for books
* const booksByAuthor = store.query.index('book', 'authorId')
* const inStockBooks = store.query.records('book', () => ({ inStock: { eq: true } }))
* ```
*
* @public
*/
export class StoreQueries<R extends UnknownRecord> {
/**
* Creates a new StoreQueries instance.
*
* recordMap - The atom map containing all records in the store
* history - The atom tracking the store's change history with diffs
*
* @internal
*/
constructor(
private readonly recordMap: AtomMap<IdOf<R>, R>,
private readonly history: Atom<number, RecordsDiff<R>>
) {}
/**
* A cache of derivations (indexes).
*
* @internal
*/
private indexCache = new Map<string, RSIndex<R>>()
/**
* A cache of derivations (filtered histories).
*
* @internal
*/
private historyCache = new Map<string, Computed<number, RecordsDiff<R>>>()
/**
* @internal
*/
public getAllIdsForType<TypeName extends R['typeName']>(
typeName: TypeName
): Set<IdOf<Extract<R, { typeName: TypeName }>>> {
type S = Extract<R, { typeName: TypeName }>
const ids = new Set<IdOf<S>>()
for (const record of this.recordMap.values()) {
if (record.typeName === typeName) {
ids.add(record.id as IdOf<S>)
}
}
return ids
}
/**
* @internal
*/
public getRecordById<TypeName extends R['typeName']>(
typeName: TypeName,
id: IdOf<Extract<R, { typeName: TypeName }>>
): Extract<R, { typeName: TypeName }> | undefined {
const record = this.recordMap.get(id as IdOf<R>)
if (record && record.typeName === typeName) {
return record as Extract<R, { typeName: TypeName }>
}
return undefined
}
/**
* Helper to extract nested property value using pre-split path parts.
* @internal
*/
private getNestedValue(obj: any, pathParts: string[]): any {
let current = obj
for (const part of pathParts) {
if (current == null || typeof current !== 'object') return undefined
current = current[part]
}
return current
}
/**
* Creates a reactive computed that tracks the change history for records of a specific type.
* The returned computed provides incremental diffs showing what records of the given type
* have been added, updated, or removed.
*
* @param typeName - The type name to filter the history by
* @returns A computed value containing the current epoch and diffs of changes for the specified type
*
* @example
* ```ts
* // Track changes to book records only
* const bookHistory = store.query.filterHistory('book')
*
* // React to book changes
* react('book-changes', () => {
* const currentEpoch = bookHistory.get()
* console.log('Books updated at epoch:', currentEpoch)
* })
* ```
*
* @public
*/
public filterHistory<TypeName extends R['typeName']>(
typeName: TypeName
): Computed<number, RecordsDiff<Extract<R, { typeName: TypeName }>>> {
type S = Extract<R, { typeName: TypeName }>
if (this.historyCache.has(typeName)) {
return this.historyCache.get(typeName) as any
}
const filtered = computed<number, RecordsDiff<S>>(
'filterHistory:' + typeName,
(lastValue, lastComputedEpoch) => {
if (isUninitialized(lastValue)) {
return this.history.get()
}
const diff = this.history.getDiffSince(lastComputedEpoch)
if (diff === RESET_VALUE) return this.history.get()
const res = { added: {}, removed: {}, updated: {} } as RecordsDiff<S>
let numAdded = 0
let numRemoved = 0
let numUpdated = 0
for (const changes of diff) {
for (const added of objectMapValues(changes.added)) {
if (added.typeName === typeName) {
if (res.removed[added.id as IdOf<S>]) {
const original = res.removed[added.id as IdOf<S>]
delete res.removed[added.id as IdOf<S>]
numRemoved--
if (original !== added) {
res.updated[added.id as IdOf<S>] = [original, added as S]
numUpdated++
}
} else {
res.added[added.id as IdOf<S>] = added as S
numAdded++
}
}
}
for (const [from, to] of objectMapValues(changes.updated)) {
if (to.typeName === typeName) {
if (res.added[to.id as IdOf<S>]) {
res.added[to.id as IdOf<S>] = to as S
} else if (res.updated[to.id as IdOf<S>]) {
res.updated[to.id as IdOf<S>] = [res.updated[to.id as IdOf<S>][0], to as S]
} else {
res.updated[to.id as IdOf<S>] = [from as S, to as S]
numUpdated++
}
}
}
for (const removed of objectMapValues(changes.removed)) {
if (removed.typeName === typeName) {
if (res.added[removed.id as IdOf<S>]) {
// was added during this diff sequence, so just undo the add
delete res.added[removed.id as IdOf<S>]
numAdded--
} else if (res.updated[removed.id as IdOf<S>]) {
// remove oldest version
res.removed[removed.id as IdOf<S>] = res.updated[removed.id as IdOf<S>][0]
delete res.updated[removed.id as IdOf<S>]
numUpdated--
numRemoved++
} else {
res.removed[removed.id as IdOf<S>] = removed as S
numRemoved++
}
}
}
}
if (numAdded || numRemoved || numUpdated) {
return withDiff(this.history.get(), res)
} else {
return lastValue
}
},
{ historyLength: 100 }
)
this.historyCache.set(typeName, filtered)
return filtered
}
/**
* Creates a reactive index that maps property values to sets of record IDs for efficient lookups.
* The index automatically updates when records are added, updated, or removed, and results are cached
* for performance.
*
* Supports nested property paths using backslash separator (e.g., 'metadata\\sessionId').
*
* @param typeName - The type name of records to index
* @param path - The property name or backslash-delimited path to index by
* @returns A reactive computed containing the index map with change diffs
*
* @example
* ```ts
* // Create an index of books by author ID
* const booksByAuthor = store.query.index('book', 'authorId')
*
* // Get all books by a specific author
* const authorBooks = booksByAuthor.get().get('author:leguin')
* console.log(authorBooks) // Set<RecordId<Book>>
*
* // Index by nested property using backslash separator
* const booksBySession = store.query.index('book', 'metadata\\sessionId')
* const sessionBooks = booksBySession.get().get('session:alpha')
* ```
*
* @public
*/
public index<TypeName extends R['typeName']>(
typeName: TypeName,
path: string
): RSIndex<Extract<R, { typeName: TypeName }>> {
const cacheKey = typeName + ':' + path
if (this.indexCache.has(cacheKey)) {
return this.indexCache.get(cacheKey) as any
}
const index = this.__uncached_createIndex(typeName, path)
this.indexCache.set(cacheKey, index as any)
return index
}
/**
* Creates a new index without checking the cache. This method performs the actual work
* of building the reactive index computation that tracks property values to record ID sets.
*
* Supports nested property paths using backslash separator.
*
* @param typeName - The type name of records to index
* @param path - The property name or backslash-delimited path to index by
* @returns A reactive computed containing the index map with change diffs
*
* @internal
*/
__uncached_createIndex<TypeName extends R['typeName']>(
typeName: TypeName,
path: string
): RSIndex<Extract<R, { typeName: TypeName }>> {
type S = Extract<R, { typeName: TypeName }>
const typeHistory = this.filterHistory(typeName)
// Create closure for efficient property value extraction
const pathParts = path.split('\\')
const getPropertyValue =
pathParts.length > 1
? (obj: S) => this.getNestedValue(obj, pathParts)
: (obj: S) => obj[path as keyof S]
const fromScratch = () => {
// deref typeHistory early so that the first time the incremental version runs
// it gets a diff to work with instead of having to bail to this from-scratch version
typeHistory.get()
const res = new Map<any, Set<IdOf<S>>>()
for (const record of this.recordMap.values()) {
if (record.typeName === typeName) {
const value = getPropertyValue(record as S)
if (value !== undefined) {
if (!res.has(value)) {
res.set(value, new Set())
}
res.get(value)!.add(record.id)
}
}
}
return res
}
return computed<RSIndexMap<S>, RSIndexDiff<S>>(
'index:' + typeName + ':' + path,
(prevValue, lastComputedEpoch) => {
if (isUninitialized(prevValue)) return fromScratch()
const history = typeHistory.getDiffSince(lastComputedEpoch)
if (history === RESET_VALUE) {
return fromScratch()
}
const setConstructors = new Map<any, IncrementalSetConstructor<IdOf<S>>>()
const add = (value: any, id: IdOf<S>) => {
let setConstructor = setConstructors.get(value)
if (!setConstructor)
setConstructor = new IncrementalSetConstructor<IdOf<S>>(
prevValue.get(value) ?? new Set()
)
setConstructor.add(id)
setConstructors.set(value, setConstructor)
}
const remove = (value: any, id: IdOf<S>) => {
let set = setConstructors.get(value)
if (!set) set = new IncrementalSetConstructor<IdOf<S>>(prevValue.get(value) ?? new Set())
set.remove(id)
setConstructors.set(value, set)
}
for (const changes of history) {
for (const record of objectMapValues(changes.added)) {
if (record.typeName === typeName) {
const value = getPropertyValue(record as S)
if (value !== undefined) {
add(value, record.id)
}
}
}
for (const [from, to] of objectMapValues(changes.updated)) {
if (to.typeName === typeName) {
const prev = getPropertyValue(from as S)
const next = getPropertyValue(to as S)
if (prev !== next) {
if (prev !== undefined) {
remove(prev, to.id)
}
if (next !== undefined) {
add(next, to.id)
}
}
}
}
for (const record of objectMapValues(changes.removed)) {
if (record.typeName === typeName) {
const value = getPropertyValue(record as S)
if (value !== undefined) {
remove(value, record.id)
}
}
}
}
let nextValue: undefined | RSIndexMap<S> = undefined
let nextDiff: undefined | RSIndexDiff<S> = undefined
for (const [value, setConstructor] of setConstructors) {
const result = setConstructor.get()
if (!result) continue
if (!nextValue) nextValue = new Map(prevValue)
if (!nextDiff) nextDiff = new Map()
if (result.value.size === 0) {
nextValue.delete(value)
} else {
nextValue.set(value, result.value)
}
nextDiff.set(value, result.diff)
}
if (nextValue && nextDiff) {
return withDiff(nextValue, nextDiff)
}
return prevValue
},
{ historyLength: 100 }
)
}
/**
* Creates a reactive query that returns the first record matching the given query criteria.
* Returns undefined if no matching record is found. The query automatically updates
* when records change.
*
* @param typeName - The type name of records to query
* @param queryCreator - Function that returns the query expression object to match against
* @param name - Optional name for the query computation (used for debugging)
* @returns A computed value containing the first matching record or undefined
*
* @example
* ```ts
* // Find the first book with a specific title
* const bookLatheOfHeaven = store.query.record('book', () => ({ title: { eq: 'The Lathe of Heaven' } }))
* console.log(bookLatheOfHeaven.get()?.title) // 'The Lathe of Heaven' or undefined
*
* // Find any book in stock
* const anyInStockBook = store.query.record('book', () => ({ inStock: { eq: true } }))
* ```
*
* @public
*/
record<TypeName extends R['typeName']>(
typeName: TypeName,
queryCreator: () => QueryExpression<Extract<R, { typeName: TypeName }>> = () => ({}),
name = 'record:' + typeName + (queryCreator ? ':' + queryCreator.toString() : '')
): Computed<Extract<R, { typeName: TypeName }> | undefined> {
type S = Extract<R, { typeName: TypeName }>
const ids = this.ids(typeName, queryCreator, name)
return computed<S | undefined>(name, () => {
for (const id of ids.get()) {
return this.recordMap.get(id) as S | undefined
}
return undefined
})
}
/**
* Creates a reactive query that returns an array of all records matching the given query criteria.
* The array automatically updates when records are added, updated, or removed.
*
* @param typeName - The type name of records to query
* @param queryCreator - Function that returns the query expression object to match against
* @param name - Optional name for the query computation (used for debugging)
* @returns A computed value containing an array of all matching records
*
* @example
* ```ts
* // Get all books in stock
* const inStockBooks = store.query.records('book', () => ({ inStock: { eq: true } }))
* console.log(inStockBooks.get()) // Book[]
*
* // Get all books by a specific author
* const leguinBooks = store.query.records('book', () => ({ authorId: { eq: 'author:leguin' } }))
*
* // Get all books (no filter)
* const allBooks = store.query.records('book')
* ```
*
* @public
*/
records<TypeName extends R['typeName']>(
typeName: TypeName,
queryCreator: () => QueryExpression<Extract<R, { typeName: TypeName }>> = () => ({}),
name = 'records:' + typeName + (queryCreator ? ':' + queryCreator.toString() : '')
): Computed<Array<Extract<R, { typeName: TypeName }>>> {
type S = Extract<R, { typeName: TypeName }>
const ids = this.ids(typeName, queryCreator, 'ids:' + name)
return computed<S[]>(
name,
() => {
return Array.from(ids.get(), (id) => this.recordMap.get(id) as S)
},
{
isEqual: areArraysShallowEqual,
}
)
}
/**
* Creates a reactive query that returns a set of record IDs matching the given query criteria.
* This is more efficient than `records()` when you only need the IDs and not the full record objects.
* The set automatically updates with collection diffs when records change.
*
* @param typeName - The type name of records to query
* @param queryCreator - Function that returns the query expression object to match against
* @param name - Optional name for the query computation (used for debugging)
* @returns A computed value containing a set of matching record IDs with collection diffs
*
* @example
* ```ts
* // Get IDs of all books in stock
* const inStockBookIds = store.query.ids('book', () => ({ inStock: { eq: true } }))
* console.log(inStockBookIds.get()) // Set<RecordId<Book>>
*
* // Get all book IDs (no filter)
* const allBookIds = store.query.ids('book')
*
* // Use with other queries for efficient lookups
* const authorBookIds = store.query.ids('book', () => ({ authorId: { eq: 'author:leguin' } }))
* ```
*
* @public
*/
ids<TypeName extends R['typeName']>(
typeName: TypeName,
queryCreator: () => QueryExpression<Extract<R, { typeName: TypeName }>> = () => ({}),
name = 'ids:' + typeName + (queryCreator ? ':' + queryCreator.toString() : '')
): Computed<
Set<IdOf<Extract<R, { typeName: TypeName }>>>,
CollectionDiff<IdOf<Extract<R, { typeName: TypeName }>>>
> {
type S = Extract<R, { typeName: TypeName }>
const typeHistory = this.filterHistory(typeName)
const fromScratch = () => {
// deref type history early to allow first incremental update to use diffs
typeHistory.get()
const query: QueryExpression<S> = queryCreator()
if (Object.keys(query).length === 0) {
return this.getAllIdsForType(typeName)
}
return executeQuery(this, typeName, query)
}
const fromScratchWithDiff = (prevValue: Set<IdOf<S>>) => {
const nextValue = fromScratch()
const diff = diffSets(prevValue, nextValue)
if (diff) {
return withDiff(nextValue, diff)
} else {
return prevValue
}
}
const cachedQuery = computed('ids_query:' + name, queryCreator, {
isEqual,
})
return computed(
'query:' + name,
(prevValue, lastComputedEpoch) => {
const query = cachedQuery.get()
if (isUninitialized(prevValue)) {
return fromScratch()
}
// if the query changed since last time this ran then we need to start again
if (lastComputedEpoch < cachedQuery.lastChangedEpoch) {
return fromScratchWithDiff(prevValue)
}
// otherwise iterate over the changes from the store and apply them to the previous value if needed
const history = typeHistory.getDiffSince(lastComputedEpoch)
if (history === RESET_VALUE) {
return fromScratchWithDiff(prevValue)
}
const setConstructor = new IncrementalSetConstructor<IdOf<S>>(
prevValue
) as IncrementalSetConstructor<IdOf<S>>
for (const changes of history) {
for (const added of objectMapValues(changes.added)) {
if (added.typeName === typeName && objectMatchesQuery(query, added)) {
setConstructor.add(added.id)
}
}
for (const [_, updated] of objectMapValues(changes.updated)) {
if (updated.typeName === typeName) {
if (objectMatchesQuery(query, updated)) {
setConstructor.add(updated.id)
} else {
setConstructor.remove(updated.id)
}
}
}
for (const removed of objectMapValues(changes.removed)) {
if (removed.typeName === typeName) {
setConstructor.remove(removed.id)
}
}
}
const result = setConstructor.get()
if (!result) {
return prevValue
}
return withDiff(result.value, result.diff)
},
{ historyLength: 50 }
)
}
/**
* Executes a one-time query against the current store state and returns matching records.
* This is a non-reactive query that returns results immediately without creating a computed value.
* Use this when you need a snapshot of data at a specific point in time.
*
* @param typeName - The type name of records to query
* @param query - The query expression object to match against
* @returns An array of records that match the query at the current moment
*
* @example
* ```ts
* // Get current in-stock books (non-reactive)
* const currentInStockBooks = store.query.exec('book', { inStock: { eq: true } })
* console.log(currentInStockBooks) // Book[]
*
* // Unlike records(), this won't update when the data changes
* const staticBookList = store.query.exec('book', { authorId: { eq: 'author:leguin' } })
* ```
*
* @public
*/
exec<TypeName extends R['typeName']>(
typeName: TypeName,
query: QueryExpression<Extract<R, { typeName: TypeName }>>
): Array<Extract<R, { typeName: TypeName }>> {
const ids = executeQuery(this, typeName, query)
if (ids.size === 0) {
return EMPTY_ARRAY
}
return Array.from(ids, (id) => this.recordMap.get(id) as Extract<R, { typeName: TypeName }>)
}
}