UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

547 lines (471 loc) 18.4 kB
import { withArrayChangeTracking, withChangeTracking } from '../proxy' import { createTransaction, getActiveTransaction } from '../transactions' import { DeleteKeyNotFoundError, DuplicateKeyError, InvalidKeyError, InvalidSchemaError, KeyUpdateNotAllowedError, MissingDeleteHandlerError, MissingInsertHandlerError, MissingUpdateArgumentError, MissingUpdateHandlerError, NoKeysPassedToDeleteError, NoKeysPassedToUpdateError, SchemaMustBeSynchronousError, SchemaValidationError, UndefinedKeyError, UpdateKeyNotFoundError, } from '../errors' import type { Collection, CollectionImpl } from './index.js' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { CollectionConfig, InsertConfig, OperationConfig, PendingMutation, StandardSchema, Transaction as TransactionType, TransactionWithMutations, UtilsRecord, WritableDeep, } from '../types' import type { CollectionLifecycleManager } from './lifecycle' import type { CollectionStateManager } from './state' export class CollectionMutationsManager< TOutput extends object = Record<string, unknown>, TKey extends string | number = string | number, TUtils extends UtilsRecord = {}, TSchema extends StandardSchemaV1 = StandardSchemaV1, TInput extends object = TOutput, > { private lifecycle!: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput> private state!: CollectionStateManager<TOutput, TKey, TSchema, TInput> private collection!: CollectionImpl<TOutput, TKey, TUtils, TSchema, TInput> private config!: CollectionConfig<TOutput, TKey, TSchema> private id: string constructor(config: CollectionConfig<TOutput, TKey, TSchema>, id: string) { this.id = id this.config = config } setDeps(deps: { lifecycle: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput> state: CollectionStateManager<TOutput, TKey, TSchema, TInput> collection: CollectionImpl<TOutput, TKey, TUtils, TSchema, TInput> }) { this.lifecycle = deps.lifecycle this.state = deps.state this.collection = deps.collection } private ensureStandardSchema(schema: unknown): StandardSchema<TOutput> { // If the schema already implements the standard-schema interface, return it if (schema && `~standard` in (schema as {})) { return schema as StandardSchema<TOutput> } throw new InvalidSchemaError() } public validateData( data: unknown, type: `insert` | `update`, key?: TKey, ): TOutput | never { if (!this.config.schema) return data as TOutput 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.state.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 SchemaMustBeSynchronousError() } // 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) } // Extract only the modified keys from the validated result const validatedMergedData = result.value as TOutput const modifiedKeys = Object.keys(data) const extractedChanges = Object.fromEntries( modifiedKeys.map((k) => [k, validatedMergedData[k as keyof TOutput]]), ) as TOutput return extractedChanges } } // 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 SchemaMustBeSynchronousError() } // 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 TOutput } public generateGlobalKey(key: any, item: any): string { if (typeof key !== `string` && typeof key !== `number`) { // Preserve specific error for undefined keys if (typeof key === `undefined`) { throw new UndefinedKeyError(item) } throw new InvalidKeyError(key, item) } return `KEY::${this.id}/${key}` } /** * Inserts one or more items into the collection */ insert = (data: TInput | Array<TInput>, config?: InsertConfig) => { this.lifecycle.validateCollectionUsable(`insert`) const state = this.state const ambientTransaction = getActiveTransaction() // If no ambient transaction exists, check for an onInsert handler early if (!ambientTransaction && !this.config.onInsert) { throw new MissingInsertHandlerError() } const items = Array.isArray(data) ? data : [data] const mutations: Array<PendingMutation<TOutput>> = [] const keysInCurrentBatch = new Set<TKey>() // 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 or in the current batch const key = this.config.getKey(validatedData) if (this.state.has(key) || keysInCurrentBatch.has(key)) { throw new DuplicateKeyError(key) } keysInCurrentBatch.add(key) const globalKey = this.generateGlobalKey(key, item) const mutation: PendingMutation<TOutput, `insert`> = { mutationId: crypto.randomUUID(), original: {}, modified: validatedData, // Pick the values from validatedData based on what's passed in - this is for cases // where a schema has default values. The validated data has the extra default // values but for changes, we just want to show the data that was actually passed in. changes: Object.fromEntries( Object.keys(item).map((k) => [ k, validatedData[k as keyof typeof validatedData], ]), ) as TInput, globalKey, key, metadata: config?.metadata as unknown, syncMetadata: this.config.sync.getSyncMetadata?.() || {}, optimistic: config?.optimistic ?? true, type: `insert`, createdAt: new Date(), updatedAt: new Date(), collection: this.collection, } mutations.push(mutation) }) // If an ambient transaction exists, use it if (ambientTransaction) { ambientTransaction.applyMutations(mutations) state.transactions.set(ambientTransaction.id, ambientTransaction) state.scheduleTransactionCleanup(ambientTransaction) state.recomputeOptimisticState(true) return ambientTransaction } else { // Create a new transaction with a mutation function that calls the onInsert handler const directOpTransaction = createTransaction<TOutput>({ mutationFn: async (params) => { // Call the onInsert handler with the transaction and collection return await this.config.onInsert!({ transaction: params.transaction as unknown as TransactionWithMutations< TOutput, `insert` >, collection: this.collection as unknown as Collection<TOutput, TKey>, }) }, }) // Apply mutations to the new transaction directOpTransaction.applyMutations(mutations) // Errors still reject tx.isPersisted.promise; this catch only prevents global unhandled rejections directOpTransaction.commit().catch(() => undefined) // Add the transaction to the collection's transactions store state.transactions.set(directOpTransaction.id, directOpTransaction) state.scheduleTransactionCleanup(directOpTransaction) state.recomputeOptimisticState(true) return directOpTransaction } } /** * Updates one or more items in the collection using a callback function */ 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), ) { if (typeof keys === `undefined`) { throw new MissingUpdateArgumentError() } const state = this.state this.lifecycle.validateCollectionUsable(`update`) const ambientTransaction = getActiveTransaction() // If no ambient transaction exists, check for an onUpdate handler early if (!ambientTransaction && !this.config.onUpdate) { throw new MissingUpdateHandlerError() } const isArray = Array.isArray(keys) const keysArray = isArray ? keys : [keys] if (isArray && keysArray.length === 0) { throw new NoKeysPassedToUpdateError() } 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.state.get(key) if (!item) { throw new UpdateKeyNotFoundError(key) } return item }) as unknown as Array<TInput> let changesArray if (isArray) { // Use the proxy to track changes for all objects changesArray = withArrayChangeTracking( currentObjects, callback as (draft: Array<TInput>) => void, ) } else { const result = withChangeTracking( currentObjects[0]!, callback as (draft: TInput) => void, ) changesArray = [result] } // Create mutations for each object that has changes const mutations: Array< PendingMutation< TOutput, `update`, CollectionImpl<TOutput, TKey, TUtils, TSchema, TInput> > > = 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 TOutput // 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.config.getKey(originalItem) const modifiedItemId = this.config.getKey(modifiedItem) if (originalItemId !== modifiedItemId) { throw new KeyUpdateNotAllowedError(originalItemId, modifiedItemId) } const globalKey = this.generateGlobalKey(modifiedItemId, modifiedItem) return { mutationId: crypto.randomUUID(), original: originalItem, modified: modifiedItem, // Pick the values from modifiedItem based on what's passed in - this is for cases // where a schema has default values or transforms. The modified data has the extra // default or transformed values but for changes, we just want to show the data that // was actually passed in. changes: Object.fromEntries( Object.keys(itemChanges).map((k) => [ k, modifiedItem[k as keyof typeof modifiedItem], ]), ) as TInput, globalKey, key, metadata: config.metadata as unknown, syncMetadata: (state.syncedMetadata.get(key) || {}) as Record< string, unknown >, optimistic: config.optimistic ?? true, type: `update`, createdAt: new Date(), updatedAt: new Date(), collection: this.collection, } }) .filter(Boolean) as Array< PendingMutation< TOutput, `update`, CollectionImpl<TOutput, TKey, TUtils, TSchema, TInput> > > // If no changes were made, return an empty transaction early if (mutations.length === 0) { const emptyTransaction = createTransaction({ mutationFn: async () => {}, }) // Errors still propagate through tx.isPersisted.promise; suppress the background commit from warning emptyTransaction.commit().catch(() => undefined) // Schedule cleanup for empty transaction state.scheduleTransactionCleanup(emptyTransaction) return emptyTransaction } // If an ambient transaction exists, use it if (ambientTransaction) { ambientTransaction.applyMutations(mutations) state.transactions.set(ambientTransaction.id, ambientTransaction) state.scheduleTransactionCleanup(ambientTransaction) state.recomputeOptimisticState(true) 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 = createTransaction<TOutput>({ mutationFn: async (params) => { // Call the onUpdate handler with the transaction and collection return this.config.onUpdate!({ transaction: params.transaction as unknown as TransactionWithMutations< TOutput, `update` >, collection: this.collection as unknown as Collection<TOutput, TKey>, }) }, }) // Apply mutations to the new transaction directOpTransaction.applyMutations(mutations) // Errors still hit tx.isPersisted.promise; avoid leaking an unhandled rejection from the fire-and-forget commit directOpTransaction.commit().catch(() => undefined) // Add the transaction to the collection's transactions store state.transactions.set(directOpTransaction.id, directOpTransaction) state.scheduleTransactionCleanup(directOpTransaction) state.recomputeOptimisticState(true) return directOpTransaction } /** * Deletes one or more items from the collection */ delete = ( keys: Array<TKey> | TKey, config?: OperationConfig, ): TransactionType<any> => { const state = this.state this.lifecycle.validateCollectionUsable(`delete`) const ambientTransaction = getActiveTransaction() // If no ambient transaction exists, check for an onDelete handler early if (!ambientTransaction && !this.config.onDelete) { throw new MissingDeleteHandlerError() } if (Array.isArray(keys) && keys.length === 0) { throw new NoKeysPassedToDeleteError() } const keysArray = Array.isArray(keys) ? keys : [keys] const mutations: Array< PendingMutation< TOutput, `delete`, CollectionImpl<TOutput, TKey, TUtils, TSchema, TInput> > > = [] for (const key of keysArray) { if (!this.state.has(key)) { throw new DeleteKeyNotFoundError(key) } const globalKey = this.generateGlobalKey(key, this.state.get(key)!) const mutation: PendingMutation< TOutput, `delete`, CollectionImpl<TOutput, TKey, TUtils, TSchema, TInput> > = { mutationId: crypto.randomUUID(), original: this.state.get(key)!, modified: this.state.get(key)!, changes: this.state.get(key)!, globalKey, key, metadata: config?.metadata as unknown, syncMetadata: (state.syncedMetadata.get(key) || {}) as Record< string, unknown >, optimistic: config?.optimistic ?? true, type: `delete`, createdAt: new Date(), updatedAt: new Date(), collection: this.collection, } mutations.push(mutation) } // If an ambient transaction exists, use it if (ambientTransaction) { ambientTransaction.applyMutations(mutations) state.transactions.set(ambientTransaction.id, ambientTransaction) state.scheduleTransactionCleanup(ambientTransaction) state.recomputeOptimisticState(true) return ambientTransaction } // Create a new transaction with a mutation function that calls the onDelete handler const directOpTransaction = createTransaction<TOutput>({ autoCommit: true, mutationFn: async (params) => { // Call the onDelete handler with the transaction and collection return this.config.onDelete!({ transaction: params.transaction as unknown as TransactionWithMutations< TOutput, `delete` >, collection: this.collection as unknown as Collection<TOutput, TKey>, }) }, }) // Apply mutations to the new transaction directOpTransaction.applyMutations(mutations) // Errors still reject tx.isPersisted.promise; silence the internal commit promise to prevent test noise directOpTransaction.commit().catch(() => undefined) state.transactions.set(directOpTransaction.id, directOpTransaction) state.scheduleTransactionCleanup(directOpTransaction) state.recomputeOptimisticState(true) return directOpTransaction } }