UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

577 lines (576 loc) 22.1 kB
import { SortedMap } from './SortedMap.cjs'; import { BTreeIndex } from './indexes/btree-index.js'; import { IndexProxy } from './indexes/lazy-index.js'; import { Transaction } from './transactions.cjs'; import { StandardSchemaV1 } from '@standard-schema/spec'; import { SingleRowRefProxy } from './query/builder/ref-proxy.cjs'; import { ChangeListener, ChangeMessage, CollectionConfig, CollectionStatus, CurrentStateAsChangesOptions, Fn, InsertConfig, OperationConfig, OptimisticChangeMessage, ResolveInsertInput, ResolveType, SubscribeChangesOptions, Transaction as TransactionType, UtilsRecord } from './types.cjs'; import { IndexOptions } from './indexes/index-options.js'; import { BaseIndex, IndexResolver } from './indexes/base-index.js'; interface PendingSyncedTransaction<T extends object = Record<string, unknown>> { committed: boolean; operations: Array<OptimisticChangeMessage<T>>; } /** * 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 = {}, TSchema extends StandardSchemaV1 = StandardSchemaV1, TInsertInput extends object = T> extends CollectionImpl<T, TKey, TUtils, TSchema, TInsertInput> { readonly utils: TUtils; } /** * Creates a new Collection instance with the given configuration * * @template TExplicit - The explicit type of items in the collection (highest priority) * @template TKey - The type of the key for the collection * @template TUtils - The utilities record type * @template TSchema - The schema type for validation and type inference (second priority) * @template TFallback - The fallback type if no explicit or schema type is provided * @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: () => {} } * }) * * // Note: You must provide either an explicit type or a schema, but not both. */ export declare function createCollection<TExplicit = unknown, TKey extends string | number = string | number, TUtils extends UtilsRecord = {}, TSchema extends StandardSchemaV1 = StandardSchemaV1, TFallback extends object = Record<string, unknown>>(options: CollectionConfig<ResolveType<TExplicit, TSchema, TFallback>, TKey, TSchema, ResolveInsertInput<TExplicit, TSchema, TFallback>> & { utils?: TUtils; }): Collection<ResolveType<TExplicit, TSchema, TFallback>, TKey, TUtils, TSchema, ResolveInsertInput<TExplicit, TSchema, TFallback>>; export declare class CollectionImpl<T extends object = Record<string, unknown>, TKey extends string | number = string | number, TUtils extends UtilsRecord = {}, TSchema extends StandardSchemaV1 = StandardSchemaV1, TInsertInput extends object = T> { config: CollectionConfig<T, TKey, TSchema, TInsertInput>; transactions: SortedMap<string, Transaction<any>>; pendingSyncedTransactions: Array<PendingSyncedTransaction<T>>; syncedData: Map<TKey, T> | SortedMap<TKey, T>; syncedMetadata: Map<TKey, unknown>; optimisticUpserts: Map<TKey, T>; optimisticDeletes: Set<TKey>; private _size; private lazyIndexes; private resolvedIndexes; private isIndexesResolved; private indexCounter; private changeListeners; private changeKeyListeners; utils: Record<string, Fn>; private syncedKeys; private preSyncVisibleState; private recentlySyncedKeys; private hasReceivedFirstCommit; private isCommittingSyncTransactions; private onFirstReadyCallbacks; private hasBeenReady; private batchedEvents; private shouldBatchEvents; private _status; private activeSubscribersCount; private gcTimeoutId; private preloadPromise; private syncCleanupFn; /** * 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 * }) */ onFirstReady(callback: () => void): void; /** * 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') * } */ isReady(): boolean; /** * Mark the collection as ready for use * This is called by sync implementations to explicitly signal that the collection is ready, * providing a more intuitive alternative to using commits for readiness signaling * @private - Should only be called by sync implementations */ private markReady; id: string; /** * Gets the current status of the collection */ get status(): CollectionStatus; /** * Validates that the collection is in a usable state for data operations * @private */ private validateCollectionUsable; /** * Validates state transitions to prevent invalid status changes * @private */ private validateStatusTransition; /** * Safely update the collection status with validation * @private */ private setStatus; /** * Creates a new Collection instance * * @param config - Configuration object for the collection * @throws Error if sync config is missing */ constructor(config: CollectionConfig<T, TKey, TSchema, TInsertInput>); /** * Start sync immediately - internal method for compiled queries * This bypasses lazy loading for special cases like live query results */ startSyncImmediate(): void; /** * Start the sync process for this collection * This is called when the collection is first accessed or preloaded */ private startSync; /** * Preload the collection data by starting sync if not already started * Multiple concurrent calls will share the same promise */ preload(): Promise<void>; /** * Clean up the collection by stopping sync and clearing data * This can be called manually or automatically by garbage collection */ cleanup(): Promise<void>; /** * Start the garbage collection timer * Called when the collection becomes inactive (no subscribers) */ private startGCTimer; /** * Cancel the garbage collection timer * Called when the collection becomes active again */ private cancelGCTimer; /** * Increment the active subscribers count and start sync if needed */ private addSubscriber; /** * Decrement the active subscribers count and start GC timer if needed */ private removeSubscriber; /** * Recompute optimistic state from active transactions */ private recomputeOptimisticState; /** * Calculate the current size based on synced data and optimistic changes */ private calculateSize; /** * Collect events for optimistic changes */ private collectOptimisticChanges; /** * Get the previous value for a key given previous optimistic state */ private getPreviousValue; /** * Emit an empty ready event to notify subscribers that the collection is ready * This bypasses the normal empty array check in emitEvents */ private emitEmptyReadyEvent; /** * Emit events either immediately or batch them for later emission */ private emitEvents; /** * Get the current value for a key (virtual derived state) */ get(key: TKey): T | undefined; /** * Check if a key exists in the collection (virtual derived state) */ has(key: TKey): boolean; /** * Get the current size of the collection (cached) */ get size(): number; /** * Get all keys (virtual derived state) */ keys(): IterableIterator<TKey>; /** * Get all values (virtual derived state) */ values(): IterableIterator<T>; /** * Get all entries (virtual derived state) */ entries(): IterableIterator<[TKey, T]>; /** * Get all entries (virtual derived state) */ [Symbol.iterator](): IterableIterator<[TKey, T]>; /** * Execute a callback for each entry in the collection */ forEach(callbackfn: (value: T, key: TKey, index: number) => void): void; /** * Create a new array with the results of calling a function for each entry in the collection */ map<U>(callbackfn: (value: T, key: TKey, index: number) => U): Array<U>; /** * Attempts to commit pending synced transactions if there are no active transactions * This method processes operations from pending transactions and applies them to the synced data */ commitPendingTransactions: () => void; /** * Schedule cleanup of a transaction when it completes * @private */ private scheduleTransactionCleanup; private ensureStandardSchema; getKeyFromItem(item: T): TKey; generateGlobalKey(key: any, item: any): string; /** * Creates an index on a collection for faster queries. * Indexes significantly improve query performance by allowing binary search * and 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 }, * 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' } * }) */ createIndex<TResolver extends IndexResolver<TKey> = typeof BTreeIndex>(indexCallback: (row: SingleRowRefProxy<T>) => any, config?: IndexOptions<TResolver>): IndexProxy<TKey>; /** * Resolve all lazy indexes (called when collection first syncs) * @private */ private resolveAllIndexes; /** * Resolve a single index immediately * @private */ private resolveSingleIndex; /** * Get resolved indexes for query optimization */ get indexes(): Map<number, BaseIndex<TKey>>; /** * Updates all indexes when the collection changes * @private */ private updateIndexes; private deepEqual; validateData(data: unknown, type: `insert` | `update`, key?: TKey): T | never; /** * 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: TInsertInput | Array<TInsertInput>, config?: InsertConfig) => Transaction<Record<string, unknown>> | Transaction<T>; /** * 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) * } */ update<TItem extends object = T>(key: Array<TKey | unknown>, callback: (drafts: Array<TItem>) => void): TransactionType; update<TItem extends object = T>(keys: Array<TKey | unknown>, config: OperationConfig, callback: (drafts: Array<TItem>) => void): TransactionType; update<TItem extends object = T>(id: TKey | unknown, callback: (draft: TItem) => void): TransactionType; update<TItem extends object = T>(id: TKey | unknown, config: OperationConfig, callback: (draft: TItem) => void): TransactionType; /** * 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>; /** * 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(): Map<TKey, T>; /** * 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, T>>; /** * Gets the current state of the collection as an Array * * @returns An Array containing all items in the collection */ get toArray(): T[]; /** * 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<T>>; /** * 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') * }) */ currentStateAsChanges(options?: CurrentStateAsChangesOptions<T>): Array<ChangeMessage<T>>; /** * 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 unsubscribe = collection.subscribeChanges((changes) => { * changes.forEach(change => { * console.log(`${change.type}: ${change.key}`, change.value) * }) * }) * * // Later: unsubscribe() * * @example * // Include current state immediately * const unsubscribe = collection.subscribeChanges((changes) => { * updateUI(changes) * }, { includeInitialState: true }) * * @example * // Subscribe only to changes matching a condition * const unsubscribe = collection.subscribeChanges((changes) => { * updateUI(changes) * }, { * includeInitialState: true, * where: (row) => row.status === 'active' * }) * * @example * // Subscribe using a pre-compiled expression * const unsubscribe = collection.subscribeChanges((changes) => { * updateUI(changes) * }, { * includeInitialState: true, * whereExpression: eq(row.status, 'active') * }) */ subscribeChanges(callback: (changes: Array<ChangeMessage<T>>) => void, options?: SubscribeChangesOptions<T>): () => void; /** * Subscribe to changes for a specific key */ subscribeChangesKey(key: TKey, listener: ChangeListener<T, TKey>, { includeInitialState }?: { includeInitialState?: boolean; }): () => void; /** * Capture visible state for keys that will be affected by pending sync operations * This must be called BEFORE onTransactionStateChange clears optimistic state */ private capturePreSyncVisibleState; /** * Trigger a recomputation when transactions change * This method should be called by the Transaction class when state changes */ onTransactionStateChange(): void; } export {};