UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

1,162 lines (1,083 loc) • 36.9 kB
import { safeRandomUUID } from './utils/uuid' import { deepEquals } from './utils' import { withCollectionConfigFactory } from './client.js' import { collectionOptionsClaim } from './collection-options.js' import { registerTransactionCommitWork } from './transaction-commit-work.js' import { InvalidStorageDataFormatError, InvalidStorageObjectFormatError, LocalStorageCollectionError, SerializationError, StorageKeyRequiredError, } from './errors' import { codedMessage, codedWarning, devBuild } from './error-message.js' 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 } // Browser storage events do not reach other Collections in the writing tab. // A sync run owns one listener for writes through the same Storage object/key. const sameTabListeners = new WeakMap<StorageApi, Map<string, Set<() => void>>>() function subscribeToSameTabWrites( storage: StorageApi, key: string, refresh: () => void, ): () => void { let byKey = sameTabListeners.get(storage) if (!byKey) { byKey = new Map() sameTabListeners.set(storage, byKey) } let listeners = byKey.get(key) if (!listeners) { listeners = new Set() byKey.set(key, listeners) } listeners.add(refresh) return () => { listeners.delete(refresh) if (listeners.size === 0) byKey.delete(key) if (byKey.size === 0) sameTabListeners.delete(storage) } } function publishSameTabWrite( storage: StorageApi, key: string, writer?: { listener: () => void; refresh: () => void }, ): void { for (const refresh of [...(sameTabListeners.get(storage)?.get(key) ?? [])]) { try { if (refresh === writer?.listener) writer.refresh() else refresh() } catch (error) { // A peer's parser or sync listener cannot revoke the writer's durable // write or prevent another peer from observing it. console.warn( devBuild() && process.env.NODE_ENV !== `production` ? `[LocalStorageCollection] Error refreshing a same-tab peer for storage key "${key}":` : codedWarning(232, { storageKey: key }), error, ) } } } /** * 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 this Collection's manual mutations in write order and persists * them to localStorage. Call it inside the transaction's mutationFn. The * transaction's persistence receipt waits for this work even if the caller * does not await the returned Promise. Await it when later mutationFn work * depends on the storage write. * * @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 * await localSettings.utils.acceptMutations(transaction) * } * }) */ acceptMutations: (transaction: { mutations: Array<PendingMutation<Record<string, unknown>>> }) => Promise<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 safeRandomUUID() } /** * 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 } function sameStorageKey( left: string | number, right: string | number, ): boolean { return left === right || (Number.isNaN(left) && Number.isNaN(right)) } /** * 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. Active * Collections sharing this Storage object and storage key also synchronize * writes in the same tab, without waiting for a browser event. * Create fresh options for each direct `createCollection()` call. One options * object contains state owned by one Collection and cannot be reused. * * **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, call `utils.acceptMutations()` in your transaction's `mutationFn` * to persist changes made during `tx.mutate()`. The transaction receipt waits for this work even * when the call is not awaited. Await it when later mutationFn work depends on the storage write. * * @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 * await 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>>, mutations: Array<PendingMutation<Record<string, unknown>>>, ): { serialized: string persisted: Map<string | number, StoredItem<any>> } => { 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) // A custom parser may normalize rows while serializing. Validate and // retain the exact snapshot before setItem makes the write durable. const persisted = parser === JSON ? dataMap : readFromStorage( config.storageKey, storage, parser, config.getKey, serialized, ) for (const mutation of mutations) { const storedItem = persisted.get(mutation.key) if (mutation.type === `delete` ? storedItem : !storedItem) { throw new InvalidStorageDataFormatError( config.storageKey, encodeStorageKey(mutation.key), ) } } storage.setItem(config.storageKey, serialized) return { serialized, persisted } } catch (error) { console.error( devBuild() && process.env.NODE_ENV !== `production` ? `[LocalStorageCollection] Error saving data to storage key "${config.storageKey}":` : codedMessage(218, { storageKey: config.storageKey }), error, ) throw error } } /** * Removes the stored snapshot and publishes removal of accepted synced rows. * Pending optimistic mutations can still settle afterward. */ const clearStorage: ClearStorageFn = (): void => { storage.removeItem(config.storageKey) publishSameTabWrite(storage, 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 } const persistMutations = ( mutations: Array<PendingMutation<Record<string, unknown>>>, ): void => { // A peer may have written after our last storage event. Start from the // current durable snapshot so this write preserves untouched peer rows. const staged = readFromStorage( config.storageKey, storage, parser, config.getKey, ) for (const mutation of mutations) { if (mutation.type === `delete`) staged.delete(mutation.key) else { if (parser === JSON) { const restored = JSON.parse(JSON.stringify(mutation.modified)) if (!sameStorageKey(config.getKey(restored), mutation.key)) { throw new InvalidStorageDataFormatError( config.storageKey, encodeStorageKey(mutation.key), ) } } staged.set(mutation.key, { versionKey: generateUuid(), data: mutation.modified, }) } } const { serialized: savedRaw, persisted } = saveToStorage(staged, mutations) try { for (const mutation of mutations) { const storedItem = persisted.get(mutation.key) if (storedItem) lastKnownData.set(mutation.key, storedItem) else lastKnownData.delete(mutation.key) } sync.confirmOperationsSync(mutations, persisted) } finally { // Storage has already accepted this snapshot. A local confirmation // error must not suppress publication to other active Collections. const writerRefresh = sync.manualTrigger publishSameTabWrite( storage, config.storageKey, writerRefresh ? { listener: writerRefresh, refresh: () => writerRefresh( storage.getItem(config.storageKey) === savedRaw ? persisted : undefined, ), } : undefined, ) } } // Reserve automatic and manual writes in acceptance order. A rejected // handler releases only its storage slot after its predecessor, while its // own transaction receipt can reject immediately. let writeTail: Promise<void> | undefined const reserveWrite = () => { const previous = writeTail let release!: () => void const slot = new Promise<void>((resolve) => { release = resolve }) writeTail = slot const retire = () => { release() if (writeTail === slot) writeTail = undefined } return { previous, retire } } const persistAutomatic = ( mutations: Array<PendingMutation<Record<string, unknown>>>, handler?: () => unknown | Promise<unknown>, ): Promise<unknown> => { const { previous, retire } = reserveWrite() // Native Storage writes synchronously. With no handler or earlier write, // preserve that direct-mutation return boundary. if (!handler && previous === undefined) { try { persistMutations(mutations) return Promise.resolve({}) } catch (error) { return Promise.reject(error) } finally { retire() } } let result: unknown try { result = handler?.() } catch (error) { if (previous) void previous.then(retire) else retire() return Promise.reject(error) } return Promise.resolve(result).then( async (value) => { await previous try { persistMutations(mutations) return value ?? {} } finally { retire() } }, (error: unknown) => { // Rejection belongs to this transaction immediately. Only its empty // storage slot must wait for the earlier accepted write to retire. if (previous) void previous.then(retire) else retire() throw error }, ) } const persistManual = ( mutations: Array<PendingMutation<Record<string, unknown>>>, ): Promise<void> => { const { previous, retire } = reserveWrite() if (previous === undefined) { try { persistMutations(mutations) return Promise.resolve() } finally { retire() } } return previous.then(() => { try { persistMutations(mutations) } finally { retire() } }) } const wrappedOnInsert = (params: InsertMutationFnParams<any>) => { params.transaction.mutations.forEach((mutation) => { validateJsonSerializable(parser, mutation.modified, `insert`) }) return persistAutomatic( params.transaction.mutations, config.onInsert ? () => config.onInsert!(params) : undefined, ) } const wrappedOnUpdate = (params: UpdateMutationFnParams<any>) => { params.transaction.mutations.forEach((mutation) => { validateJsonSerializable(parser, mutation.modified, `update`) }) return persistAutomatic( params.transaction.mutations, config.onUpdate ? () => config.onUpdate!(params) : undefined, ) } const wrappedOnDelete = (params: DeleteMutationFnParams<any>) => persistAutomatic( params.transaction.mutations, config.onDelete ? () => config.onDelete!(params) : undefined, ) // Extract standard Collection config properties // Remove localStorage-specific properties so they don't leak into the CollectionConfig const { storageKey: _storageKey, storage: _storage, storageEventApi: _storageEventApi, parser: _parser, 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 this Collection's manual mutations in write order and resolves * after their storage write. A mutationFn receipt tracks this work even * when the caller does not await the returned Promise. */ const persistAcceptedMutations = async ( transaction: { mutations: Array<PendingMutation<Record<string, unknown>>> }, owner: PendingMutation<Record<string, unknown>>[`collection`] | undefined, ): Promise<void> => { const collectionMutations = transaction.mutations.filter( (mutation) => mutation.collection === owner, ) if (collectionMutations.length === 0) { return Promise.resolve() } // 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 } } await persistManual(collectionMutations) } const materializedAcceptors = new WeakSet< LocalStorageCollectionUtils[`acceptMutations`] >() const acceptMutations = (transaction: { mutations: Array<PendingMutation<Record<string, unknown>>> }): Promise<void> => { // A DbClient materializes module-level options into a fresh Collection. // Route acceptance to that Collection's utility so its automatic and // manual writes reserve slots in the same queue. const candidates = new Set( transaction.mutations .filter((mutation) => mutation.collection.id === collectionId) .map((mutation) => mutation.collection), ) const owned = [...candidates].filter((candidate) => { const candidateAccept = (candidate.utils as LocalStorageCollectionUtils) .acceptMutations return ( candidateAccept === acceptMutations || materializedAcceptors.has(candidateAccept) ) }) if (owned.length > 1 || (candidates.size > 0 && owned.length === 0)) { const work = Promise.reject( new LocalStorageCollectionError( devBuild() && process.env.NODE_ENV !== `production` ? `LocalStorage manual acceptance belongs to a different Collection.` : codedMessage(231), ), ) registerTransactionCommitWork(transaction, work) return work } const owner = owned[0] const ownerAccept = owner ? (owner.utils as LocalStorageCollectionUtils).acceptMutations : undefined if (ownerAccept && ownerAccept !== acceptMutations) return ownerAccept(transaction) const work = persistAcceptedMutations(transaction, owner) registerTransactionCommitWork(transaction, work) return work } const options = { ...restConfig, id: collectionId, sync, onInsert: wrappedOnInsert, onUpdate: wrappedOnUpdate, onDelete: wrappedOnDelete, utils: { clearStorage, getStorageSize, acceptMutations, }, } let claimed = false Object.defineProperty(options, collectionOptionsClaim, { enumerable: true, value: () => { if (claimed) throw new LocalStorageCollectionError( devBuild() && process.env.NODE_ENV !== `production` ? `LocalStorage options can create only one Collection. Create fresh options for each Collection.` : codedMessage(230), ) claimed = true }, }) return withCollectionConfigFactory(options, () => { const materialized = localStorageCollectionOptions({ ...config, id: collectionId, }) as unknown as typeof options materializedAcceptors.add(materialized.utils.acceptMutations) return materialized }) } /** * Read data from storage without treating a failed read as an empty snapshot. * @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 */ function readFromStorage<T extends object>( storageKey: string, storage: StorageApi, parser: Parser, getKey: (item: T) => string | number, knownRaw?: string, ): Map<string | number, StoredItem<T>> { const rawData = knownRaw ?? storage.getItem(storageKey) if (rawData === null) { 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 && typeof value.versionKey === `string` && `data` in value && value.data !== null && typeof value.data === `object` ) { const storedItem = value as StoredItem<T> const decodedKey = decodeStorageKey(encodedKey) if ( !sameStorageKey(getKey(storedItem.data), decodedKey) || dataMap.has(decodedKey) ) { throw new InvalidStorageDataFormatError(storageKey, encodedKey) } dataMap.set(decodedKey, storedItem) } else { throw new InvalidStorageDataFormatError(storageKey, encodedKey) } }) } else { throw new InvalidStorageObjectFormatError(storageKey) } return dataMap } /** * 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?: (knownSnapshot?: Map<string | number, StoredItem<T>>) => void collection: any confirmOperationsSync: ( mutations: Array<any>, persisted: Map<string | number, StoredItem<T>>, ) => 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>>, compareContent: boolean, ): 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 || (compareContent && !deepEquals(oldStoredItem.data, newStoredItem.data) && // A default-JSON writer may retain a native Date while the stored // row contains its ISO string. A peer's unrelated write must not // publish that representation change as an update. (parser !== JSON || JSON.stringify(oldStoredItem.data) !== JSON.stringify(newStoredItem.data))) ) { 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 = ( knownSnapshot?: Map<string | number, StoredItem<T>>, ) => { if (!syncParams) return const { begin, write, commit } = syncParams // Load the new data let newData: Map<string | number, StoredItem<T>> try { newData = knownSnapshot ?? readFromStorage<T>(storageKey, storage, parser, getKey) } catch (error) { console.warn( devBuild() && process.env.NODE_ENV !== `production` ? `[LocalStorageCollection] Error loading data from storage key "${storageKey}":` : codedWarning(219, { storageKey }), error, ) return } // Find the specific changes // The default-JSON writer deliberately retains native authored values. // Peers and storage events must compare content even under the same token. const changes = findChanges( lastKnownData, newData, knownSnapshot === undefined || parser !== JSON, ) if (changes.length > 0) { // Reject an invalid snapshot before opening a sync transaction. This // leaves the previous mirror available for a later valid refresh. changes.forEach(({ type, value }) => { if (value) validateJsonSerializable(parser, value, type) }) begin() changes.forEach(({ type, value }) => { if (value) { write({ type, value }) } }) // A commit can synchronously publish to a subscriber that writes again. // The nested refresh must compare against this snapshot, not its parent. lastKnownData.clear() newData.forEach((storedItem, key) => { lastKnownData.set(key, storedItem) }) commit() } } const syncConfig: SyncConfig<T> & { manualTrigger?: ( knownSnapshot?: Map<string | number, StoredItem<T>>, ) => void } = { rowUpdateMode: `full`, sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => { const { begin, write, commit, markReady } = params // A failed restore cannot establish an empty authoritative snapshot. // Read before retaining this sync run's callbacks so startup failure // leaves no stale adapter state. const initialData = readFromStorage<T>( storageKey, storage, parser, getKey, ) initialData.forEach((storedItem) => { validateJsonSerializable(parser, storedItem.data, `load`) }) syncParams = params collection = params.collection // Establish the mirror and same-tab listener before restore publishes // callbacks, which can synchronously write through an already-ready peer. lastKnownData.clear() initialData.forEach((storedItem, key) => { lastKnownData.set(key, storedItem) }) // 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() } const unsubscribeSameTab = subscribeToSameTabWrites( storage, storageKey, processStorageChanges, ) try { if (initialData.size > 0) { begin() initialData.forEach((storedItem) => { write({ type: `insert`, value: storedItem.data }) }) commit() } // Mark collection as ready after initial load, then listen for // browser events from other tabs. markReady() storageEventApi.addEventListener(`storage`, handleStorageEvent) } catch (error) { unsubscribeSameTab() if (syncParams === params) { syncParams = null } throw error } return { cleanup: () => { storageEventApi.removeEventListener(`storage`, handleStorageEvent) unsubscribeSameTab() if (syncParams === params) { syncParams = null } }, } }, /** * 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, } /** * 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>, persisted: Map<string | number, StoredItem<T>>, ) => { 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({ // A peer may have inserted the same key while this local mutation // was pending. Full-row update confirms the accepted stored value in // either case without a duplicate insert. type: mutation.type === `delete` ? `delete` : `update`, value: mutation.type === `delete` ? mutation.original : persisted.get(mutation.key)!.data, }) }) commit() } return { ...syncConfig, get collection() { return collection }, confirmOperationsSync, } }