UNPKG

@tldraw/store

Version:

tldraw infinite canvas SDK (store).

1,467 lines (1,348 loc) • 39.8 kB
import { Atom, Reactor, Signal, atom, computed, reactor, transact } from '@tldraw/state' import { WeakCache, assert, filterEntries, getOwnProperty, isEqual, objectMapEntries, objectMapKeys, objectMapValues, throttleToNextFrame, uniqueId, } from '@tldraw/utils' import { AtomMap } from './AtomMap' import { IdOf, RecordId, UnknownRecord } from './BaseRecord' import { RecordScope } from './RecordType' import { RecordsDiff, squashRecordDiffs } from './RecordsDiff' import { StoreQueries } from './StoreQueries' import { SerializedSchema, StoreSchema } from './StoreSchema' import { StoreSideEffects } from './StoreSideEffects' import { devFreeze } from './devFreeze' /** * Extracts the record type from a record ID type. * * @example * ```ts * type BookId = RecordId<Book> * type BookType = RecordFromId<BookId> // Book * ``` * * @public */ export type RecordFromId<K extends RecordId<UnknownRecord>> = K extends RecordId<infer R> ? R : never /** * A diff describing the changes to a collection. * * @example * ```ts * const diff: CollectionDiff<string> = { * added: new Set(['newItem']), * removed: new Set(['oldItem']) * } * ``` * * @public */ export interface CollectionDiff<T> { /** Items that were added to the collection */ added?: Set<T> /** Items that were removed from the collection */ removed?: Set<T> } /** * The source of a change to the store. * - `'user'` - Changes originating from local user actions * - `'remote'` - Changes originating from remote synchronization * * @public */ export type ChangeSource = 'user' | 'remote' /** * Filters for store listeners to control which changes trigger the listener. * * @example * ```ts * const filters: StoreListenerFilters = { * source: 'user', // Only listen to user changes * scope: 'document' // Only listen to document-scoped records * } * ``` * * @public */ export interface StoreListenerFilters { /** Filter by the source of changes */ source: ChangeSource | 'all' /** Filter by the scope of records */ scope: RecordScope | 'all' } /** * An entry containing changes that originated either by user actions or remote changes. * History entries are used to track and replay changes to the store. * * @example * ```ts * const entry: HistoryEntry<Book> = { * changes: { * added: { 'book:123': bookRecord }, * updated: {}, * removed: {} * }, * source: 'user' * } * ``` * * @public */ export interface HistoryEntry<R extends UnknownRecord = UnknownRecord> { /** The changes that occurred in this history entry */ changes: RecordsDiff<R> /** The source of these changes */ source: ChangeSource } /** * A function that will be called when the history changes. * * @example * ```ts * const listener: StoreListener<Book> = (entry) => { * console.log('Changes:', entry.changes) * console.log('Source:', entry.source) * } * * store.listen(listener) * ``` * * @param entry - The history entry containing the changes * * @public */ export type StoreListener<R extends UnknownRecord> = (entry: HistoryEntry<R>) => void /** * A computed cache that stores derived data for records. * The cache automatically updates when underlying records change and cleans up when records are deleted. * * @example * ```ts * const expensiveCache = store.createComputedCache( * 'expensive', * (book: Book) => performExpensiveCalculation(book) * ) * * const result = expensiveCache.get(bookId) * ``` * * @public */ export interface ComputedCache<Data, R extends UnknownRecord> { /** * Get the cached data for a record by its ID. * * @param id - The ID of the record * @returns The cached data or undefined if the record doesn't exist */ get(id: IdOf<R>): Data | undefined } /** * Options for creating a computed cache. * * @example * ```ts * const options: CreateComputedCacheOpts<string[], Book> = { * areRecordsEqual: (a, b) => a.title === b.title, * areResultsEqual: (a, b) => JSON.stringify(a) === JSON.stringify(b) * } * ``` * * @public */ export interface CreateComputedCacheOpts<Data, R extends UnknownRecord> { /** Custom equality function for comparing records */ areRecordsEqual?(a: R, b: R): boolean /** Custom equality function for comparing results */ areResultsEqual?(a: Data, b: Data): boolean } /** * A serialized snapshot of the record store's values. * This is a plain JavaScript object that can be saved to storage or transmitted over the network. * * @example * ```ts * const serialized: SerializedStore<Book> = { * 'book:123': { id: 'book:123', typeName: 'book', title: 'The Lathe of Heaven' }, * 'book:456': { id: 'book:456', typeName: 'book', title: 'The Left Hand of Darkness' } * } * ``` * * @public */ export type SerializedStore<R extends UnknownRecord> = Record<IdOf<R>, R> /** * A snapshot of the store including both data and schema information. * This enables proper migration when loading data from different schema versions. * * @example * ```ts * const snapshot = store.getStoreSnapshot() * // Later... * store.loadStoreSnapshot(snapshot) * ``` * * @public */ export interface StoreSnapshot<R extends UnknownRecord> { /** The serialized store data */ store: SerializedStore<R> /** The serialized schema information */ schema: SerializedSchema } /** * A validator for store records that ensures data integrity. * Validators are called when records are created or updated. * * @example * ```ts * const bookValidator: StoreValidator<Book> = { * validate(record: unknown): Book { * // Validate and return the record * if (typeof record !== 'object' || !record.title) { * throw new Error('Invalid book') * } * return record as Book * } * } * ``` * * @public */ export interface StoreValidator<R extends UnknownRecord> { /** * Validate a record. * * @param record - The record to validate * @returns The validated record * @throws When validation fails */ validate(record: unknown): R /** * Validate a record using a known good version for reference. * * @param knownGoodVersion - A known valid version of the record * @param record - The record to validate * @returns The validated record */ validateUsingKnownGoodVersion?(knownGoodVersion: R, record: unknown): R } /** * A map of validators for each record type in the store. * * @example * ```ts * const validators: StoreValidators<Book | Author> = { * book: bookValidator, * author: authorValidator * } * ``` * * @public */ export type StoreValidators<R extends UnknownRecord> = { [K in R['typeName']]: StoreValidator<Extract<R, { typeName: K }>> } /** * Information about an error that occurred in the store. * * @example * ```ts * const error: StoreError = { * error: new Error('Validation failed'), * phase: 'updateRecord', * recordBefore: oldRecord, * recordAfter: newRecord, * isExistingValidationIssue: false * } * ``` * * @public */ export interface StoreError { /** The error that occurred */ error: Error /** The phase during which the error occurred */ phase: 'initialize' | 'createRecord' | 'updateRecord' | 'tests' /** The record state before the operation (if applicable) */ recordBefore?: unknown /** The record state after the operation */ recordAfter: unknown /** Whether this is an existing validation issue */ isExistingValidationIssue: boolean } /** * Extract the record type from a Store type. * Used internally for type inference. * * @internal */ export type StoreRecord<S extends Store<any>> = S extends Store<infer R> ? R : never /** * A reactive store that manages collections of typed records. * * The Store is the central container for your application's data, providing: * - Reactive state management with automatic updates * - Type-safe record operations * - History tracking and change notifications * - Schema validation and migrations * - Side effects and business logic hooks * - Efficient querying and indexing * * @example * ```ts * // Create a store with schema * const schema = StoreSchema.create({ * book: Book, * author: Author * }) * * const store = new Store({ * schema, * props: {} * }) * * // Add records * const book = Book.create({ title: 'The Lathe of Heaven', author: 'Le Guin' }) * store.put([book]) * * // Listen to changes * store.listen((entry) => { * console.log('Changes:', entry.changes) * }) * ``` * * @public */ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> { /** * The unique identifier of the store instance. * * @public */ public readonly id: string /** * An AtomMap containing the stores records. * * @internal * @readonly */ private readonly records: AtomMap<IdOf<R>, R> /** * An atom containing the store's history. * * @public * @readonly */ readonly history: Atom<number, RecordsDiff<R>> = atom('history', 0, { historyLength: 1000, }) /** * Reactive queries and indexes for efficiently accessing store data. * Provides methods for filtering, indexing, and subscribing to subsets of records. * * @example * ```ts * // Create an index by a property * const booksByAuthor = store.query.index('book', 'author') * * // Get records matching criteria * const inStockBooks = store.query.records('book', () => ({ * inStock: { eq: true } * })) * ``` * * @public * @readonly */ readonly query: StoreQueries<R> /** * A set containing listeners that have been added to this store. * * @internal */ private listeners = new Set<{ onHistory: StoreListener<R>; filters: StoreListenerFilters }>() /** * An array of history entries that have not yet been flushed. * * @internal */ private historyAccumulator = new HistoryAccumulator<R>() /** * A reactor that responds to changes to the history by squashing the accumulated history and * notifying listeners of the changes. * * @internal */ private historyReactor: Reactor /** * Function to dispose of any in-flight timeouts. * * @internal */ private cancelHistoryReactor(): void { /* noop */ } /** * The schema that defines the structure and validation rules for records in this store. * * @public */ readonly schema: StoreSchema<R, Props> /** * Custom properties associated with this store instance. * * @public */ readonly props: Props /** * A mapping of record scopes to the set of record type names that belong to each scope. * Used to filter records by their persistence and synchronization behavior. * * @public */ public readonly scopedTypes: { readonly [K in RecordScope]: ReadonlySet<R['typeName']> } /** * Side effects manager that handles lifecycle events for record operations. * Allows registration of callbacks for create, update, delete, and validation events. * * @example * ```ts * store.sideEffects.registerAfterCreateHandler('book', (book) => { * console.log('Book created:', book.title) * }) * ``` * * @public */ public readonly sideEffects = new StoreSideEffects<R>(this) /** * Creates a new Store instance. * * @example * ```ts * const store = new Store({ * schema: StoreSchema.create({ book: Book }), * props: { appName: 'MyLibrary' }, * initialData: savedData * }) * ``` * * @param config - Configuration object for the store */ constructor(config: { /** Optional unique identifier for the store */ id?: string /** The store's initial data to populate on creation */ initialData?: SerializedStore<R> /** The schema defining record types, validation, and migrations */ schema: StoreSchema<R, Props> /** Custom properties for the store instance */ props: Props }) { const { initialData, schema, id } = config this.id = id ?? uniqueId() this.schema = schema this.props = config.props if (initialData) { this.records = new AtomMap( 'store', objectMapEntries(initialData).map(([id, record]) => [ id, devFreeze(this.schema.validateRecord(this, record, 'initialize', null)), ]) ) } else { this.records = new AtomMap('store') } this.query = new StoreQueries<R>(this.records, this.history) this.historyReactor = reactor( 'Store.historyReactor', () => { // deref to make sure we're subscribed regardless of whether we need to propagate this.history.get() // If we have accumulated history, flush it and update listeners this._flushHistory() }, { scheduleEffect: (cb) => (this.cancelHistoryReactor = throttleToNextFrame(cb)) } ) this.scopedTypes = { document: new Set( objectMapValues(this.schema.types) .filter((t) => t.scope === 'document') .map((t) => t.typeName) ), session: new Set( objectMapValues(this.schema.types) .filter((t) => t.scope === 'session') .map((t) => t.typeName) ), presence: new Set( objectMapValues(this.schema.types) .filter((t) => t.scope === 'presence') .map((t) => t.typeName) ), } } public _flushHistory() { // If we have accumulated history, flush it and update listeners if (this.historyAccumulator.hasChanges()) { const entries = this.historyAccumulator.flush() for (const { changes, source } of entries) { let instanceChanges = null as null | RecordsDiff<R> let documentChanges = null as null | RecordsDiff<R> let presenceChanges = null as null | RecordsDiff<R> for (const { onHistory, filters } of this.listeners) { if (filters.source !== 'all' && filters.source !== source) { continue } if (filters.scope !== 'all') { if (filters.scope === 'document') { documentChanges ??= this.filterChangesByScope(changes, 'document') if (!documentChanges) continue onHistory({ changes: documentChanges, source }) } else if (filters.scope === 'session') { instanceChanges ??= this.filterChangesByScope(changes, 'session') if (!instanceChanges) continue onHistory({ changes: instanceChanges, source }) } else { presenceChanges ??= this.filterChangesByScope(changes, 'presence') if (!presenceChanges) continue onHistory({ changes: presenceChanges, source }) } } else { onHistory({ changes, source }) } } } } } dispose() { this.cancelHistoryReactor() } /** * Filters out non-document changes from a diff. Returns null if there are no changes left. * @param change - the records diff * @param scope - the records scope * @returns */ filterChangesByScope(change: RecordsDiff<R>, scope: RecordScope) { const result = { added: filterEntries(change.added, (_, r) => this.scopedTypes[scope].has(r.typeName)), updated: filterEntries(change.updated, (_, r) => this.scopedTypes[scope].has(r[1].typeName)), removed: filterEntries(change.removed, (_, r) => this.scopedTypes[scope].has(r.typeName)), } if ( Object.keys(result.added).length === 0 && Object.keys(result.updated).length === 0 && Object.keys(result.removed).length === 0 ) { return null } return result } /** * Update the history with a diff of changes. * * @param changes - The changes to add to the history. */ private updateHistory(changes: RecordsDiff<R>): void { this.historyAccumulator.add({ changes, source: this.isMergingRemoteChanges ? 'remote' : 'user', }) if (this.listeners.size === 0) { this.historyAccumulator.clear() } this.history.set(this.history.get() + 1, changes) } validate(phase: 'initialize' | 'createRecord' | 'updateRecord' | 'tests') { this.allRecords().forEach((record) => this.schema.validateRecord(this, record, phase, null)) } /** * Add or update records in the store. If a record with the same ID already exists, it will be updated. * Otherwise, a new record will be created. * * @example * ```ts * // Add new records * const book = Book.create({ title: 'Lathe Of Heaven', author: 'Le Guin' }) * store.put([book]) * * // Update existing record * store.put([{ ...book, title: 'The Lathe of Heaven' }]) * ``` * * @param records - The records to add or update * @param phaseOverride - Override the validation phase (used internally) * @public */ put(records: R[], phaseOverride?: 'initialize'): void { this.atomic(() => { const updates: Record<IdOf<UnknownRecord>, [from: R, to: R]> = {} const additions: Record<IdOf<UnknownRecord>, R> = {} // Iterate through all records, creating, updating or removing as needed let record: R // There's a chance that, despite having records, all of the values are // identical to what they were before; and so we'd end up with an "empty" // history entry. Let's keep track of whether we've actually made any // changes (e.g. additions, deletions, or updates that produce a new value). let didChange = false const source = this.isMergingRemoteChanges ? 'remote' : 'user' for (let i = 0, n = records.length; i < n; i++) { record = records[i] const initialValue = this.records.__unsafe__getWithoutCapture(record.id) // If we already have an atom for this record, update its value. if (initialValue) { // If we have a beforeUpdate callback, run it against the initial and next records record = this.sideEffects.handleBeforeChange(initialValue, record, source) // Validate the record const validated = this.schema.validateRecord( this, record, phaseOverride ?? 'updateRecord', initialValue ) if (validated === initialValue) continue record = devFreeze(record) this.records.set(record.id, record) didChange = true updates[record.id] = [initialValue, record] this.addDiffForAfterEvent(initialValue, record) } else { record = this.sideEffects.handleBeforeCreate(record, source) didChange = true // If we don't have an atom, create one. // Validate the record record = this.schema.validateRecord( this, record as R, phaseOverride ?? 'createRecord', null ) // freeze it record = devFreeze(record) // Mark the change as a new addition. additions[record.id] = record this.addDiffForAfterEvent(null, record) this.records.set(record.id, record) } } // If we did change, update the history if (!didChange) return this.updateHistory({ added: additions, updated: updates, removed: {} as Record<IdOf<R>, R>, }) }) } /** * Remove records from the store by their IDs. * * @example * ```ts * // Remove a single record * store.remove([book.id]) * * // Remove multiple records * store.remove([book1.id, book2.id, book3.id]) * ``` * * @param ids - The IDs of the records to remove * @public */ remove(ids: IdOf<R>[]): void { this.atomic(() => { const toDelete = new Set<IdOf<R>>(ids) const source = this.isMergingRemoteChanges ? 'remote' : 'user' if (this.sideEffects.isEnabled()) { for (const id of ids) { const record = this.records.__unsafe__getWithoutCapture(id) if (!record) continue if (this.sideEffects.handleBeforeDelete(record, source) === false) { toDelete.delete(id) } } } const actuallyDeleted = this.records.deleteMany(toDelete) if (actuallyDeleted.length === 0) return const removed = {} as RecordsDiff<R>['removed'] for (const [id, record] of actuallyDeleted) { removed[id] = record this.addDiffForAfterEvent(record, null) } // Update the history with the removed records. this.updateHistory({ added: {}, updated: {}, removed } as RecordsDiff<R>) }) } /** * Get a record by its ID. This creates a reactive subscription to the record. * * @example * ```ts * const book = store.get(bookId) * if (book) { * console.log(book.title) * } * ``` * * @param id - The ID of the record to get * @returns The record if it exists, undefined otherwise * @public */ get<K extends IdOf<R>>(id: K): RecordFromId<K> | undefined { return this.records.get(id) as RecordFromId<K> | undefined } /** * Get a record by its ID without creating a reactive subscription. * Use this when you need to access a record but don't want reactive updates. * * @example * ```ts * // Won't trigger reactive updates when this record changes * const book = store.unsafeGetWithoutCapture(bookId) * ``` * * @param id - The ID of the record to get * @returns The record if it exists, undefined otherwise * @public */ unsafeGetWithoutCapture<K extends IdOf<R>>(id: K): RecordFromId<K> | undefined { return this.records.__unsafe__getWithoutCapture(id) as RecordFromId<K> | undefined } /** * Serialize the store's records to a plain JavaScript object. * Only includes records matching the specified scope. * * @example * ```ts * // Serialize only document records (default) * const documentData = store.serialize('document') * * // Serialize all records * const allData = store.serialize('all') * ``` * * @param scope - The scope of records to serialize. Defaults to 'document' * @returns The serialized store data * @public */ serialize(scope: RecordScope | 'all' = 'document'): SerializedStore<R> { const result = {} as SerializedStore<R> for (const [id, record] of this.records) { if (scope === 'all' || this.scopedTypes[scope].has(record.typeName)) { result[id as IdOf<R>] = record } } return result } /** * Get a serialized snapshot of the store and its schema. * This includes both the data and schema information needed for proper migration. * * @example * ```ts * const snapshot = store.getStoreSnapshot() * localStorage.setItem('myApp', JSON.stringify(snapshot)) * * // Later... * const saved = JSON.parse(localStorage.getItem('myApp')) * store.loadStoreSnapshot(saved) * ``` * * @param scope - The scope of records to serialize. Defaults to 'document' * @returns A snapshot containing both store data and schema information * @public */ getStoreSnapshot(scope: RecordScope | 'all' = 'document'): StoreSnapshot<R> { return { store: this.serialize(scope), schema: this.schema.serialize(), } } /** * Migrate a serialized snapshot to the current schema version. * This applies any necessary migrations to bring old data up to date. * * @example * ```ts * const oldSnapshot = JSON.parse(localStorage.getItem('myApp')) * const migratedSnapshot = store.migrateSnapshot(oldSnapshot) * ``` * * @param snapshot - The snapshot to migrate * @returns The migrated snapshot with current schema version * @throws Error if migration fails * @public */ migrateSnapshot(snapshot: StoreSnapshot<R>): StoreSnapshot<R> { const migrationResult = this.schema.migrateStoreSnapshot(snapshot) if (migrationResult.type === 'error') { throw new Error(`Failed to migrate snapshot: ${migrationResult.reason}`) } return { store: migrationResult.value, schema: this.schema.serialize(), } } /** * Load a serialized snapshot into the store, replacing all current data. * The snapshot will be automatically migrated to the current schema version if needed. * * @example * ```ts * const snapshot = JSON.parse(localStorage.getItem('myApp')) * store.loadStoreSnapshot(snapshot) * ``` * * @param snapshot - The snapshot to load * @throws Error if migration fails or snapshot is invalid * @public */ loadStoreSnapshot(snapshot: StoreSnapshot<R>): void { const migrationResult = this.schema.migrateStoreSnapshot(snapshot) if (migrationResult.type === 'error') { throw new Error(`Failed to migrate snapshot: ${migrationResult.reason}`) } const prevSideEffectsEnabled = this.sideEffects.isEnabled() try { this.sideEffects.setIsEnabled(false) this.atomic(() => { this.clear() this.put(Object.values(migrationResult.value)) this.ensureStoreIsUsable() }) } finally { this.sideEffects.setIsEnabled(prevSideEffectsEnabled) } } /** * Get an array of all records in the store. * * @example * ```ts * const allRecords = store.allRecords() * const books = allRecords.filter(r => r.typeName === 'book') * ``` * * @returns An array containing all records in the store * @public */ allRecords(): R[] { return Array.from(this.records.values()) } /** * Remove all records from the store. * * @example * ```ts * store.clear() * console.log(store.allRecords().length) // 0 * ``` * * @public */ clear(): void { this.remove(Array.from(this.records.keys())) } /** * Update a single record using an updater function. To update multiple records at once, * use the `update` method of the `TypedStore` class. * * @example * ```ts * store.update(book.id, (book) => ({ * ...book, * title: 'Updated Title' * })) * ``` * * @param id - The ID of the record to update * @param updater - A function that receives the current record and returns the updated record * @public */ update<K extends IdOf<R>>(id: K, updater: (record: RecordFromId<K>) => RecordFromId<K>) { const existing = this.unsafeGetWithoutCapture(id) if (!existing) { console.error(`Record ${id} not found. This is probably an error`) return } this.put([updater(existing) as any]) } /** * Check whether a record with the given ID exists in the store. * * @example * ```ts * if (store.has(bookId)) { * console.log('Book exists!') * } * ``` * * @param id - The ID of the record to check * @returns True if the record exists, false otherwise * @public */ has<K extends IdOf<R>>(id: K): boolean { return this.records.has(id) } /** * Add a listener that will be called when the store changes. * Returns a function to remove the listener. * * @example * ```ts * const removeListener = store.listen((entry) => { * console.log('Changes:', entry.changes) * console.log('Source:', entry.source) * }) * * // Listen only to user changes to document records * const removeDocumentListener = store.listen( * (entry) => console.log('Document changed:', entry), * { source: 'user', scope: 'document' } * ) * * // Later, remove the listener * removeListener() * ``` * * @param onHistory - The listener function to call when changes occur * @param filters - Optional filters to control when the listener is called * @returns A function that removes the listener when called * @public */ listen(onHistory: StoreListener<R>, filters?: Partial<StoreListenerFilters>) { // flush history so that this listener's history starts from exactly now this._flushHistory() const listener = { onHistory, filters: { source: filters?.source ?? 'all', scope: filters?.scope ?? 'all', }, } if (!this.historyReactor.scheduler.isActivelyListening) { this.historyReactor.start() this.historyReactor.scheduler.execute() } this.listeners.add(listener) return () => { this.listeners.delete(listener) if (this.listeners.size === 0) { this.historyReactor.stop() } } } private isMergingRemoteChanges = false /** * Merge changes from a remote source. Changes made within the provided function * will be marked with source 'remote' instead of 'user'. * * @example * ```ts * // Changes from sync/collaboration * store.mergeRemoteChanges(() => { * store.put(remoteRecords) * store.remove(deletedIds) * }) * ``` * * @param fn - A function that applies the remote changes * @public */ mergeRemoteChanges(fn: () => void) { if (this.isMergingRemoteChanges) { return fn() } if (this._isInAtomicOp) { throw new Error('Cannot merge remote changes while in atomic operation') } try { this.atomic(fn, true, true) } finally { this.ensureStoreIsUsable() } } /** * Run `fn` and return a {@link RecordsDiff} of the changes that occurred as a result. */ extractingChanges(fn: () => void): RecordsDiff<R> { const changes: Array<RecordsDiff<R>> = [] const dispose = this.historyAccumulator.addInterceptor((entry) => changes.push(entry.changes)) try { transact(fn) return squashRecordDiffs(changes) } finally { dispose() } } applyDiff( diff: RecordsDiff<R>, { runCallbacks = true, ignoreEphemeralKeys = false, }: { runCallbacks?: boolean; ignoreEphemeralKeys?: boolean } = {} ) { this.atomic(() => { const toPut = objectMapValues(diff.added) for (const [_from, to] of objectMapValues(diff.updated)) { const type = this.schema.getType(to.typeName) if (ignoreEphemeralKeys && type.ephemeralKeySet.size) { const existing = this.get(to.id) if (!existing) { toPut.push(to) continue } let changed: R | null = null for (const [key, value] of Object.entries(to)) { if (type.ephemeralKeySet.has(key) || Object.is(value, getOwnProperty(existing, key))) { continue } if (!changed) changed = { ...existing } as R ;(changed as any)[key] = value } if (changed) toPut.push(changed) } else { toPut.push(to) } } const toRemove = objectMapKeys(diff.removed) if (toPut.length) { this.put(toPut) } if (toRemove.length) { this.remove(toRemove) } }, runCallbacks) } /** * Create a cache based on values in the store. Pass in a function that takes and ID and a * signal for the underlying record. Return a signal (usually a computed) for the cached value. * For simple derivations, use {@link Store.createComputedCache}. This function is useful if you * need more precise control over intermediate values. */ createCache<Result, Record extends R = R>( create: (id: IdOf<Record>, recordSignal: Signal<R>) => Signal<Result> ) { const cache = new WeakCache<Atom<any>, Signal<Result>>() return { get: (id: IdOf<Record>) => { const atom = this.records.getAtom(id) if (!atom) return undefined return cache.get(atom, () => create(id, atom as Signal<R>)).get() }, } } /** * Create a computed cache. * * @param name - The name of the derivation cache. * @param derive - A function used to derive the value of the cache. * @param opts - Options for the computed cache. * @public */ createComputedCache<Result, Record extends R = R>( name: string, derive: (record: Record) => Result | undefined, opts?: CreateComputedCacheOpts<Result, Record> ): ComputedCache<Result, Record> { return this.createCache((id, record) => { const recordSignal = opts?.areRecordsEqual ? computed(`${name}:${id}:isEqual`, () => record.get(), { isEqual: opts.areRecordsEqual }) : record return computed<Result | undefined>( name + ':' + id, () => { return derive(recordSignal.get() as Record) }, { isEqual: opts?.areResultsEqual, } ) }) } private _integrityChecker?: () => void | undefined /** @internal */ ensureStoreIsUsable() { this.atomic(() => { this._integrityChecker ??= this.schema.createIntegrityChecker(this) this._integrityChecker?.() }) } private _isPossiblyCorrupted = false /** @internal */ markAsPossiblyCorrupted() { this._isPossiblyCorrupted = true } /** @internal */ isPossiblyCorrupted() { return this._isPossiblyCorrupted } private pendingAfterEvents: Map<IdOf<R>, { before: R | null; after: R | null }> | null = null private addDiffForAfterEvent(before: R | null, after: R | null) { assert(this.pendingAfterEvents, 'must be in event operation') if (before === after) return if (before && after) assert(before.id === after.id) if (!before && !after) return const id = (before || after)!.id const existing = this.pendingAfterEvents.get(id) if (existing) { existing.after = after } else { this.pendingAfterEvents.set(id, { before, after }) } } private flushAtomicCallbacks(isMergingRemoteChanges: boolean) { let updateDepth = 0 let source: ChangeSource = isMergingRemoteChanges ? 'remote' : 'user' while (this.pendingAfterEvents) { const events = this.pendingAfterEvents this.pendingAfterEvents = null if (!this.sideEffects.isEnabled()) continue updateDepth++ if (updateDepth > 100) { throw new Error('Maximum store update depth exceeded, bailing out') } for (const { before, after } of events.values()) { if (before && after && before !== after && !isEqual(before, after)) { this.sideEffects.handleAfterChange(before, after, source) } else if (before && !after) { this.sideEffects.handleAfterDelete(before, source) } else if (!before && after) { this.sideEffects.handleAfterCreate(after, source) } } if (!this.pendingAfterEvents) { this.sideEffects.handleOperationComplete(source) } else { // if the side effects triggered by a remote operation resulted in more effects, // those extra effects should not be marked as originating remotely. source = 'user' } } } private _isInAtomicOp = false /** @internal */ atomic<T>(fn: () => T, runCallbacks = true, isMergingRemoteChanges = false): T { return transact(() => { if (this._isInAtomicOp) { if (!this.pendingAfterEvents) this.pendingAfterEvents = new Map() const prevSideEffectsEnabled = this.sideEffects.isEnabled() assert(!isMergingRemoteChanges, 'cannot call mergeRemoteChanges while in atomic operation') try { // if we are in an atomic context with side effects ON allow switching before* callbacks OFF. // but don't allow switching them ON if they had been marked OFF before. if (prevSideEffectsEnabled && !runCallbacks) { this.sideEffects.setIsEnabled(false) } return fn() } finally { this.sideEffects.setIsEnabled(prevSideEffectsEnabled) } } this.pendingAfterEvents = new Map() const prevSideEffectsEnabled = this.sideEffects.isEnabled() this.sideEffects.setIsEnabled(runCallbacks ?? prevSideEffectsEnabled) this._isInAtomicOp = true if (isMergingRemoteChanges) { this.isMergingRemoteChanges = true } try { const result = fn() this.isMergingRemoteChanges = false this.flushAtomicCallbacks(isMergingRemoteChanges) return result } finally { this.pendingAfterEvents = null this.sideEffects.setIsEnabled(prevSideEffectsEnabled) this._isInAtomicOp = false this.isMergingRemoteChanges = false } }) } /** @internal */ addHistoryInterceptor(fn: (entry: HistoryEntry<R>, source: ChangeSource) => void) { return this.historyAccumulator.addInterceptor((entry) => fn(entry, this.isMergingRemoteChanges ? 'remote' : 'user') ) } } /** * Collect and squash history entries by their adjacent sources. * Adjacent entries from the same source are combined into a single entry. * * For example: [user, user, remote, remote, user] becomes [user, remote, user] * * @example * ```ts * const entries = [ * { source: 'user', changes: userChanges1 }, * { source: 'user', changes: userChanges2 }, * { source: 'remote', changes: remoteChanges } * ] * * const squashed = squashHistoryEntries(entries) * // Results in 2 entries: combined user changes + remote changes * ``` * * @param entries - The array of history entries to squash * @returns An array of squashed history entries * @public */ function squashHistoryEntries<T extends UnknownRecord>( entries: HistoryEntry<T>[] ): HistoryEntry<T>[] { if (entries.length === 0) return [] const chunked: HistoryEntry<T>[][] = [] let chunk: HistoryEntry<T>[] = [entries[0]] let entry: HistoryEntry<T> for (let i = 1, n = entries.length; i < n; i++) { entry = entries[i] if (chunk[0].source !== entry.source) { chunked.push(chunk) chunk = [] } chunk.push(entry) } // Push the last chunk chunked.push(chunk) return devFreeze( chunked.map((chunk) => ({ source: chunk[0].source, changes: squashRecordDiffs(chunk.map((e) => e.changes)), })) ) } /** * Internal class that accumulates history entries before they are flushed to listeners. * Handles batching and squashing of adjacent entries from the same source. * * @internal */ class HistoryAccumulator<T extends UnknownRecord> { private _history: HistoryEntry<T>[] = [] private _interceptors: Set<(entry: HistoryEntry<T>) => void> = new Set() /** * Add an interceptor that will be called for each history entry. * Returns a function to remove the interceptor. */ addInterceptor(fn: (entry: HistoryEntry<T>) => void) { this._interceptors.add(fn) return () => { this._interceptors.delete(fn) } } /** * Add a history entry to the accumulator. * Calls all registered interceptors with the entry. */ add(entry: HistoryEntry<T>) { this._history.push(entry) for (const interceptor of this._interceptors) { interceptor(entry) } } /** * Flush all accumulated history entries, squashing adjacent entries from the same source. * Clears the internal history buffer. */ flush() { const history = squashHistoryEntries(this._history) this._history = [] return history } /** * Clear all accumulated history entries without flushing. */ clear() { this._history = [] } /** * Check if there are any accumulated history entries. */ hasChanges() { return this._history.length > 0 } } /** * A store or an object containing a store. * This type is used for APIs that can accept either a store directly or an object with a store property. * * @example * ```ts * function useStore(storeOrObject: StoreObject<MyRecord>) { * const store = storeOrObject instanceof Store ? storeOrObject : storeOrObject.store * return store * } * ``` * * @public */ export type StoreObject<R extends UnknownRecord> = Store<R> | { store: Store<R> } /** * Extract the record type from a StoreObject. * * @example * ```ts * type MyStoreObject = { store: Store<Book | Author> } * type Records = StoreObjectRecordType<MyStoreObject> // Book | Author * ``` * * @public */ export type StoreObjectRecordType<Context extends StoreObject<any>> = Context extends Store<infer R> ? R : Context extends { store: Store<infer R> } ? R : never /** * Create a computed cache that works with any StoreObject (store or object containing a store). * This is a standalone version of Store.createComputedCache that can work with multiple store instances. * * @example * ```ts * const expensiveCache = createComputedCache( * 'expensiveData', * (context: { store: Store<Book> }, book: Book) => { * return performExpensiveCalculation(book) * } * ) * * // Use with different store instances * const result1 = expensiveCache.get(storeObject1, bookId) * const result2 = expensiveCache.get(storeObject2, bookId) * ``` * * @param name - A unique name for the cache (used for debugging) * @param derive - Function that derives a value from the context and record * @param opts - Optional configuration for equality checks * @returns A cache that can be used with multiple store instances * @public */ export function createComputedCache< Context extends StoreObject<any>, Result, Record extends StoreObjectRecordType<Context> = StoreObjectRecordType<Context>, >( name: string, derive: (context: Context, record: Record) => Result | undefined, opts?: CreateComputedCacheOpts<Result, Record> ) { const cache = new WeakCache<Context, ComputedCache<Result, Record>>() return { get(context: Context, id: IdOf<Record>) { const computedCache = cache.get(context, () => { const store = (context instanceof Store ? context : context.store) as Store<Record> return store.createComputedCache(name, (record) => derive(context, record), opts) }) return computedCache.get(id) }, } }