UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

943 lines (873 loc) 29.4 kB
import { CollectionRequiresConfigError, CollectionRequiresSyncConfigError, } from '../errors' import { currentStateAsChanges } from './change-events' import { CollectionStateManager } from './state' import { CollectionChangesManager } from './changes' import { CollectionLifecycleManager } from './lifecycle.js' import { CollectionSyncManager } from './sync' import { CollectionIndexesManager } from './indexes' import { CollectionMutationsManager } from './mutations' import { CollectionEventsManager } from './events.js' import type { CollectionSubscription } from './subscription' import type { AllCollectionEvents, CollectionEventHandler } from './events.js' import type { BaseIndex, IndexResolver } from '../indexes/base-index.js' import type { IndexOptions } from '../indexes/index-options.js' import type { ChangeMessage, CollectionConfig, CollectionStatus, CurrentStateAsChangesOptions, Fn, InferSchemaInput, InferSchemaOutput, InsertConfig, NonSingleResult, OperationConfig, SingleResult, StringCollationConfig, SubscribeChangesOptions, Transaction as TransactionType, UtilsRecord, WritableDeep, } from '../types' import type { SingleRowRefProxy } from '../query/builder/ref-proxy' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { BTreeIndex } from '../indexes/btree-index.js' import type { IndexProxy } from '../indexes/lazy-index.js' /** * Enhanced Collection interface that includes both data type T and utilities TUtils * @template T - The type of items in the collection * @template TKey - The type of the key for the collection * @template TUtils - The utilities record type * @template TInsertInput - The type for insert operations (can be different from T for schemas with defaults) */ export interface Collection< T extends object = Record<string, unknown>, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, TSchema extends StandardSchemaV1 = StandardSchemaV1, TInsertInput extends object = T, > extends CollectionImpl<T, TKey, TUtils, TSchema, TInsertInput> { readonly utils: TUtils readonly singleResult?: true } /** * Creates a new Collection instance with the given configuration * * @template T - The schema type if a schema is provided, otherwise the type of items in the collection * @template TKey - The type of the key for the collection * @template TUtils - The utilities record type * @param options - Collection options with optional utilities * @returns A new Collection with utilities exposed both at top level and under .utils * * @example * // Pattern 1: With operation handlers (direct collection calls) * const todos = createCollection({ * id: "todos", * getKey: (todo) => todo.id, * schema, * onInsert: async ({ transaction, collection }) => { * // Send to API * await api.createTodo(transaction.mutations[0].modified) * }, * onUpdate: async ({ transaction, collection }) => { * await api.updateTodo(transaction.mutations[0].modified) * }, * onDelete: async ({ transaction, collection }) => { * await api.deleteTodo(transaction.mutations[0].key) * }, * sync: { sync: () => {} } * }) * * // Direct usage (handlers manage transactions) * const tx = todos.insert({ id: "1", text: "Buy milk", completed: false }) * await tx.isPersisted.promise * * @example * // Pattern 2: Manual transaction management * const todos = createCollection({ * getKey: (todo) => todo.id, * schema: todoSchema, * sync: { sync: () => {} } * }) * * // Explicit transaction usage * const tx = createTransaction({ * mutationFn: async ({ transaction }) => { * // Handle all mutations in transaction * await api.saveChanges(transaction.mutations) * } * }) * * tx.mutate(() => { * todos.insert({ id: "1", text: "Buy milk" }) * todos.update("2", draft => { draft.completed = true }) * }) * * await tx.isPersisted.promise * * @example * // Using schema for type inference (preferred as it also gives you client side validation) * const todoSchema = z.object({ * id: z.string(), * title: z.string(), * completed: z.boolean() * }) * * const todos = createCollection({ * schema: todoSchema, * getKey: (todo) => todo.id, * sync: { sync: () => {} } * }) * */ // Overload for when schema is provided and utils is required (not optional) // We can't infer the Utils type from the CollectionConfig because it will always be optional // So we omit it from that type and instead infer it from the extension `& { utils: TUtils }` // such that we have the real, non-optional Utils type export function createCollection< T extends StandardSchemaV1, TKey extends string | number, TUtils extends UtilsRecord, >( options: Omit< CollectionConfig<InferSchemaOutput<T>, TKey, T, TUtils>, `utils` > & { schema: T utils: TUtils // Required utils } & NonSingleResult, ): Collection<InferSchemaOutput<T>, TKey, TUtils, T, InferSchemaInput<T>> & NonSingleResult // Overload for when schema is provided and utils is optional // In this case we can simply infer the Utils type from the CollectionConfig type export function createCollection< T extends StandardSchemaV1, TKey extends string | number, TUtils extends UtilsRecord, >( options: CollectionConfig<InferSchemaOutput<T>, TKey, T, TUtils> & { schema: T } & NonSingleResult, ): Collection< InferSchemaOutput<T>, TKey, Exclude<TUtils, undefined>, T, InferSchemaInput<T> > & NonSingleResult // Overload for when schema is provided, singleResult is true, and utils is required export function createCollection< T extends StandardSchemaV1, TKey extends string | number, TUtils extends UtilsRecord, >( options: Omit< CollectionConfig<InferSchemaOutput<T>, TKey, T, TUtils>, `utils` > & { schema: T utils: TUtils // Required utils } & SingleResult, ): Collection<InferSchemaOutput<T>, TKey, TUtils, T, InferSchemaInput<T>> & SingleResult // Overload for when schema is provided and singleResult is true export function createCollection< T extends StandardSchemaV1, TKey extends string | number, TUtils extends UtilsRecord, >( options: CollectionConfig<InferSchemaOutput<T>, TKey, T, TUtils> & { schema: T } & SingleResult, ): Collection<InferSchemaOutput<T>, TKey, TUtils, T, InferSchemaInput<T>> & SingleResult // Overload for when no schema is provided and utils is required // the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config export function createCollection< T extends object, TKey extends string | number, TUtils extends UtilsRecord, >( options: Omit<CollectionConfig<T, TKey, never, TUtils>, `utils`> & { schema?: never // prohibit schema if an explicit type is provided utils: TUtils // Required utils } & NonSingleResult, ): Collection<T, TKey, TUtils, never, T> & NonSingleResult // Overload for when no schema is provided // the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config export function createCollection< T extends object, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, >( options: CollectionConfig<T, TKey, never, TUtils> & { schema?: never // prohibit schema if an explicit type is provided } & NonSingleResult, ): Collection<T, TKey, TUtils, never, T> & NonSingleResult // Overload for when no schema is provided, singleResult is true, and utils is required // the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config export function createCollection< T extends object, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, >( options: Omit<CollectionConfig<T, TKey, never, TUtils>, `utils`> & { schema?: never // prohibit schema if an explicit type is provided utils: TUtils // Required utils } & SingleResult, ): Collection<T, TKey, TUtils, never, T> & SingleResult // Overload for when no schema is provided and singleResult is true // the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config export function createCollection< T extends object, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, >( options: CollectionConfig<T, TKey, never, TUtils> & { schema?: never // prohibit schema if an explicit type is provided } & SingleResult, ): Collection<T, TKey, TUtils, never, T> & SingleResult // Implementation export function createCollection( options: CollectionConfig<any, string | number, any, UtilsRecord> & { schema?: StandardSchemaV1 }, ): Collection<any, string | number, UtilsRecord, any, any> { const collection = new CollectionImpl<any, string | number, any, any, any>( options, ) // Attach utils to collection if (options.utils) { collection.utils = options.utils } else { collection.utils = {} } return collection } export class CollectionImpl< TOutput extends object = Record<string, unknown>, TKey extends string | number = string | number, TUtils extends UtilsRecord = {}, TSchema extends StandardSchemaV1 = StandardSchemaV1, TInput extends object = TOutput, > { public id: string public config: CollectionConfig<TOutput, TKey, TSchema> // Utilities namespace // This is populated by createCollection public utils: Record<string, Fn> = {} // Managers private _events: CollectionEventsManager private _changes: CollectionChangesManager<TOutput, TKey, TSchema, TInput> public _lifecycle: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput> public _sync: CollectionSyncManager<TOutput, TKey, TSchema, TInput> private _indexes: CollectionIndexesManager<TOutput, TKey, TSchema, TInput> private _mutations: CollectionMutationsManager< TOutput, TKey, TUtils, TSchema, TInput > // The core state of the collection is "public" so that is accessible in tests // and for debugging public _state: CollectionStateManager<TOutput, TKey, TSchema, TInput> private comparisonOpts: StringCollationConfig /** * Creates a new Collection instance * * @param config - Configuration object for the collection * @throws Error if sync config is missing */ constructor(config: CollectionConfig<TOutput, TKey, TSchema>) { // eslint-disable-next-line if (!config) { throw new CollectionRequiresConfigError() } // eslint-disable-next-line if (!config.sync) { throw new CollectionRequiresSyncConfigError() } if (config.id) { this.id = config.id } else { this.id = crypto.randomUUID() } // Set default values for optional config properties this.config = { ...config, autoIndex: config.autoIndex ?? `eager`, } this._changes = new CollectionChangesManager() this._events = new CollectionEventsManager() this._indexes = new CollectionIndexesManager() this._lifecycle = new CollectionLifecycleManager(config, this.id) this._mutations = new CollectionMutationsManager(config, this.id) this._state = new CollectionStateManager(config) this._sync = new CollectionSyncManager(config, this.id) this.comparisonOpts = buildCompareOptionsFromConfig(config) this._changes.setDeps({ collection: this, // Required for passing to CollectionSubscription lifecycle: this._lifecycle, sync: this._sync, events: this._events, }) this._events.setDeps({ collection: this, // Required for adding to emitted events }) this._indexes.setDeps({ state: this._state, lifecycle: this._lifecycle, }) this._lifecycle.setDeps({ changes: this._changes, events: this._events, indexes: this._indexes, state: this._state, sync: this._sync, }) this._mutations.setDeps({ collection: this, // Required for passing to config.onInsert/onUpdate/onDelete and annotating mutations lifecycle: this._lifecycle, state: this._state, }) this._state.setDeps({ collection: this, // Required for filtering events to only include this collection lifecycle: this._lifecycle, changes: this._changes, indexes: this._indexes, events: this._events, }) this._sync.setDeps({ collection: this, // Required for passing to config.sync callback state: this._state, lifecycle: this._lifecycle, events: this._events, }) // Only start sync immediately if explicitly enabled if (config.startSync === true) { this._sync.startSync() } } /** * Gets the current status of the collection */ public get status(): CollectionStatus { return this._lifecycle.status } /** * Get the number of subscribers to the collection */ public get subscriberCount(): number { return this._changes.activeSubscribersCount } /** * Register a callback to be executed when the collection first becomes ready * Useful for preloading collections * @param callback Function to call when the collection first becomes ready * @example * collection.onFirstReady(() => { * console.log('Collection is ready for the first time') * // Safe to access collection.state now * }) */ public onFirstReady(callback: () => void): void { return this._lifecycle.onFirstReady(callback) } /** * Check if the collection is ready for use * Returns true if the collection has been marked as ready by its sync implementation * @returns true if the collection is ready, false otherwise * @example * if (collection.isReady()) { * console.log('Collection is ready, data is available') * // Safe to access collection.state * } else { * console.log('Collection is still loading') * } */ public isReady(): boolean { return this._lifecycle.status === `ready` } /** * Check if the collection is currently loading more data * @returns true if the collection has pending load more operations, false otherwise */ public get isLoadingSubset(): boolean { return this._sync.isLoadingSubset } /** * Start sync immediately - internal method for compiled queries * This bypasses lazy loading for special cases like live query results */ public startSyncImmediate(): void { this._sync.startSync() } /** * Preload the collection data by starting sync if not already started * Multiple concurrent calls will share the same promise */ public preload(): Promise<void> { return this._sync.preload() } /** * Get the current value for a key (virtual derived state) */ public get(key: TKey): TOutput | undefined { return this._state.get(key) } /** * Check if a key exists in the collection (virtual derived state) */ public has(key: TKey): boolean { return this._state.has(key) } /** * Get the current size of the collection (cached) */ public get size(): number { return this._state.size } /** * Get all keys (virtual derived state) */ public *keys(): IterableIterator<TKey> { yield* this._state.keys() } /** * Get all values (virtual derived state) */ public *values(): IterableIterator<TOutput> { yield* this._state.values() } /** * Get all entries (virtual derived state) */ public *entries(): IterableIterator<[TKey, TOutput]> { yield* this._state.entries() } /** * Get all entries (virtual derived state) */ public *[Symbol.iterator](): IterableIterator<[TKey, TOutput]> { yield* this._state[Symbol.iterator]() } /** * Execute a callback for each entry in the collection */ public forEach( callbackfn: (value: TOutput, key: TKey, index: number) => void, ): void { return this._state.forEach(callbackfn) } /** * Create a new array with the results of calling a function for each entry in the collection */ public map<U>( callbackfn: (value: TOutput, key: TKey, index: number) => U, ): Array<U> { return this._state.map(callbackfn) } public getKeyFromItem(item: TOutput): TKey { return this.config.getKey(item) } /** * Creates an index on a collection for faster queries. * Indexes significantly improve query performance by allowing constant time lookups * and logarithmic time range queries instead of full scans. * * @template TResolver - The type of the index resolver (constructor or async loader) * @param indexCallback - Function that extracts the indexed value from each item * @param config - Configuration including index type and type-specific options * @returns An index proxy that provides access to the index when ready * * @example * // Create a default B+ tree index * const ageIndex = collection.createIndex((row) => row.age) * * // Create a ordered index with custom options * const ageIndex = collection.createIndex((row) => row.age, { * indexType: BTreeIndex, * options: { * compareFn: customComparator, * compareOptions: { direction: 'asc', nulls: 'first', stringSort: 'lexical' } * }, * name: 'age_btree' * }) * * // Create an async-loaded index * const textIndex = collection.createIndex((row) => row.content, { * indexType: async () => { * const { FullTextIndex } = await import('./indexes/fulltext.js') * return FullTextIndex * }, * options: { language: 'en' } * }) */ public createIndex<TResolver extends IndexResolver<TKey> = typeof BTreeIndex>( indexCallback: (row: SingleRowRefProxy<TOutput>) => any, config: IndexOptions<TResolver> = {}, ): IndexProxy<TKey> { return this._indexes.createIndex(indexCallback, config) } /** * Get resolved indexes for query optimization */ get indexes(): Map<number, BaseIndex<TKey>> { return this._indexes.indexes } /** * Validates the data against the schema */ public validateData( data: unknown, type: `insert` | `update`, key?: TKey, ): TOutput | never { return this._mutations.validateData(data, type, key) } get compareOptions(): StringCollationConfig { // return a copy such that no one can mutate the internal comparison object return { ...this.comparisonOpts } } /** * Inserts one or more items into the collection * @param items - Single item or array of items to insert * @param config - Optional configuration including metadata * @returns A Transaction object representing the insert operation(s) * @throws {SchemaValidationError} If the data fails schema validation * @example * // Insert a single todo (requires onInsert handler) * const tx = collection.insert({ id: "1", text: "Buy milk", completed: false }) * await tx.isPersisted.promise * * @example * // Insert multiple todos at once * const tx = collection.insert([ * { id: "1", text: "Buy milk", completed: false }, * { id: "2", text: "Walk dog", completed: true } * ]) * await tx.isPersisted.promise * * @example * // Insert with metadata * const tx = collection.insert({ id: "1", text: "Buy groceries" }, * { metadata: { source: "mobile-app" } } * ) * await tx.isPersisted.promise * * @example * // Handle errors * try { * const tx = collection.insert({ id: "1", text: "New item" }) * await tx.isPersisted.promise * console.log('Insert successful') * } catch (error) { * console.log('Insert failed:', error) * } */ insert = (data: TInput | Array<TInput>, config?: InsertConfig) => { return this._mutations.insert(data, config) } /** * Updates one or more items in the collection using a callback function * @param keys - Single key or array of keys to update * @param configOrCallback - Either update configuration or update callback * @param maybeCallback - Update callback if config was provided * @returns A Transaction object representing the update operation(s) * @throws {SchemaValidationError} If the updated data fails schema validation * @example * // Update single item by key * const tx = collection.update("todo-1", (draft) => { * draft.completed = true * }) * await tx.isPersisted.promise * * @example * // Update multiple items * const tx = collection.update(["todo-1", "todo-2"], (drafts) => { * drafts.forEach(draft => { draft.completed = true }) * }) * await tx.isPersisted.promise * * @example * // Update with metadata * const tx = collection.update("todo-1", * { metadata: { reason: "user update" } }, * (draft) => { draft.text = "Updated text" } * ) * await tx.isPersisted.promise * * @example * // Handle errors * try { * const tx = collection.update("item-1", draft => { draft.value = "new" }) * await tx.isPersisted.promise * console.log('Update successful') * } catch (error) { * console.log('Update failed:', error) * } */ // Overload 1: Update multiple items with a callback update( key: Array<TKey | unknown>, callback: (drafts: Array<WritableDeep<TInput>>) => void, ): TransactionType // Overload 2: Update multiple items with config and a callback update( keys: Array<TKey | unknown>, config: OperationConfig, callback: (drafts: Array<WritableDeep<TInput>>) => void, ): TransactionType // Overload 3: Update a single item with a callback update( id: TKey | unknown, callback: (draft: WritableDeep<TInput>) => void, ): TransactionType // Overload 4: Update a single item with config and a callback update( id: TKey | unknown, config: OperationConfig, callback: (draft: WritableDeep<TInput>) => void, ): TransactionType update( keys: (TKey | unknown) | Array<TKey | unknown>, configOrCallback: | ((draft: WritableDeep<TInput>) => void) | ((drafts: Array<WritableDeep<TInput>>) => void) | OperationConfig, maybeCallback?: | ((draft: WritableDeep<TInput>) => void) | ((drafts: Array<WritableDeep<TInput>>) => void), ) { return this._mutations.update(keys, configOrCallback, maybeCallback) } /** * Deletes one or more items from the collection * @param keys - Single key or array of keys to delete * @param config - Optional configuration including metadata * @returns A Transaction object representing the delete operation(s) * @example * // Delete a single item * const tx = collection.delete("todo-1") * await tx.isPersisted.promise * * @example * // Delete multiple items * const tx = collection.delete(["todo-1", "todo-2"]) * await tx.isPersisted.promise * * @example * // Delete with metadata * const tx = collection.delete("todo-1", { metadata: { reason: "completed" } }) * await tx.isPersisted.promise * * @example * // Handle errors * try { * const tx = collection.delete("item-1") * await tx.isPersisted.promise * console.log('Delete successful') * } catch (error) { * console.log('Delete failed:', error) * } */ delete = ( keys: Array<TKey> | TKey, config?: OperationConfig, ): TransactionType<any> => { return this._mutations.delete(keys, config) } /** * Gets the current state of the collection as a Map * @returns Map containing all items in the collection, with keys as identifiers * @example * const itemsMap = collection.state * console.log(`Collection has ${itemsMap.size} items`) * * for (const [key, item] of itemsMap) { * console.log(`${key}: ${item.title}`) * } * * // Check if specific item exists * if (itemsMap.has("todo-1")) { * console.log("Todo 1 exists:", itemsMap.get("todo-1")) * } */ get state() { const result = new Map<TKey, TOutput>() for (const [key, value] of this.entries()) { result.set(key, value) } return result } /** * Gets the current state of the collection as a Map, but only resolves when data is available * Waits for the first sync commit to complete before resolving * * @returns Promise that resolves to a Map containing all items in the collection */ stateWhenReady(): Promise<Map<TKey, TOutput>> { // If we already have data or collection is ready, resolve immediately if (this.size > 0 || this.isReady()) { return Promise.resolve(this.state) } // Use preload to ensure the collection starts loading, then return the state return this.preload().then(() => this.state) } /** * Gets the current state of the collection as an Array * * @returns An Array containing all items in the collection */ get toArray() { return Array.from(this.values()) } /** * Gets the current state of the collection as an Array, but only resolves when data is available * Waits for the first sync commit to complete before resolving * * @returns Promise that resolves to an Array containing all items in the collection */ toArrayWhenReady(): Promise<Array<TOutput>> { // If we already have data or collection is ready, resolve immediately if (this.size > 0 || this.isReady()) { return Promise.resolve(this.toArray) } // Use preload to ensure the collection starts loading, then return the array return this.preload().then(() => this.toArray) } /** * Returns the current state of the collection as an array of changes * @param options - Options including optional where filter * @returns An array of changes * @example * // Get all items as changes * const allChanges = collection.currentStateAsChanges() * * // Get only items matching a condition * const activeChanges = collection.currentStateAsChanges({ * where: (row) => row.status === 'active' * }) * * // Get only items using a pre-compiled expression * const activeChanges = collection.currentStateAsChanges({ * whereExpression: eq(row.status, 'active') * }) */ public currentStateAsChanges( options: CurrentStateAsChangesOptions = {}, ): Array<ChangeMessage<TOutput>> | void { return currentStateAsChanges(this, options) } /** * Subscribe to changes in the collection * @param callback - Function called when items change * @param options - Subscription options including includeInitialState and where filter * @returns Unsubscribe function - Call this to stop listening for changes * @example * // Basic subscription * const subscription = collection.subscribeChanges((changes) => { * changes.forEach(change => { * console.log(`${change.type}: ${change.key}`, change.value) * }) * }) * * // Later: subscription.unsubscribe() * * @example * // Include current state immediately * const subscription = collection.subscribeChanges((changes) => { * updateUI(changes) * }, { includeInitialState: true }) * * @example * // Subscribe only to changes matching a condition * const subscription = collection.subscribeChanges((changes) => { * updateUI(changes) * }, { * includeInitialState: true, * where: (row) => row.status === 'active' * }) * * @example * // Subscribe using a pre-compiled expression * const subscription = collection.subscribeChanges((changes) => { * updateUI(changes) * }, { * includeInitialState: true, * whereExpression: eq(row.status, 'active') * }) */ public subscribeChanges( callback: (changes: Array<ChangeMessage<TOutput>>) => void, options: SubscribeChangesOptions = {}, ): CollectionSubscription { return this._changes.subscribeChanges(callback, options) } /** * Subscribe to a collection event */ public on<T extends keyof AllCollectionEvents>( event: T, callback: CollectionEventHandler<T>, ) { return this._events.on(event, callback) } /** * Subscribe to a collection event once */ public once<T extends keyof AllCollectionEvents>( event: T, callback: CollectionEventHandler<T>, ) { return this._events.once(event, callback) } /** * Unsubscribe from a collection event */ public off<T extends keyof AllCollectionEvents>( event: T, callback: CollectionEventHandler<T>, ) { this._events.off(event, callback) } /** * Wait for a collection event */ public waitFor<T extends keyof AllCollectionEvents>( event: T, timeout?: number, ) { return this._events.waitFor(event, timeout) } /** * Clean up the collection by stopping sync and clearing data * This can be called manually or automatically by garbage collection */ public async cleanup(): Promise<void> { this._lifecycle.cleanup() return Promise.resolve() } } function buildCompareOptionsFromConfig( config: CollectionConfig<any, any, any>, ): StringCollationConfig { if (config.defaultStringCollation) { const options = config.defaultStringCollation return { stringSort: options.stringSort ?? `locale`, locale: options.stringSort === `locale` ? options.locale : undefined, localeOptions: options.stringSort === `locale` ? options.localeOptions : undefined, } } else { return { stringSort: `locale`, } } }