UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

373 lines (342 loc) 11.7 kB
import type { BaseCollectionConfig, CollectionConfig, DeleteMutationFnParams, InferSchemaOutput, InsertMutationFnParams, OperationType, PendingMutation, SyncConfig, UpdateMutationFnParams, UtilsRecord, } from './types' import type { Collection } from './collection/index' import type { StandardSchemaV1 } from '@standard-schema/spec' /** * Configuration interface for Local-only collection options * @template T - The type of items in the collection * @template TSchema - The schema type for validation * @template TKey - The type of the key returned by `getKey` */ export interface LocalOnlyCollectionConfig< T extends object = object, TSchema extends StandardSchemaV1 = never, TKey extends string | number = string | number, > extends Omit< BaseCollectionConfig<T, TKey, TSchema, LocalOnlyCollectionUtils>, `gcTime` | `startSync` > { /** * Optional initial data to populate the collection with on creation * This data will be applied during the initial sync process */ initialData?: Array<T> } /** * Local-only collection utilities type */ export interface LocalOnlyCollectionUtils extends UtilsRecord { /** * Accepts mutations from a transaction that belong to this collection and persists them. * This should be called in your transaction's mutationFn to persist local-only data. * * @param transaction - The transaction containing mutations to accept * @example * const localData = createCollection(localOnlyCollectionOptions({...})) * * const tx = createTransaction({ * mutationFn: async ({ transaction }) => { * // Make API call first * await api.save(...) * // Then persist local-only mutations after success * localData.utils.acceptMutations(transaction) * } * }) */ acceptMutations: (transaction: { mutations: Array<PendingMutation<Record<string, unknown>>> }) => void } type LocalOnlyCollectionOptionsResult< T extends object, TKey extends string | number, TSchema extends StandardSchemaV1 | never = never, > = CollectionConfig<T, TKey, TSchema> & { utils: LocalOnlyCollectionUtils } /** * Creates Local-only collection options for use with a standard Collection * * This is an in-memory collection that doesn't sync with external sources but uses a loopback sync config * that immediately "syncs" all optimistic changes to the collection, making them permanent. * Perfect for local-only data that doesn't need persistence or external synchronization. * * **Using with Manual Transactions:** * * For manual transactions, you must call `utils.acceptMutations()` in your transaction's `mutationFn` * to persist changes made during `tx.mutate()`. This is necessary because local-only collections * don't participate in the standard mutation handler flow for manual transactions. * * @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 returned by getKey * @param config - Configuration options for the Local-only collection * @returns Collection options with utilities including acceptMutations * * @example * // Basic local-only collection * const collection = createCollection( * localOnlyCollectionOptions({ * getKey: (item) => item.id, * }) * ) * * @example * // Local-only collection with initial data * const collection = createCollection( * localOnlyCollectionOptions({ * getKey: (item) => item.id, * initialData: [ * { id: 1, name: 'Item 1' }, * { id: 2, name: 'Item 2' }, * ], * }) * ) * * @example * // Local-only collection with mutation handlers * const collection = createCollection( * localOnlyCollectionOptions({ * getKey: (item) => item.id, * onInsert: async ({ transaction }) => { * console.log('Item inserted:', transaction.mutations[0].modified) * // Custom logic after insert * }, * }) * ) * * @example * // Using with manual transactions * const localData = createCollection( * localOnlyCollectionOptions({ * getKey: (item) => item.id, * }) * ) * * const tx = createTransaction({ * mutationFn: async ({ transaction }) => { * // Use local data in API call * const localMutations = transaction.mutations.filter(m => m.collection === localData) * await api.save({ metadata: localMutations[0]?.modified }) * * // Persist local-only mutations after API success * localData.utils.acceptMutations(transaction) * } * }) * * tx.mutate(() => { * localData.insert({ id: 1, data: 'metadata' }) * apiCollection.insert({ id: 2, data: 'main data' }) * }) * * await tx.commit() */ // Overload for when schema is provided export function localOnlyCollectionOptions< T extends StandardSchemaV1, TKey extends string | number = string | number, >( config: LocalOnlyCollectionConfig<InferSchemaOutput<T>, T, TKey> & { schema: T }, ): LocalOnlyCollectionOptionsResult<InferSchemaOutput<T>, TKey, T> & { schema: T } // 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 localOnlyCollectionOptions< T extends object, TKey extends string | number = string | number, >( config: LocalOnlyCollectionConfig<T, never, TKey> & { schema?: never // prohibit schema }, ): LocalOnlyCollectionOptionsResult<T, TKey> & { schema?: never // no schema in the result } export function localOnlyCollectionOptions< T extends object = object, TSchema extends StandardSchemaV1 = never, TKey extends string | number = string | number, >( config: LocalOnlyCollectionConfig<T, TSchema, TKey>, ): LocalOnlyCollectionOptionsResult<T, TKey, TSchema> & { schema?: StandardSchemaV1 } { const { initialData, onInsert, onUpdate, onDelete, ...restConfig } = config // Create the sync configuration with transaction confirmation capability const syncResult = createLocalOnlySync<T, TKey>(initialData) /** * Create wrapper handlers that call user handlers first, then confirm transactions * Wraps the user's onInsert handler to also confirm the transaction immediately */ const wrappedOnInsert = async ( params: InsertMutationFnParams<T, TKey, LocalOnlyCollectionUtils>, ) => { // Call user handler first if provided let handlerResult if (onInsert) { handlerResult = (await onInsert(params)) ?? {} } // Then synchronously confirm the transaction by looping through mutations syncResult.confirmOperationsSync(params.transaction.mutations) return handlerResult } /** * Wrapper for onUpdate handler that also confirms the transaction immediately */ const wrappedOnUpdate = async ( params: UpdateMutationFnParams<T, TKey, LocalOnlyCollectionUtils>, ) => { // Call user handler first if provided let handlerResult if (onUpdate) { handlerResult = (await onUpdate(params)) ?? {} } // Then synchronously confirm the transaction by looping through mutations syncResult.confirmOperationsSync(params.transaction.mutations) return handlerResult } /** * Wrapper for onDelete handler that also confirms the transaction immediately */ const wrappedOnDelete = async ( params: DeleteMutationFnParams<T, TKey, LocalOnlyCollectionUtils>, ) => { // Call user handler first if provided let handlerResult if (onDelete) { handlerResult = (await onDelete(params)) ?? {} } // Then synchronously confirm the transaction by looping through mutations syncResult.confirmOperationsSync(params.transaction.mutations) return handlerResult } /** * Accepts mutations from a transaction that belong to this collection and persists them */ const acceptMutations = (transaction: { mutations: Array<PendingMutation<Record<string, unknown>>> }) => { // Filter mutations that belong to this collection const collectionMutations = transaction.mutations.filter( (m) => // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition m.collection === syncResult.collection, ) if (collectionMutations.length === 0) { return } // Persist the mutations through sync syncResult.confirmOperationsSync( collectionMutations as Array<PendingMutation<T>>, ) } return { ...restConfig, sync: syncResult.sync, onInsert: wrappedOnInsert, onUpdate: wrappedOnUpdate, onDelete: wrappedOnDelete, utils: { acceptMutations, }, startSync: true, gcTime: 0, } as LocalOnlyCollectionOptionsResult<T, TKey, TSchema> & { schema?: StandardSchemaV1 } } /** * Internal function to create Local-only sync configuration with transaction confirmation * * This captures the sync functions and provides synchronous confirmation of operations. * It creates a loopback sync that immediately confirms all optimistic operations, * making them permanent in the collection. * * @param initialData - Optional array of initial items to populate the collection * @returns Object with sync configuration and confirmOperationsSync function */ function createLocalOnlySync<T extends object, TKey extends string | number>( initialData?: Array<T>, ) { // Capture sync functions and collection for transaction confirmation let syncBegin: (() => void) | null = null let syncWrite: ((message: { type: OperationType; value: T }) => void) | null = null let syncCommit: (() => void) | null = null let collection: Collection<T, TKey, LocalOnlyCollectionUtils> | null = null const sync: SyncConfig<T, TKey> = { /** * Sync function that captures sync parameters and applies initial data * @param params - Sync parameters containing begin, write, and commit functions * @returns Unsubscribe function (empty since no ongoing sync is needed) */ sync: (params) => { const { begin, write, commit, markReady } = params // Capture sync functions and collection for later use syncBegin = begin syncWrite = write syncCommit = commit collection = params.collection // Apply initial data if provided if (initialData && initialData.length > 0) { begin() initialData.forEach((item) => { write({ type: `insert`, value: item, }) }) commit() } // Mark collection as ready since local-only collections are immediately ready markReady() // Return empty unsubscribe function - no ongoing sync needed return () => {} }, /** * Get sync metadata - returns empty object for local-only collections * @returns Empty metadata object */ getSyncMetadata: () => ({}), } /** * Synchronously confirms optimistic operations by immediately writing through sync * * This loops through transaction mutations and applies them to move from optimistic to synced state. * It's called after user handlers to make optimistic changes permanent. * * @param mutations - Array of mutation objects from the transaction */ const confirmOperationsSync = (mutations: Array<PendingMutation<T>>) => { if (!syncBegin || !syncWrite || !syncCommit) { return // Sync not initialized yet, which is fine } // Immediately write back through sync interface syncBegin() mutations.forEach((mutation) => { if (syncWrite) { syncWrite({ type: mutation.type, value: mutation.modified, }) } }) syncCommit() } return { sync, confirmOperationsSync, collection, } }