UNPKG

@tldraw/store

Version:

tldraw infinite canvas SDK (store).

678 lines (620 loc) • 21.7 kB
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 }>) } }