UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

620 lines (544 loc) 18.4 kB
import { InvalidStorageDataFormatError, InvalidStorageObjectFormatError, NoStorageAvailableError, NoStorageEventApiError, SerializationError, StorageKeyRequiredError, } from "./errors" import type { BaseCollectionConfig, CollectionConfig, DeleteMutationFnParams, InferSchemaOutput, InsertMutationFnParams, 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 } /** * 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 } /** * 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 } /** * Validates that a value can be JSON serialized * @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(value: any, operation: string): void { try { JSON.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() } /** * 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. * * @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 and getStorageSize * * @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) * }, * }) * ) */ // 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> & { 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> & { 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>, `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 const storage = config.storage || (typeof window !== `undefined` ? window.localStorage : null) if (!storage) { throw new NoStorageAvailableError() } // Default to window for storage events if not provided const storageEventApi = config.storageEventApi || (typeof window !== `undefined` ? window : null) if (!storageEventApi) { throw new NoStorageEventApiError() } // 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, config.getKey, lastKnownData ) /** * Manual trigger function for local sync updates * Forces a check for storage changes and updates the collection if needed */ const triggerLocalSync = () => { if (sync.manualTrigger) { sync.manualTrigger() } } /** * 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[String(key)] = storedItem }) const serialized = JSON.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(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 // Load current data from storage const currentData = loadFromStorage<any>(config.storageKey, storage) // Add new items with version keys params.transaction.mutations.forEach((mutation) => { const key = config.getKey(mutation.modified) const storedItem: StoredItem<any> = { versionKey: generateUuid(), data: mutation.modified, } currentData.set(key, storedItem) }) // Save to storage saveToStorage(currentData) // Manually trigger local sync since storage events don't fire for current tab triggerLocalSync() 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(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 // Load current data from storage const currentData = loadFromStorage<any>(config.storageKey, storage) // Update items with new version keys params.transaction.mutations.forEach((mutation) => { const key = config.getKey(mutation.modified) const storedItem: StoredItem<any> = { versionKey: generateUuid(), data: mutation.modified, } currentData.set(key, storedItem) }) // Save to storage saveToStorage(currentData) // Manually trigger local sync since storage events don't fire for current tab triggerLocalSync() 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 // Load current data from storage const currentData = loadFromStorage<any>(config.storageKey, storage) // Remove items params.transaction.mutations.forEach((mutation) => { // For delete operations, mutation.original contains the full object const key = config.getKey(mutation.original) currentData.delete(key) }) // Save to storage saveToStorage(currentData) // Manually trigger local sync since storage events don't fire for current tab triggerLocalSync() 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}` return { ...restConfig, id: collectionId, sync, onInsert: wrappedOnInsert, onUpdate: wrappedOnUpdate, onDelete: wrappedOnDelete, utils: { clearStorage, getStorageSize, }, } } /** * Load data from storage and return as a Map * @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 ): Map<string | number, StoredItem<T>> { try { const rawData = storage.getItem(storageKey) if (!rawData) { return new Map() } const parsed = JSON.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(([key, 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> dataMap.set(key, storedItem) } else { throw new InvalidStorageDataFormatError(storageKey, key) } }) } 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, _getKey: (item: T) => string | number, lastKnownData: Map<string | number, StoredItem<T>> ): SyncConfig<T> & { manualTrigger?: () => void } { let syncParams: Parameters<SyncConfig<T>[`sync`]>[0] | null = 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) // Find the specific changes const changes = findChanges(lastKnownData, newData) if (changes.length > 0) { begin() changes.forEach(({ type, value }) => { if (value) { validateJsonSerializable(value, type) write({ type, value }) } }) commit() // Update lastKnownData lastKnownData.clear() newData.forEach((storedItem, key) => { lastKnownData.set(key, storedItem) }) } } const syncConfig: SyncConfig<T> & { manualTrigger?: () => void } = { sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => { const { begin, write, commit, markReady } = params // Store sync params for later use syncParams = params // Initial load const initialData = loadFromStorage<T>(storageKey, storage) if (initialData.size > 0) { begin() initialData.forEach((storedItem) => { validateJsonSerializable(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, } return syncConfig }