UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

874 lines (786 loc) 26.9 kB
import { InvalidStorageDataFormatError, InvalidStorageObjectFormatError, SerializationError, StorageKeyRequiredError, } from './errors' import type { BaseCollectionConfig, CollectionConfig, DeleteMutationFnParams, InferSchemaOutput, InsertMutationFnParams, PendingMutation, SyncConfig, UpdateMutationFnParams, UtilsRecord, } from './types' import type { StandardSchemaV1 } from '@standard-schema/spec' /** * Storage API interface - subset of DOM Storage that we need */ export type StorageApi = Pick<Storage, `getItem` | `setItem` | `removeItem`> /** * Storage event API - subset of Window for 'storage' events only */ export type StorageEventApi = { addEventListener: ( type: `storage`, listener: (event: StorageEvent) => void, ) => void removeEventListener: ( type: `storage`, listener: (event: StorageEvent) => void, ) => void } /** * Internal storage format that includes version tracking */ interface StoredItem<T> { versionKey: string data: T } export interface Parser { parse: (data: string) => unknown stringify: (data: unknown) => string } /** * Configuration interface for localStorage 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 LocalStorageCollectionConfig< T extends object = object, TSchema extends StandardSchemaV1 = never, TKey extends string | number = string | number, > extends BaseCollectionConfig<T, TKey, TSchema> { /** * The key to use for storing the collection data in localStorage/sessionStorage */ storageKey: string /** * Storage API to use (defaults to window.localStorage) * Can be any object that implements the Storage interface (e.g., sessionStorage) */ storage?: StorageApi /** * Storage event API to use for cross-tab synchronization (defaults to window) * Can be any object that implements addEventListener/removeEventListener for storage events */ storageEventApi?: StorageEventApi /** * Parser to use for serializing and deserializing data to and from storage * Defaults to JSON */ parser?: Parser } /** * Type for the clear utility function */ export type ClearStorageFn = () => void /** * Type for the getStorageSize utility function */ export type GetStorageSizeFn = () => number /** * LocalStorage collection utilities type */ export interface LocalStorageCollectionUtils extends UtilsRecord { clearStorage: ClearStorageFn getStorageSize: GetStorageSizeFn /** * Accepts mutations from a transaction that belong to this collection and persists them to localStorage. * This should be called in your transaction's mutationFn to persist local-storage data. * * @param transaction - The transaction containing mutations to accept * @example * const localSettings = createCollection(localStorageCollectionOptions({...})) * * const tx = createTransaction({ * mutationFn: async ({ transaction }) => { * // Make API call first * await api.save(...) * // Then persist local-storage mutations after success * localSettings.utils.acceptMutations(transaction) * } * }) */ acceptMutations: (transaction: { mutations: Array<PendingMutation<Record<string, unknown>>> }) => void } /** * Validates that a value can be JSON serialized * @param parser - The parser to use for serialization * @param value - The value to validate for JSON serialization * @param operation - The operation type being performed (for error messages) * @throws Error if the value cannot be JSON serialized */ function validateJsonSerializable( parser: Parser, value: any, operation: string, ): void { try { parser.stringify(value) } catch (error) { throw new SerializationError( operation, error instanceof Error ? error.message : String(error), ) } } /** * Generate a UUID for version tracking * @returns A unique identifier string for tracking data versions */ function generateUuid(): string { return crypto.randomUUID() } /** * Encodes a key (string or number) into a storage-safe string format. * This prevents collisions between numeric and string keys by prefixing with type information. * * Examples: * - number 1 → "n:1" * - string "1" → "s:1" * - string "n:1" → "s:n:1" * * @param key - The key to encode (string or number) * @returns Type-prefixed string that is safe for storage */ function encodeStorageKey(key: string | number): string { if (typeof key === `number`) { return `n:${key}` } return `s:${key}` } /** * Decodes a storage key back to its original form. * This is the inverse of encodeStorageKey. * * @param encodedKey - The encoded key from storage * @returns The original key (string or number) */ function decodeStorageKey(encodedKey: string): string | number { if (encodedKey.startsWith(`n:`)) { return Number(encodedKey.slice(2)) } if (encodedKey.startsWith(`s:`)) { return encodedKey.slice(2) } // Fallback for legacy data without encoding return encodedKey } /** * Creates an in-memory storage implementation that mimics the StorageApi interface * Used as a fallback when localStorage is not available (e.g., server-side rendering) * @returns An object implementing the StorageApi interface using an in-memory Map */ function createInMemoryStorage(): StorageApi { const storage = new Map<string, string>() return { getItem(key: string): string | null { return storage.get(key) ?? null }, setItem(key: string, value: string): void { storage.set(key, value) }, removeItem(key: string): void { storage.delete(key) }, } } /** * Creates a no-op storage event API for environments without window (e.g., server-side) * This provides the required interface but doesn't actually listen to any events * since cross-tab synchronization is not possible in server environments * @returns An object implementing the StorageEventApi interface with no-op methods */ function createNoOpStorageEventApi(): StorageEventApi { return { addEventListener: () => { // No-op: cannot listen to storage events without window }, removeEventListener: () => { // No-op: cannot remove listeners without window }, } } /** * Creates localStorage collection options for use with a standard Collection * * This function creates a collection that persists data to localStorage/sessionStorage * and synchronizes changes across browser tabs using storage events. * * **Fallback Behavior:** * * When localStorage is not available (e.g., in server-side rendering environments), * this function automatically falls back to an in-memory storage implementation. * This prevents errors during module initialization and allows the collection to * work in any environment, though data will not persist across page reloads or * be shared across tabs when using the in-memory fallback. * * **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-storage collections * don't participate in the standard mutation handler flow for manual transactions. * * @template TExplicit - The explicit type of items in the collection (highest priority) * @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 config - Configuration options for the localStorage collection * @returns Collection options with utilities including clearStorage, getStorageSize, and acceptMutations * * @example * // Basic localStorage collection * const collection = createCollection( * localStorageCollectionOptions({ * storageKey: 'todos', * getKey: (item) => item.id, * }) * ) * * @example * // localStorage collection with custom storage * const collection = createCollection( * localStorageCollectionOptions({ * storageKey: 'todos', * storage: window.sessionStorage, // Use sessionStorage instead * getKey: (item) => item.id, * }) * ) * * @example * // localStorage collection with mutation handlers * const collection = createCollection( * localStorageCollectionOptions({ * storageKey: 'todos', * getKey: (item) => item.id, * onInsert: async ({ transaction }) => { * console.log('Item inserted:', transaction.mutations[0].modified) * }, * }) * ) * * @example * // Using with manual transactions * const localSettings = createCollection( * localStorageCollectionOptions({ * storageKey: 'user-settings', * getKey: (item) => item.id, * }) * ) * * const tx = createTransaction({ * mutationFn: async ({ transaction }) => { * // Use settings data in API call * const settingsMutations = transaction.mutations.filter(m => m.collection === localSettings) * await api.updateUserProfile({ settings: settingsMutations[0]?.modified }) * * // Persist local-storage mutations after API success * localSettings.utils.acceptMutations(transaction) * } * }) * * tx.mutate(() => { * localSettings.insert({ id: 'theme', value: 'dark' }) * apiCollection.insert({ id: 2, data: 'profile data' }) * }) * * await tx.commit() */ // Overload for when schema is provided export function localStorageCollectionOptions< T extends StandardSchemaV1, TKey extends string | number = string | number, >( config: LocalStorageCollectionConfig<InferSchemaOutput<T>, T, TKey> & { schema: T }, ): CollectionConfig< InferSchemaOutput<T>, TKey, T, LocalStorageCollectionUtils > & { id: string utils: LocalStorageCollectionUtils 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 localStorageCollectionOptions< T extends object, TKey extends string | number = string | number, >( config: LocalStorageCollectionConfig<T, never, TKey> & { schema?: never // prohibit schema }, ): CollectionConfig<T, TKey, never, LocalStorageCollectionUtils> & { id: string utils: LocalStorageCollectionUtils schema?: never // no schema in the result } export function localStorageCollectionOptions( config: LocalStorageCollectionConfig<any, any, string | number>, ): Omit< CollectionConfig<any, string | number, any, LocalStorageCollectionUtils>, `id` > & { id: string utils: LocalStorageCollectionUtils schema?: StandardSchemaV1 } { // Validate required parameters if (!config.storageKey) { throw new StorageKeyRequiredError() } // Default to window.localStorage if no storage is provided // Fall back to in-memory storage if localStorage is not available (e.g., server-side rendering) const storage = config.storage || (typeof window !== `undefined` ? window.localStorage : null) || createInMemoryStorage() // Default to window for storage events if not provided // Fall back to no-op storage event API if window is not available (e.g., server-side rendering) const storageEventApi = config.storageEventApi || (typeof window !== `undefined` ? window : null) || createNoOpStorageEventApi() // Default to JSON parser if no parser is provided const parser = config.parser || JSON // Track the last known state to detect changes const lastKnownData = new Map<string | number, StoredItem<any>>() // Create the sync configuration const sync = createLocalStorageSync<any>( config.storageKey, storage, storageEventApi, parser, config.getKey, lastKnownData, ) /** * Save data to storage * @param dataMap - Map of items with version tracking to save to storage */ const saveToStorage = ( dataMap: Map<string | number, StoredItem<any>>, ): void => { try { // Convert Map to object format for storage const objectData: Record<string, StoredItem<any>> = {} dataMap.forEach((storedItem, key) => { objectData[encodeStorageKey(key)] = storedItem }) const serialized = parser.stringify(objectData) storage.setItem(config.storageKey, serialized) } catch (error) { console.error( `[LocalStorageCollection] Error saving data to storage key "${config.storageKey}":`, error, ) throw error } } /** * Removes all collection data from the configured storage */ const clearStorage: ClearStorageFn = (): void => { storage.removeItem(config.storageKey) } /** * Get the size of the stored data in bytes (approximate) * @returns The approximate size in bytes of the stored collection data */ const getStorageSize: GetStorageSizeFn = (): number => { const data = storage.getItem(config.storageKey) return data ? new Blob([data]).size : 0 } /* * Create wrapper handlers for direct persistence operations that perform actual storage operations * Wraps the user's onInsert handler to also save changes to localStorage */ const wrappedOnInsert = async (params: InsertMutationFnParams<any>) => { // Validate that all values in the transaction can be JSON serialized params.transaction.mutations.forEach((mutation) => { validateJsonSerializable(parser, mutation.modified, `insert`) }) // Call the user handler BEFORE persisting changes (if provided) let handlerResult: any = {} if (config.onInsert) { handlerResult = (await config.onInsert(params)) ?? {} } // Always persist to storage // Use lastKnownData (in-memory cache) instead of reading from storage // Add new items with version keys params.transaction.mutations.forEach((mutation) => { // Use the engine's pre-computed key for consistency const storedItem: StoredItem<any> = { versionKey: generateUuid(), data: mutation.modified, } lastKnownData.set(mutation.key, storedItem) }) // Save to storage saveToStorage(lastKnownData) // Confirm mutations through sync interface (moves from optimistic to synced state) // without reloading from storage sync.confirmOperationsSync(params.transaction.mutations) return handlerResult } const wrappedOnUpdate = async (params: UpdateMutationFnParams<any>) => { // Validate that all values in the transaction can be JSON serialized params.transaction.mutations.forEach((mutation) => { validateJsonSerializable(parser, mutation.modified, `update`) }) // Call the user handler BEFORE persisting changes (if provided) let handlerResult: any = {} if (config.onUpdate) { handlerResult = (await config.onUpdate(params)) ?? {} } // Always persist to storage // Use lastKnownData (in-memory cache) instead of reading from storage // Update items with new version keys params.transaction.mutations.forEach((mutation) => { // Use the engine's pre-computed key for consistency const storedItem: StoredItem<any> = { versionKey: generateUuid(), data: mutation.modified, } lastKnownData.set(mutation.key, storedItem) }) // Save to storage saveToStorage(lastKnownData) // Confirm mutations through sync interface (moves from optimistic to synced state) // without reloading from storage sync.confirmOperationsSync(params.transaction.mutations) return handlerResult } const wrappedOnDelete = async (params: DeleteMutationFnParams<any>) => { // Call the user handler BEFORE persisting changes (if provided) let handlerResult: any = {} if (config.onDelete) { handlerResult = (await config.onDelete(params)) ?? {} } // Always persist to storage // Use lastKnownData (in-memory cache) instead of reading from storage // Remove items params.transaction.mutations.forEach((mutation) => { // Use the engine's pre-computed key for consistency lastKnownData.delete(mutation.key) }) // Save to storage saveToStorage(lastKnownData) // Confirm mutations through sync interface (moves from optimistic to synced state) // without reloading from storage sync.confirmOperationsSync(params.transaction.mutations) return handlerResult } // Extract standard Collection config properties const { storageKey: _storageKey, storage: _storage, storageEventApi: _storageEventApi, onInsert: _onInsert, onUpdate: _onUpdate, onDelete: _onDelete, id, ...restConfig } = config // Default id to a pattern based on storage key if not provided const collectionId = id ?? `local-collection:${config.storageKey}` /** * Accepts mutations from a transaction that belong to this collection and persists them to storage */ const acceptMutations = (transaction: { mutations: Array<PendingMutation<Record<string, unknown>>> }) => { // Filter mutations that belong to this collection // Use collection ID for filtering if collection reference isn't available yet const collectionMutations = transaction.mutations.filter((m) => { // Try to match by collection reference first if (sync.collection && m.collection === sync.collection) { return true } // Fall back to matching by collection ID return m.collection.id === collectionId }) if (collectionMutations.length === 0) { return } // Validate all mutations can be serialized before modifying storage for (const mutation of collectionMutations) { switch (mutation.type) { case `insert`: case `update`: validateJsonSerializable(parser, mutation.modified, mutation.type) break case `delete`: validateJsonSerializable(parser, mutation.original, mutation.type) break } } // Use lastKnownData (in-memory cache) instead of reading from storage // Apply each mutation for (const mutation of collectionMutations) { // Use the engine's pre-computed key to avoid key derivation issues switch (mutation.type) { case `insert`: case `update`: { const storedItem: StoredItem<Record<string, unknown>> = { versionKey: generateUuid(), data: mutation.modified, } lastKnownData.set(mutation.key, storedItem) break } case `delete`: { lastKnownData.delete(mutation.key) break } } } // Save to storage saveToStorage(lastKnownData) // Confirm the mutations in the collection to move them from optimistic to synced state // This writes them through the sync interface to make them "synced" instead of "optimistic" sync.confirmOperationsSync(collectionMutations) } return { ...restConfig, id: collectionId, sync, onInsert: wrappedOnInsert, onUpdate: wrappedOnUpdate, onDelete: wrappedOnDelete, utils: { clearStorage, getStorageSize, acceptMutations, }, } } /** * Load data from storage and return as a Map * @param parser - The parser to use for deserializing the data * @param storageKey - The key used to store data in the storage API * @param storage - The storage API to load from (localStorage, sessionStorage, etc.) * @returns Map of stored items with version tracking, or empty Map if loading fails */ function loadFromStorage<T extends object>( storageKey: string, storage: StorageApi, parser: Parser, ): Map<string | number, StoredItem<T>> { try { const rawData = storage.getItem(storageKey) if (!rawData) { return new Map() } const parsed = parser.parse(rawData) const dataMap = new Map<string | number, StoredItem<T>>() // Handle object format where keys map to StoredItem values if ( typeof parsed === `object` && parsed !== null && !Array.isArray(parsed) ) { Object.entries(parsed).forEach(([encodedKey, value]) => { // Runtime check to ensure the value has the expected StoredItem structure if ( value && typeof value === `object` && `versionKey` in value && `data` in value ) { const storedItem = value as StoredItem<T> const decodedKey = decodeStorageKey(encodedKey) dataMap.set(decodedKey, storedItem) } else { throw new InvalidStorageDataFormatError(storageKey, encodedKey) } }) } else { throw new InvalidStorageObjectFormatError(storageKey) } return dataMap } catch (error) { console.warn( `[LocalStorageCollection] Error loading data from storage key "${storageKey}":`, error, ) return new Map() } } /** * Internal function to create localStorage sync configuration * Creates a sync configuration that handles localStorage persistence and cross-tab synchronization * @param storageKey - The key used for storing data in localStorage * @param storage - The storage API to use (localStorage, sessionStorage, etc.) * @param storageEventApi - The event API for listening to storage changes * @param getKey - Function to extract the key from an item * @param lastKnownData - Map tracking the last known state for change detection * @returns Sync configuration with manual trigger capability */ function createLocalStorageSync<T extends object>( storageKey: string, storage: StorageApi, storageEventApi: StorageEventApi, parser: Parser, _getKey: (item: T) => string | number, lastKnownData: Map<string | number, StoredItem<T>>, ): SyncConfig<T> & { manualTrigger?: () => void collection: any confirmOperationsSync: (mutations: Array<any>) => void } { let syncParams: Parameters<SyncConfig<T>[`sync`]>[0] | null = null let collection: any = null /** * Compare two Maps to find differences using version keys * @param oldData - The previous state of stored items * @param newData - The current state of stored items * @returns Array of changes with type, key, and value information */ const findChanges = ( oldData: Map<string | number, StoredItem<T>>, newData: Map<string | number, StoredItem<T>>, ): Array<{ type: `insert` | `update` | `delete` key: string | number value?: T }> => { const changes: Array<{ type: `insert` | `update` | `delete` key: string | number value?: T }> = [] // Check for deletions and updates oldData.forEach((oldStoredItem, key) => { const newStoredItem = newData.get(key) if (!newStoredItem) { changes.push({ type: `delete`, key, value: oldStoredItem.data }) } else if (oldStoredItem.versionKey !== newStoredItem.versionKey) { changes.push({ type: `update`, key, value: newStoredItem.data }) } }) // Check for insertions newData.forEach((newStoredItem, key) => { if (!oldData.has(key)) { changes.push({ type: `insert`, key, value: newStoredItem.data }) } }) return changes } /** * Process storage changes and update collection * Loads new data from storage, compares with last known state, and applies changes */ const processStorageChanges = () => { if (!syncParams) return const { begin, write, commit } = syncParams // Load the new data const newData = loadFromStorage<T>(storageKey, storage, parser) // Find the specific changes const changes = findChanges(lastKnownData, newData) if (changes.length > 0) { begin() changes.forEach(({ type, value }) => { if (value) { validateJsonSerializable(parser, value, type) write({ type, value }) } }) commit() // Update lastKnownData lastKnownData.clear() newData.forEach((storedItem, key) => { lastKnownData.set(key, storedItem) }) } } const syncConfig: SyncConfig<T> & { manualTrigger?: () => void collection: any } = { sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => { const { begin, write, commit, markReady } = params // Store sync params and collection for later use syncParams = params collection = params.collection // Initial load const initialData = loadFromStorage<T>(storageKey, storage, parser) if (initialData.size > 0) { begin() initialData.forEach((storedItem) => { validateJsonSerializable(parser, storedItem.data, `load`) write({ type: `insert`, value: storedItem.data }) }) commit() } // Update lastKnownData lastKnownData.clear() initialData.forEach((storedItem, key) => { lastKnownData.set(key, storedItem) }) // Mark collection as ready after initial load markReady() // Listen for storage events from other tabs const handleStorageEvent = (event: StorageEvent) => { // Only respond to changes to our specific key and from our storage if (event.key !== storageKey || event.storageArea !== storage) { return } processStorageChanges() } // Add storage event listener for cross-tab sync storageEventApi.addEventListener(`storage`, handleStorageEvent) // Note: Cleanup is handled automatically by the collection when it's disposed }, /** * Get sync metadata - returns storage key information * @returns Object containing storage key and storage type metadata */ getSyncMetadata: () => ({ storageKey, storageType: storage === (typeof window !== `undefined` ? window.localStorage : null) ? `localStorage` : `custom`, }), // Manual trigger function for local updates manualTrigger: processStorageChanges, // Collection instance reference collection, } /** * Confirms mutations by writing them through the sync interface * This moves mutations from optimistic to synced state * @param mutations - Array of mutation objects to confirm */ const confirmOperationsSync = (mutations: Array<any>) => { if (!syncParams) { // Sync not initialized yet, mutations will be handled on next sync return } const { begin, write, commit } = syncParams // Write the mutations through sync to confirm them begin() mutations.forEach((mutation: any) => { write({ type: mutation.type, value: mutation.type === `delete` ? mutation.original : mutation.modified, }) }) commit() } return { ...syncConfig, confirmOperationsSync, } }