@tanstack/db
Version:
A reactive client store for building super fast apps on sync
577 lines (576 loc) • 22.1 kB
text/typescript
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 {};