UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

1,362 lines (1,194 loc) 42.3 kB
import { Store } from "@tanstack/store" import { withArrayChangeTracking, withChangeTracking } from "./proxy" import { Transaction, getActiveTransaction } from "./transactions" import { SortedMap } from "./SortedMap" import type { ChangeListener, ChangeMessage, CollectionConfig, Fn, InsertConfig, OperationConfig, OptimisticChangeMessage, PendingMutation, StandardSchema, Transaction as TransactionType, UtilsRecord, } from "./types" // Store collections in memory export const collectionsStore = new Map<string, CollectionImpl<any, any>>() // Map to track loading collections const loadingCollectionResolvers = new Map< string, { promise: Promise<CollectionImpl<any, any>> resolve: (value: CollectionImpl<any, any>) => void } >() 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 TUtils - The utilities record type */ export interface Collection< T extends object = Record<string, unknown>, TKey extends string | number = string | number, TUtils extends UtilsRecord = {}, > extends CollectionImpl<T, TKey> { readonly utils: TUtils } /** * Creates a new Collection instance with the given configuration * * @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 * @param options - Collection options with optional utilities * @returns A new Collection with utilities exposed both at top level and under .utils */ export function createCollection< T extends object = Record<string, unknown>, TKey extends string | number = string | number, TUtils extends UtilsRecord = {}, >( options: CollectionConfig<T, TKey> & { utils?: TUtils } ): Collection<T, TKey, TUtils> { const collection = new CollectionImpl<T, TKey>(options) // Copy utils to both top level and .utils namespace if (options.utils) { collection.utils = { ...options.utils } } else { collection.utils = {} as TUtils } return collection as Collection<T, TKey, TUtils> } /** * Preloads a collection with the given configuration * Returns a promise that resolves once the sync tool has done its first commit (initial sync is finished) * If the collection has already loaded, it resolves immediately * * This function is useful in route loaders or similar pre-rendering scenarios where you want * to ensure data is available before a route transition completes. It uses the same shared collection * instance that will be used by useCollection, ensuring data consistency. * * @example * ```typescript * // In a route loader * async function loader({ params }) { * await preloadCollection({ * id: `users-${params.userId}`, * sync: { ... }, * }); * * return null; * } * ``` * * @template T - The type of items in the collection * @param config - Configuration for the collection, including id and sync * @returns Promise that resolves when the initial sync is finished */ export function preloadCollection< T extends object = Record<string, unknown>, TKey extends string | number = string | number, >(config: CollectionConfig<T, TKey>): Promise<CollectionImpl<T, TKey>> { if (!config.id) { throw new Error(`The id property is required for preloadCollection`) } // If the collection is already fully loaded, return a resolved promise if ( collectionsStore.has(config.id) && !loadingCollectionResolvers.has(config.id) ) { return Promise.resolve( collectionsStore.get(config.id)! as CollectionImpl<T, TKey> ) } // If the collection is in the process of loading, return its promise if (loadingCollectionResolvers.has(config.id)) { return loadingCollectionResolvers.get(config.id)!.promise } // Create a new collection instance if it doesn't exist if (!collectionsStore.has(config.id)) { collectionsStore.set( config.id, createCollection<T, TKey>({ id: config.id, getKey: config.getKey, sync: config.sync, schema: config.schema, }) ) } const collection = collectionsStore.get(config.id)! as CollectionImpl<T, TKey> // Create a promise that will resolve after the first commit let resolveFirstCommit: (value: CollectionImpl<T, TKey>) => void const firstCommitPromise = new Promise<CollectionImpl<T, TKey>>((resolve) => { resolveFirstCommit = resolve }) // Store the loading promise first loadingCollectionResolvers.set(config.id, { promise: firstCommitPromise, resolve: resolveFirstCommit!, }) // Register a one-time listener for the first commit collection.onFirstCommit(() => { if (!config.id) { throw new Error(`The id property is required for preloadCollection`) } if (loadingCollectionResolvers.has(config.id)) { const resolver = loadingCollectionResolvers.get(config.id)! loadingCollectionResolvers.delete(config.id) resolver.resolve(collection) } }) return firstCommitPromise } /** * Custom error class for schema validation errors */ export class SchemaValidationError extends Error { type: `insert` | `update` issues: ReadonlyArray<{ message: string path?: ReadonlyArray<string | number | symbol> }> constructor( type: `insert` | `update`, issues: ReadonlyArray<{ message: string path?: ReadonlyArray<string | number | symbol> }>, message?: string ) { const defaultMessage = `${type === `insert` ? `Insert` : `Update`} validation failed: ${issues .map((issue) => issue.message) .join(`, `)}` super(message || defaultMessage) this.name = `SchemaValidationError` this.type = type this.issues = issues } } export class CollectionImpl< T extends object = Record<string, unknown>, TKey extends string | number = string | number, > { public transactions: SortedMap<string, Transaction<any>> // Core state - make public for testing public syncedData = new Map<TKey, T>() public syncedMetadata = new Map<TKey, unknown>() // Optimistic state tracking - make public for testing public derivedUpserts = new Map<TKey, T>() public derivedDeletes = new Set<TKey>() // Cached size for performance private _size = 0 // Event system private changeListeners = new Set<ChangeListener<T, TKey>>() private changeKeyListeners = new Map<TKey, Set<ChangeListener<T, TKey>>>() // Utilities namespace // This is populated by createCollection public utils: Record<string, Fn> = {} private pendingSyncedTransactions: Array<PendingSyncedTransaction<T>> = [] private syncedKeys = new Set<TKey>() public config: CollectionConfig<T, TKey> private hasReceivedFirstCommit = false // Array to store one-time commit listeners private onFirstCommitCallbacks: Array<() => void> = [] /** * Register a callback to be executed on the next commit * Useful for preloading collections * @param callback Function to call after the next commit */ public onFirstCommit(callback: () => void): void { this.onFirstCommitCallbacks.push(callback) } public id = `` /** * Creates a new Collection instance * * @param config - Configuration object for the collection * @throws Error if sync config is missing */ constructor(config: CollectionConfig<T, TKey>) { // eslint-disable-next-line if (!config) { throw new Error(`Collection requires a config`) } if (config.id) { this.id = config.id } else { this.id = crypto.randomUUID() } // eslint-disable-next-line if (!config.sync) { throw new Error(`Collection requires a sync config`) } this.transactions = new SortedMap<string, Transaction<any>>( (a, b) => a.createdAt.getTime() - b.createdAt.getTime() ) this.config = config // Start the sync process config.sync.sync({ collection: this, begin: () => { this.pendingSyncedTransactions.push({ committed: false, operations: [], }) }, write: (messageWithoutKey: Omit<ChangeMessage<T>, `key`>) => { const pendingTransaction = this.pendingSyncedTransactions[ this.pendingSyncedTransactions.length - 1 ] if (!pendingTransaction) { throw new Error(`No pending sync transaction to write to`) } if (pendingTransaction.committed) { throw new Error( `The pending sync transaction is already committed, you can't still write to it.` ) } const key = this.getKeyFromItem(messageWithoutKey.value) // Check if an item with this key already exists when inserting if (messageWithoutKey.type === `insert`) { if ( this.syncedData.has(key) && !pendingTransaction.operations.some( (op) => op.key === key && op.type === `delete` ) ) { throw new Error( `Cannot insert document with key "${key}" from sync because it already exists in the collection "${this.id}"` ) } } const message: ChangeMessage<T> = { ...messageWithoutKey, key, } pendingTransaction.operations.push(message) }, commit: () => { const pendingTransaction = this.pendingSyncedTransactions[ this.pendingSyncedTransactions.length - 1 ] if (!pendingTransaction) { throw new Error(`No pending sync transaction to commit`) } if (pendingTransaction.committed) { throw new Error( `The pending sync transaction is already committed, you can't commit it again.` ) } pendingTransaction.committed = true this.commitPendingTransactions() }, }) } /** * Recompute optimistic state from active transactions */ private recomputeOptimisticState(): void { const previousState = new Map(this.derivedUpserts) const previousDeletes = new Set(this.derivedDeletes) // Clear current optimistic state this.derivedUpserts.clear() this.derivedDeletes.clear() // Apply active transactions const activeTransactions = Array.from(this.transactions.values()) for (const transaction of activeTransactions) { if (![`completed`, `failed`].includes(transaction.state)) { for (const mutation of transaction.mutations) { if (mutation.collection === this) { switch (mutation.type) { case `insert`: case `update`: this.derivedUpserts.set(mutation.key, mutation.modified as T) this.derivedDeletes.delete(mutation.key) break case `delete`: this.derivedUpserts.delete(mutation.key) this.derivedDeletes.add(mutation.key) break } } } } } // Update cached size this._size = this.calculateSize() // Collect events for changes const events: Array<ChangeMessage<T, TKey>> = [] this.collectOptimisticChanges(previousState, previousDeletes, events) // Emit all events at once this.emitEvents(events) } /** * Calculate the current size based on synced data and optimistic changes */ private calculateSize(): number { const syncedSize = this.syncedData.size const deletesFromSynced = Array.from(this.derivedDeletes).filter( (key) => this.syncedData.has(key) && !this.derivedUpserts.has(key) ).length const upsertsNotInSynced = Array.from(this.derivedUpserts.keys()).filter( (key) => !this.syncedData.has(key) ).length return syncedSize - deletesFromSynced + upsertsNotInSynced } /** * Collect events for optimistic changes */ private collectOptimisticChanges( previousUpserts: Map<TKey, T>, previousDeletes: Set<TKey>, events: Array<ChangeMessage<T, TKey>> ): void { const allKeys = new Set([ ...previousUpserts.keys(), ...this.derivedUpserts.keys(), ...previousDeletes, ...this.derivedDeletes, ]) for (const key of allKeys) { const currentValue = this.get(key) const previousValue = this.getPreviousValue( key, previousUpserts, previousDeletes ) if (previousValue !== undefined && currentValue === undefined) { events.push({ type: `delete`, key, value: previousValue }) } else if (previousValue === undefined && currentValue !== undefined) { events.push({ type: `insert`, key, value: currentValue }) } else if ( previousValue !== undefined && currentValue !== undefined && previousValue !== currentValue ) { events.push({ type: `update`, key, value: currentValue, previousValue, }) } } } /** * Get the previous value for a key given previous optimistic state */ private getPreviousValue( key: TKey, previousUpserts: Map<TKey, T>, previousDeletes: Set<TKey> ): T | undefined { if (previousDeletes.has(key)) { return undefined } if (previousUpserts.has(key)) { return previousUpserts.get(key) } return this.syncedData.get(key) } /** * Emit multiple events at once to all listeners */ private emitEvents(changes: Array<ChangeMessage<T, TKey>>): void { if (changes.length > 0) { // Emit to general listeners for (const listener of this.changeListeners) { listener(changes) } // Emit to key-specific listeners if (this.changeKeyListeners.size > 0) { // Group changes by key, but only for keys that have listeners const changesByKey = new Map<TKey, Array<ChangeMessage<T, TKey>>>() for (const change of changes) { if (this.changeKeyListeners.has(change.key)) { if (!changesByKey.has(change.key)) { changesByKey.set(change.key, []) } changesByKey.get(change.key)!.push(change) } } // Emit batched changes to each key's listeners for (const [key, keyChanges] of changesByKey) { const keyListeners = this.changeKeyListeners.get(key)! for (const listener of keyListeners) { listener(keyChanges) } } } } } /** * Get the current value for a key (virtual derived state) */ public get(key: TKey): T | undefined { // Check if optimistically deleted if (this.derivedDeletes.has(key)) { return undefined } // Check optimistic upserts first if (this.derivedUpserts.has(key)) { return this.derivedUpserts.get(key) } // Fall back to synced data return this.syncedData.get(key) } /** * Check if a key exists in the collection (virtual derived state) */ public has(key: TKey): boolean { // Check if optimistically deleted if (this.derivedDeletes.has(key)) { return false } // Check optimistic upserts first if (this.derivedUpserts.has(key)) { return true } // Fall back to synced data return this.syncedData.has(key) } /** * Get the current size of the collection (cached) */ public get size(): number { return this._size } /** * Get all keys (virtual derived state) */ public *keys(): IterableIterator<TKey> { // Yield keys from synced data, skipping any that are deleted. for (const key of this.syncedData.keys()) { if (!this.derivedDeletes.has(key)) { yield key } } // Yield keys from upserts that were not already in synced data. for (const key of this.derivedUpserts.keys()) { if (!this.syncedData.has(key) && !this.derivedDeletes.has(key)) { // The derivedDeletes check is technically redundant if inserts/updates always remove from deletes, // but it's safer to keep it. yield key } } } /** * Get all values (virtual derived state) */ public *values(): IterableIterator<T> { for (const key of this.keys()) { const value = this.get(key) if (value !== undefined) { yield value } } } /** * Get all entries (virtual derived state) */ public *entries(): IterableIterator<[TKey, T]> { for (const key of this.keys()) { const value = this.get(key) if (value !== undefined) { yield [key, value] } } } /** * 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 = () => { if ( !Array.from(this.transactions.values()).some( ({ state }) => state === `persisting` ) ) { const changedKeys = new Set<TKey>() const events: Array<ChangeMessage<T, TKey>> = [] for (const transaction of this.pendingSyncedTransactions) { for (const operation of transaction.operations) { const key = operation.key as TKey changedKeys.add(key) this.syncedKeys.add(key) // Update metadata switch (operation.type) { case `insert`: this.syncedMetadata.set(key, operation.metadata) break case `update`: this.syncedMetadata.set( key, Object.assign( {}, this.syncedMetadata.get(key), operation.metadata ) ) break case `delete`: this.syncedMetadata.delete(key) break } // Update synced data and collect events const previousValue = this.syncedData.get(key) switch (operation.type) { case `insert`: this.syncedData.set(key, operation.value) if ( !this.derivedDeletes.has(key) && !this.derivedUpserts.has(key) ) { events.push({ type: `insert`, key, value: operation.value, }) } break case `update`: { const updatedValue = Object.assign( {}, this.syncedData.get(key), operation.value ) this.syncedData.set(key, updatedValue) if ( !this.derivedDeletes.has(key) && !this.derivedUpserts.has(key) ) { events.push({ type: `update`, key, value: updatedValue, previousValue, }) } break } case `delete`: this.syncedData.delete(key) if ( !this.derivedDeletes.has(key) && !this.derivedUpserts.has(key) ) { if (previousValue) { events.push({ type: `delete`, key, value: previousValue, }) } } break } } } // Update cached size after synced data changes this._size = this.calculateSize() // Emit all events at once this.emitEvents(events) this.pendingSyncedTransactions = [] // Call any registered one-time commit listeners if (!this.hasReceivedFirstCommit) { this.hasReceivedFirstCommit = true const callbacks = [...this.onFirstCommitCallbacks] this.onFirstCommitCallbacks = [] callbacks.forEach((callback) => callback()) } } } private ensureStandardSchema(schema: unknown): StandardSchema<T> { // If the schema already implements the standard-schema interface, return it if (schema && typeof schema === `object` && `~standard` in schema) { return schema as StandardSchema<T> } throw new Error( `Schema must either implement the standard-schema interface or be a Zod schema` ) } public getKeyFromItem(item: T): TKey { return this.config.getKey(item) } public generateGlobalKey(key: any, item: any): string { if (typeof key === `undefined`) { throw new Error( `An object was created without a defined key: ${JSON.stringify(item)}` ) } return `KEY::${this.id}/${key}` } private validateData( data: unknown, type: `insert` | `update`, key?: TKey ): T | never { if (!this.config.schema) return data as T const standardSchema = this.ensureStandardSchema(this.config.schema) // For updates, we need to merge with the existing data before validation if (type === `update` && key) { // Get the existing data for this key const existingData = this.get(key) if ( existingData && data && typeof data === `object` && typeof existingData === `object` ) { // Merge the update with the existing data const mergedData = Object.assign({}, existingData, data) // Validate the merged data const result = standardSchema[`~standard`].validate(mergedData) // Ensure validation is synchronous if (result instanceof Promise) { throw new TypeError(`Schema validation must be synchronous`) } // If validation fails, throw a SchemaValidationError with the issues if (`issues` in result && result.issues) { const typedIssues = result.issues.map((issue) => ({ message: issue.message, path: issue.path?.map((p) => String(p)), })) throw new SchemaValidationError(type, typedIssues) } // Return the original update data, not the merged data // We only used the merged data for validation return data as T } } // For inserts or updates without existing data, validate the data directly const result = standardSchema[`~standard`].validate(data) // Ensure validation is synchronous if (result instanceof Promise) { throw new TypeError(`Schema validation must be synchronous`) } // If validation fails, throw a SchemaValidationError with the issues if (`issues` in result && result.issues) { const typedIssues = result.issues.map((issue) => ({ message: issue.message, path: issue.path?.map((p) => String(p)), })) throw new SchemaValidationError(type, typedIssues) } return result.value as T } /** * Inserts one or more items into the collection * @param items - Single item or array of items to insert * @param config - Optional configuration including metadata and custom keys * @returns A TransactionType object representing the insert operation(s) * @throws {SchemaValidationError} If the data fails schema validation * @example * // Insert a single item * insert({ text: "Buy groceries", completed: false }) * * // Insert multiple items * insert([ * { text: "Buy groceries", completed: false }, * { text: "Walk dog", completed: false } * ]) * * // Insert with custom key * insert({ text: "Buy groceries" }, { key: "grocery-task" }) */ insert = (data: T | Array<T>, config?: InsertConfig) => { const ambientTransaction = getActiveTransaction() // If no ambient transaction exists, check for an onInsert handler early if (!ambientTransaction && !this.config.onInsert) { throw new Error( `Collection.insert called directly (not within an explicit transaction) but no 'onInsert' handler is configured.` ) } const items = Array.isArray(data) ? data : [data] const mutations: Array<PendingMutation<T>> = [] // Create mutations for each item items.forEach((item) => { // Validate the data against the schema if one exists const validatedData = this.validateData(item, `insert`) // Check if an item with this ID already exists in the collection const key = this.getKeyFromItem(item) if (this.has(key)) { throw `Cannot insert document with ID "${key}" because it already exists in the collection` } const globalKey = this.generateGlobalKey(key, item) const mutation: PendingMutation<T> = { mutationId: crypto.randomUUID(), original: {}, modified: validatedData, changes: validatedData, globalKey, key, metadata: config?.metadata as unknown, syncMetadata: this.config.sync.getSyncMetadata?.() || {}, type: `insert`, createdAt: new Date(), updatedAt: new Date(), collection: this, } mutations.push(mutation) }) // If an ambient transaction exists, use it if (ambientTransaction) { ambientTransaction.applyMutations(mutations) this.transactions.set(ambientTransaction.id, ambientTransaction) this.recomputeOptimisticState() return ambientTransaction } else { // Create a new transaction with a mutation function that calls the onInsert handler const directOpTransaction = new Transaction<T>({ mutationFn: async (params) => { // Call the onInsert handler with the transaction return this.config.onInsert!(params) }, }) // Apply mutations to the new transaction directOpTransaction.applyMutations(mutations) directOpTransaction.commit() // Add the transaction to the collection's transactions store this.transactions.set(directOpTransaction.id, directOpTransaction) this.recomputeOptimisticState() return directOpTransaction } } /** * Updates one or more items in the collection using a callback function * @param items - Single item/key or array of items/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 a single item * update(todo, (draft) => { draft.completed = true }) * * // Update multiple items * update([todo1, todo2], (drafts) => { * drafts.forEach(draft => { draft.completed = true }) * }) * * // Update with metadata * update(todo, { metadata: { reason: "user update" } }, (draft) => { draft.text = "Updated text" }) */ /** * Updates one or more items in the collection using a callback function * @param ids - Single ID or array of IDs 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 a single item * update("todo-1", (draft) => { draft.completed = true }) * * // Update multiple items * update(["todo-1", "todo-2"], (drafts) => { * drafts.forEach(draft => { draft.completed = true }) * }) * * // Update with metadata * update("todo-1", { metadata: { reason: "user update" } }, (draft) => { draft.text = "Updated text" }) */ // Overload 1: Update multiple items with a callback update<TItem extends object = T>( key: Array<TKey | unknown>, callback: (drafts: Array<TItem>) => void ): TransactionType // Overload 2: Update multiple items with config and a callback update<TItem extends object = T>( keys: Array<TKey | unknown>, config: OperationConfig, callback: (drafts: Array<TItem>) => void ): TransactionType // Overload 3: Update a single item with a callback update<TItem extends object = T>( id: TKey | unknown, callback: (draft: TItem) => void ): TransactionType // Overload 4: Update a single item with config and a callback update<TItem extends object = T>( id: TKey | unknown, config: OperationConfig, callback: (draft: TItem) => void ): TransactionType update<TItem extends object = T>( keys: (TKey | unknown) | Array<TKey | unknown>, configOrCallback: ((draft: TItem | Array<TItem>) => void) | OperationConfig, maybeCallback?: (draft: TItem | Array<TItem>) => void ) { if (typeof keys === `undefined`) { throw new Error(`The first argument to update is missing`) } const ambientTransaction = getActiveTransaction() // If no ambient transaction exists, check for an onUpdate handler early if (!ambientTransaction && !this.config.onUpdate) { throw new Error( `Collection.update called directly (not within an explicit transaction) but no 'onUpdate' handler is configured.` ) } const isArray = Array.isArray(keys) const keysArray = isArray ? keys : [keys] if (isArray && keysArray.length === 0) { throw new Error(`No keys were passed to update`) } const callback = typeof configOrCallback === `function` ? configOrCallback : maybeCallback! const config = typeof configOrCallback === `function` ? {} : configOrCallback // Get the current objects or empty objects if they don't exist const currentObjects = keysArray.map((key) => { const item = this.get(key) if (!item) { throw new Error( `The key "${key}" was passed to update but an object for this key was not found in the collection` ) } return item }) as unknown as Array<TItem> let changesArray if (isArray) { // Use the proxy to track changes for all objects changesArray = withArrayChangeTracking( currentObjects, callback as (draft: Array<TItem>) => void ) } else { const result = withChangeTracking( currentObjects[0]!, callback as (draft: TItem) => void ) changesArray = [result] } // Create mutations for each object that has changes const mutations: Array<PendingMutation<T>> = keysArray .map((key, index) => { const itemChanges = changesArray[index] // User-provided changes for this specific item // Skip items with no changes if (!itemChanges || Object.keys(itemChanges).length === 0) { return null } const originalItem = currentObjects[index] as unknown as T // Validate the user-provided changes for this item const validatedUpdatePayload = this.validateData( itemChanges, `update`, key ) // Construct the full modified item by applying the validated update payload to the original item const modifiedItem = Object.assign( {}, originalItem, validatedUpdatePayload ) // Check if the ID of the item is being changed const originalItemId = this.getKeyFromItem(originalItem) const modifiedItemId = this.getKeyFromItem(modifiedItem) if (originalItemId !== modifiedItemId) { throw new Error( `Updating the key of an item is not allowed. Original key: "${originalItemId}", Attempted new key: "${modifiedItemId}". Please delete the old item and create a new one if a key change is necessary.` ) } const globalKey = this.generateGlobalKey(modifiedItemId, modifiedItem) return { mutationId: crypto.randomUUID(), original: originalItem as Record<string, unknown>, modified: modifiedItem as Record<string, unknown>, changes: validatedUpdatePayload as Record<string, unknown>, globalKey, key, metadata: config.metadata as unknown, syncMetadata: (this.syncedMetadata.get(key) || {}) as Record< string, unknown >, type: `update`, createdAt: new Date(), updatedAt: new Date(), collection: this, } }) .filter(Boolean) as Array<PendingMutation<T>> // If no changes were made, return an empty transaction early if (mutations.length === 0) { const emptyTransaction = new Transaction({ mutationFn: async () => {}, }) emptyTransaction.commit() return emptyTransaction } // If an ambient transaction exists, use it if (ambientTransaction) { ambientTransaction.applyMutations(mutations) this.transactions.set(ambientTransaction.id, ambientTransaction) this.recomputeOptimisticState() return ambientTransaction } // No need to check for onUpdate handler here as we've already checked at the beginning // Create a new transaction with a mutation function that calls the onUpdate handler const directOpTransaction = new Transaction<T>({ mutationFn: async (params) => { // Call the onUpdate handler with the transaction return this.config.onUpdate!(params) }, }) // Apply mutations to the new transaction directOpTransaction.applyMutations(mutations) directOpTransaction.commit() // Add the transaction to the collection's transactions store this.transactions.set(directOpTransaction.id, directOpTransaction) this.recomputeOptimisticState() return directOpTransaction } /** * Deletes one or more items from the collection * @param ids - Single ID or array of IDs to delete * @param config - Optional configuration including metadata * @returns A TransactionType object representing the delete operation(s) * @example * // Delete a single item * delete("todo-1") * * // Delete multiple items * delete(["todo-1", "todo-2"]) * * // Delete with metadata * delete("todo-1", { metadata: { reason: "completed" } }) */ delete = ( keys: Array<TKey> | TKey, config?: OperationConfig ): TransactionType<any> => { const ambientTransaction = getActiveTransaction() // If no ambient transaction exists, check for an onDelete handler early if (!ambientTransaction && !this.config.onDelete) { throw new Error( `Collection.delete called directly (not within an explicit transaction) but no 'onDelete' handler is configured.` ) } if (Array.isArray(keys) && keys.length === 0) { throw new Error(`No keys were passed to delete`) } const keysArray = Array.isArray(keys) ? keys : [keys] const mutations: Array<PendingMutation<T>> = [] for (const key of keysArray) { const globalKey = this.generateGlobalKey(key, this.get(key)!) const mutation: PendingMutation<T> = { mutationId: crypto.randomUUID(), original: this.get(key) || {}, modified: this.get(key)!, changes: this.get(key) || {}, globalKey, key, metadata: config?.metadata as unknown, syncMetadata: (this.syncedMetadata.get(key) || {}) as Record< string, unknown >, type: `delete`, createdAt: new Date(), updatedAt: new Date(), collection: this, } mutations.push(mutation) } // If an ambient transaction exists, use it if (ambientTransaction) { ambientTransaction.applyMutations(mutations) this.transactions.set(ambientTransaction.id, ambientTransaction) this.recomputeOptimisticState() return ambientTransaction } // Create a new transaction with a mutation function that calls the onDelete handler const directOpTransaction = new Transaction<T>({ autoCommit: true, mutationFn: async (params) => { // Call the onDelete handler with the transaction return this.config.onDelete!(params) }, }) // Apply mutations to the new transaction directOpTransaction.applyMutations(mutations) directOpTransaction.commit() this.transactions.set(directOpTransaction.id, directOpTransaction) this.recomputeOptimisticState() return directOpTransaction } /** * Gets the current state of the collection as a Map * * @returns A Map containing all items in the collection, with keys as identifiers */ get state() { const result = new Map<TKey, T>() 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, T>> { // If we already have data or there are no loading collections, resolve immediately if (this.size > 0 || this.hasReceivedFirstCommit === true) { return Promise.resolve(this.state) } // Otherwise, wait for the first commit return new Promise<Map<TKey, T>>((resolve) => { this.onFirstCommit(() => { resolve(this.state) }) }) } /** * Gets the current state of the collection as an Array * * @returns An Array containing all items in the collection */ get toArray() { const array = Array.from(this.values()) // Currently a query with an orderBy will add a _orderByIndex to the items // so for now we need to sort the array by _orderByIndex if it exists // TODO: in the future it would be much better is the keys are sorted - this // should be done by the query engine. if (array[0] && (array[0] as { _orderByIndex?: number })._orderByIndex) { return (array as Array<{ _orderByIndex: number }>).sort( (a, b) => a._orderByIndex - b._orderByIndex ) as Array<T> } return array } /** * 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>> { // If we already have data or there are no loading collections, resolve immediately if (this.size > 0 || this.hasReceivedFirstCommit === true) { return Promise.resolve(this.toArray) } // Otherwise, wait for the first commit return new Promise<Array<T>>((resolve) => { this.onFirstCommit(() => { resolve(this.toArray) }) }) } /** * Returns the current state of the collection as an array of changes * @returns An array of changes */ public currentStateAsChanges(): Array<ChangeMessage<T>> { return Array.from(this.entries()).map(([key, value]) => ({ type: `insert`, key, value, })) } /** * Subscribe to changes in the collection * @param callback - A function that will be called with the changes in the collection * @returns A function that can be called to unsubscribe from the changes */ public subscribeChanges( callback: (changes: Array<ChangeMessage<T>>) => void, { includeInitialState = false }: { includeInitialState?: boolean } = {} ): () => void { if (includeInitialState) { // First send the current state as changes callback(this.currentStateAsChanges()) } // Add to batched listeners this.changeListeners.add(callback) return () => { this.changeListeners.delete(callback) } } /** * Subscribe to changes for a specific key */ public subscribeChangesKey( key: TKey, listener: ChangeListener<T, TKey>, { includeInitialState = false }: { includeInitialState?: boolean } = {} ): () => void { if (!this.changeKeyListeners.has(key)) { this.changeKeyListeners.set(key, new Set()) } if (includeInitialState) { // First send the current state as changes listener([ { type: `insert`, key, value: this.get(key)!, }, ]) } this.changeKeyListeners.get(key)!.add(listener) return () => { const listeners = this.changeKeyListeners.get(key) if (listeners) { listeners.delete(listener) if (listeners.size === 0) { this.changeKeyListeners.delete(key) } } } } /** * Trigger a recomputation when transactions change * This method should be called by the Transaction class when state changes */ public onTransactionStateChange(): void { this.recomputeOptimisticState() } private _storeMap: Store<Map<TKey, T>> | undefined /** * Returns a Tanstack Store Map that is updated when the collection changes * This is a temporary solution to enable the existing framework hooks to work * with the new internals of Collection until they are rewritten. * TODO: Remove this once the framework hooks are rewritten. */ public asStoreMap(): Store<Map<TKey, T>> { if (!this._storeMap) { this._storeMap = new Store(new Map(this.entries())) this.subscribeChanges(() => { this._storeMap!.setState(() => new Map(this.entries())) }) } return this._storeMap } private _storeArray: Store<Array<T>> | undefined /** * Returns a Tanstack Store Array that is updated when the collection changes * This is a temporary solution to enable the existing framework hooks to work * with the new internals of Collection until they are rewritten. * TODO: Remove this once the framework hooks are rewritten. */ public asStoreArray(): Store<Array<T>> { if (!this._storeArray) { this._storeArray = new Store(this.toArray) this.subscribeChanges(() => { this._storeArray!.setState(() => this.toArray) }) } return this._storeArray } }