diff --git a/.changeset/add-local-storage-collection.md b/.changeset/add-local-storage-collection.md new file mode 100644 index 000000000..b39f1ac8f --- /dev/null +++ b/.changeset/add-local-storage-collection.md @@ -0,0 +1,5 @@ +--- +"@tanstack/db-collections": patch +--- + +Add localStorage collection with cross-tab sync and configurable storage APIs. diff --git a/docs/overview.md b/docs/overview.md index 811c4a160..536ac068d 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -66,7 +66,7 @@ Collections can be populated in many ways, including: - fetching data, for example [from API endpoints using TanStack Query](https://tanstack.com/query/latest) - syncing data, for example [using a sync engine like ElectricSQL](https://electric-sql.com/) -- storing local data, for example [in-memory client data or UI state](https://github.com/TanStack/db/issues/79) +- storing local data, for example [using localStorage for user preferences and settings](#localstoragecollection) or [in-memory client data or UI state](#localonlycollection) - from live collection queries, creating [derived collections as materialised views](#using-live-queries) Once you have your data in collections, you can query across them using live queries in your components. @@ -153,7 +153,8 @@ There are a number of built-in collection types implemented in [`@tanstack/db-co 1. [`QueryCollection`](#querycollection) to load data into collections using [TanStack Query](https://tanstack.com/query) 2. [`ElectricCollection`](#electriccollection) to sync data into collections using [ElectricSQL](https://electric-sql.com) -3. [WIP] [`LocalCollection`](#localcollection) for in-memory client data or UI state +3. [`LocalStorageCollection`](#localstoragecollection) for small amounts of local-only state that syncs across browser tabs +4. [WIP] [`LocalOnlyCollection`](#localonlycollection) for in-memory client data or UI state You can also use: @@ -247,10 +248,49 @@ If you need more control over what data syncs into the collection, Electric allo See the [Electric docs](https://electric-sql.com/docs/intro) for more information. -#### `LocalCollection` +#### `LocalStorageCollection` + +localStorage collections store small amounts of local-only state that persists across browser sessions and syncs across browser tabs in real-time. All data is stored under a single localStorage key and automatically synchronized using storage events. + +Use `localStorageCollectionOptions` to create a collection that stores data in localStorage: + +```ts +import { createCollection } from '@tanstack/react-db' +import { localStorageCollectionOptions } from '@tanstack/db-collections' + +export const userPreferencesCollection = createCollection(localStorageCollectionOptions({ + id: 'user-preferences', + storageKey: 'app-user-prefs', // localStorage key + getKey: (item) => item.id, + schema: userPrefsSchema +})) +``` + +The localStorage collection requires: + +- `storageKey` — the localStorage key where all collection data is stored +- `getKey` — identifies the id for items in the collection + +Mutation handlers (`onInsert`, `onUpdate`, `onDelete`) are completely optional. Data will persist to localStorage whether or not you provide handlers. You can provide alternative storage backends like `sessionStorage` or custom implementations that match the localStorage API. + +```ts +export const sessionCollection = createCollection(localStorageCollectionOptions({ + id: 'session-data', + storageKey: 'session-key', + storage: sessionStorage, // Use sessionStorage instead + getKey: (item) => item.id +})) +``` + +> [!TIP] +> localStorage collections are perfect for user preferences, UI state, and other data that should persist locally but doesn't need server synchronization. For server-synchronized data, use [`QueryCollection`](#querycollection) or [`ElectricCollection`](#electriccollection) instead. + +#### `LocalOnlyCollection` This is WIP. Track progress at [#79](https://github.com/TanStack/db/issues/79). +LocalOnly collections will be designed for in-memory client data or UI state that doesn't need to persist across browser sessions or sync across tabs. + #### Derived collections Live queries return collections. This allows you to derive collections from other collections. diff --git a/packages/db-collections/src/index.ts b/packages/db-collections/src/index.ts index d347679f3..7736320db 100644 --- a/packages/db-collections/src/index.ts +++ b/packages/db-collections/src/index.ts @@ -8,3 +8,10 @@ export { type QueryCollectionConfig, type QueryCollectionUtils, } from "./query" +export { + localStorageCollectionOptions, + type LocalStorageCollectionConfig, + type LocalStorageCollectionUtils, + type StorageApi, + type StorageEventApi, +} from "./local-storage" diff --git a/packages/db-collections/src/local-storage.ts b/packages/db-collections/src/local-storage.ts new file mode 100644 index 000000000..0c20269ca --- /dev/null +++ b/packages/db-collections/src/local-storage.ts @@ -0,0 +1,583 @@ +import type { + CollectionConfig, + DeleteMutationFnParams, + InsertMutationFnParams, + ResolveType, + SyncConfig, + UpdateMutationFnParams, + UtilsRecord, +} from "@tanstack/db" +import type { StandardSchemaV1 } from "@standard-schema/spec" + +/** + * Storage API interface - subset of DOM Storage that we need + */ +export type StorageApi = Pick + +/** + * 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 { + versionKey: string + data: T +} + +/** + * Configuration interface for localStorage collection options + * @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 + * + * @remarks + * Type resolution follows a priority order: + * 1. If you provide an explicit type via generic parameter, it will be used + * 2. If no explicit type is provided but a schema is, the schema's output type will be inferred + * 3. If neither explicit type nor schema is provided, the fallback type will be used + * + * You should provide EITHER an explicit type OR a schema, but not both, as they would conflict. + */ +export interface LocalStorageCollectionConfig< + TExplicit = unknown, + TSchema extends StandardSchemaV1 = never, + TFallback extends object = Record, +> { + /** + * 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 + + /** + * Collection identifier (defaults to "local-collection:{storageKey}" if not provided) + */ + id?: string + schema?: TSchema + getKey: CollectionConfig>[`getKey`] + sync?: CollectionConfig>[`sync`] + + /** + * Optional asynchronous handler function called before an insert operation + * @param params Object containing transaction and mutation information + * @returns Promise resolving to any value + */ + onInsert?: ( + params: InsertMutationFnParams> + ) => Promise + + /** + * Optional asynchronous handler function called before an update operation + * @param params Object containing transaction and mutation information + * @returns Promise resolving to any value + */ + onUpdate?: ( + params: UpdateMutationFnParams> + ) => Promise + + /** + * Optional asynchronous handler function called before a delete operation + * @param params Object containing transaction and mutation information + * @returns Promise resolving to any value + */ + onDelete?: ( + params: DeleteMutationFnParams> + ) => Promise +} + +/** + * 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 + */ +function validateJsonSerializable(value: any, operation: string): void { + try { + JSON.stringify(value) + } catch (error) { + throw new Error( + `Cannot ${operation} item because it cannot be JSON serialized: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } +} + +/** + * Generate a UUID for version tracking + */ +function generateUuid(): string { + return crypto.randomUUID() +} + +/** + * Creates localStorage collection options for use with a standard Collection + * + * @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 + */ +export function localStorageCollectionOptions< + TExplicit = unknown, + TSchema extends StandardSchemaV1 = never, + TFallback extends object = Record, +>(config: LocalStorageCollectionConfig) { + type ResolvedType = ResolveType + + // Validate required parameters + if (!config.storageKey) { + throw new Error(`[LocalStorageCollection] storageKey must be provided.`) + } + + // Default to window.localStorage if no storage is provided + const storage = + config.storage || + (typeof window !== `undefined` ? window.localStorage : null) + + if (!storage) { + throw new Error( + `[LocalStorageCollection] No storage available. Please provide a storage option or ensure window.localStorage is available.` + ) + } + + // Default to window for storage events if not provided + const storageEventApi = + config.storageEventApi || (typeof window !== `undefined` ? window : null) + + if (!storageEventApi) { + throw new Error( + `[LocalStorageCollection] No storage event API available. Please provide a storageEventApi option or ensure window is available.` + ) + } + + // Track the last known state to detect changes + const lastKnownData = new Map>() + + // Create the sync configuration + const sync = createLocalStorageSync( + config.storageKey, + storage, + storageEventApi, + config.getKey, + lastKnownData + ) + + // Manual trigger function for local sync updates + const triggerLocalSync = () => { + if (sync.manualTrigger) { + sync.manualTrigger() + } + } + + /** + * Save data to storage + */ + const saveToStorage = ( + dataMap: Map> + ): void => { + try { + // Convert Map to object format for storage + const objectData: Record> = {} + 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 + } + } + + /** + * Clear all data from the storage key + */ + const clearStorage: ClearStorageFn = (): void => { + storage.removeItem(config.storageKey) + } + + /** + * Get the size of the stored data in bytes (approximate) + */ + 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 + const wrappedOnInsert = async ( + params: InsertMutationFnParams + ) => { + // 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( + config.storageKey, + storage + ) + + // Add new items with version keys + params.transaction.mutations.forEach((mutation) => { + const key = config.getKey(mutation.modified) + const storedItem: StoredItem = { + 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 + ) => { + // 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( + config.storageKey, + storage + ) + + // Update items with new version keys + params.transaction.mutations.forEach((mutation) => { + const key = config.getKey(mutation.modified) + const storedItem: StoredItem = { + 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 + ) => { + // 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( + 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 + */ +function loadFromStorage( + storageKey: string, + storage: StorageApi +): Map> { + try { + const rawData = storage.getItem(storageKey) + if (!rawData) { + return new Map() + } + + const parsed = JSON.parse(rawData) + const dataMap = new Map>() + + // 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 + dataMap.set(key, storedItem) + } else { + throw new Error( + `[LocalStorageCollection] Invalid data format in storage key "${storageKey}" for key "${key}".` + ) + } + }) + } else { + throw new Error( + `[LocalStorageCollection] Invalid data format in storage key "${storageKey}". Expected object format.` + ) + } + + 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 + */ +function createLocalStorageSync( + storageKey: string, + storage: StorageApi, + storageEventApi: StorageEventApi, + getKey: (item: T) => string | number, + lastKnownData: Map> +): SyncConfig & { manualTrigger?: () => void } { + let syncParams: Parameters[`sync`]>[0] | null = null + + /** + * Compare two Maps to find differences using version keys + */ + const findChanges = ( + oldData: Map>, + newData: Map> + ): 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 + */ + const processStorageChanges = () => { + if (!syncParams) return + + const { begin, write, commit } = syncParams + + // Load the new data + const newData = loadFromStorage(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 & { manualTrigger?: () => void } = { + sync: (params: Parameters[`sync`]>[0]) => { + const { begin, write, commit } = params + + // Store sync params for later use + syncParams = params + + // Initial load + const initialData = loadFromStorage(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) + }) + + // 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 + */ + getSyncMetadata: () => ({ + storageKey, + storageType: + storage === (typeof window !== `undefined` ? window.localStorage : null) + ? `localStorage` + : `custom`, + }), + + // Manual trigger function for local updates + manualTrigger: processStorageChanges, + } + + return syncConfig +} diff --git a/packages/db-collections/tests/local-storage.test-d.ts b/packages/db-collections/tests/local-storage.test-d.ts new file mode 100644 index 000000000..7b4959c9f --- /dev/null +++ b/packages/db-collections/tests/local-storage.test-d.ts @@ -0,0 +1,282 @@ +import { describe, expectTypeOf, it } from "vitest" +import { z } from "zod" +import { createCollection } from "@tanstack/db" +import { localStorageCollectionOptions } from "../src/local-storage" +import type { + LocalStorageCollectionConfig, + StorageApi, + StorageEventApi, +} from "../src/local-storage" +import type { + CollectionConfig, + DeleteMutationFnParams, + InsertMutationFnParams, + ResolveType, + UpdateMutationFnParams, +} from "@tanstack/db" + +describe(`LocalStorage collection type resolution tests`, () => { + // Define test types + type ExplicitType = { id: string; explicit: boolean } + type FallbackType = { id: string; fallback: boolean } + + // Define a schema + const testSchema = z.object({ + id: z.string(), + schema: z.boolean(), + }) + + type SchemaType = z.infer + + // Mock storage and event API for type tests + const mockStorage: StorageApi = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + } + + const mockStorageEventApi: StorageEventApi = { + addEventListener: () => {}, + removeEventListener: () => {}, + } + + it(`should return a type compatible with createCollection`, () => { + const options = localStorageCollectionOptions({ + storageKey: `test`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (item) => item.id, + }) + + // Should be able to create a collection with the returned options + const collection = createCollection(options) + + // Verify the collection has the expected methods and properties + expectTypeOf(collection.get).toBeFunction() + expectTypeOf(collection.insert).toBeFunction() + expectTypeOf(collection.update).toBeFunction() + expectTypeOf(collection.delete).toBeFunction() + expectTypeOf(collection.size).toBeNumber() + expectTypeOf(collection.utils.clearStorage).toBeFunction() + expectTypeOf(collection.utils.getStorageSize).toBeFunction() + }) + + it(`should prioritize explicit type in LocalStorageCollectionConfig`, () => { + const options = localStorageCollectionOptions({ + storageKey: `test`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (item) => item.id, + }) + + type ExpectedType = ResolveType< + ExplicitType, + never, + Record + > + // The getKey function should have the resolved type + expectTypeOf(options.getKey).parameters.toEqualTypeOf<[ExplicitType]>() + expectTypeOf().toEqualTypeOf() + }) + + it(`should use schema type when explicit type is not provided`, () => { + const options = localStorageCollectionOptions({ + storageKey: `test`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + schema: testSchema, + getKey: (item) => item.id, + }) + + type ExpectedType = ResolveType< + unknown, + typeof testSchema, + Record + > + // The getKey function should have the resolved type + expectTypeOf(options.getKey).parameters.toEqualTypeOf<[SchemaType]>() + expectTypeOf().toEqualTypeOf() + }) + + it(`should use fallback type when neither explicit nor schema type is provided`, () => { + const config: LocalStorageCollectionConfig = { + storageKey: `test`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (item) => item.id, + } + + const options = localStorageCollectionOptions( + config + ) + + type ExpectedType = ResolveType + // The getKey function should have the resolved type + expectTypeOf(options.getKey).parameters.toEqualTypeOf<[FallbackType]>() + expectTypeOf().toEqualTypeOf() + }) + + it(`should correctly resolve type with all three types provided`, () => { + const options = localStorageCollectionOptions< + ExplicitType, + typeof testSchema, + FallbackType + >({ + storageKey: `test`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + schema: testSchema, + getKey: (item) => item.id, + }) + + type ExpectedType = ResolveType< + ExplicitType, + typeof testSchema, + FallbackType + > + // The getKey function should have the resolved type (explicit type should win) + expectTypeOf(options.getKey).parameters.toEqualTypeOf<[ExplicitType]>() + expectTypeOf().toEqualTypeOf() + }) + + it(`should properly type the onInsert, onUpdate, and onDelete handlers`, () => { + const options = localStorageCollectionOptions({ + storageKey: `test`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (item) => item.id, + onInsert: (params) => { + // Verify that the mutation value has the correct type + expectTypeOf( + params.transaction.mutations[0].modified + ).toEqualTypeOf() + return Promise.resolve({ success: true }) + }, + onUpdate: (params) => { + // Verify that the mutation value has the correct type + expectTypeOf( + params.transaction.mutations[0].modified + ).toEqualTypeOf() + return Promise.resolve({ success: true }) + }, + onDelete: (params) => { + // Verify that the mutation value has the correct type + expectTypeOf( + params.transaction.mutations[0].original + ).toEqualTypeOf() + return Promise.resolve({ success: true }) + }, + }) + + // Verify that the handlers are properly typed + expectTypeOf(options.onInsert).parameters.toEqualTypeOf< + [InsertMutationFnParams] + >() + + expectTypeOf(options.onUpdate).parameters.toEqualTypeOf< + [UpdateMutationFnParams] + >() + + expectTypeOf(options.onDelete).parameters.toEqualTypeOf< + [DeleteMutationFnParams] + >() + }) + + it(`should properly type localStorage-specific configuration options`, () => { + const config: LocalStorageCollectionConfig = { + storageKey: `test`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (item) => item.id, + id: `custom-id`, + } + + // Verify config types + expectTypeOf(config.storageKey).toEqualTypeOf() + expectTypeOf(config.storage).toEqualTypeOf() + expectTypeOf(config.storageEventApi).toEqualTypeOf< + StorageEventApi | undefined + >() + expectTypeOf(config.id).toEqualTypeOf() + + const options = localStorageCollectionOptions(config) + + // Verify the id defaults correctly + expectTypeOf(options.id).toEqualTypeOf() + }) + + it(`should properly type utility functions`, () => { + const options = localStorageCollectionOptions({ + storageKey: `test`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (item) => item.id, + }) + + // Verify utility function types + expectTypeOf(options.utils.clearStorage).toEqualTypeOf<() => void>() + expectTypeOf(options.utils.getStorageSize).toEqualTypeOf<() => number>() + }) + + it(`should properly type sync configuration`, () => { + const options = localStorageCollectionOptions({ + storageKey: `test`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (item) => item.id, + }) + + // Verify sync has the correct type and optional getSyncMetadata + expectTypeOf(options.sync).toMatchTypeOf< + CollectionConfig[`sync`] + >() + + if (options.sync.getSyncMetadata) { + expectTypeOf(options.sync.getSyncMetadata).toBeFunction() + // Verify that getSyncMetadata returns an object with expected properties + const metadata = options.sync.getSyncMetadata() + expectTypeOf(metadata).toHaveProperty(`storageKey`) + expectTypeOf(metadata).toHaveProperty(`storageType`) + } + }) + + it(`should allow optional storage and storageEventApi (defaults to window)`, () => { + // This should compile without providing storage or storageEventApi + const config: LocalStorageCollectionConfig = { + storageKey: `test`, + getKey: (item) => item.id, + } + + expectTypeOf(config.storage).toEqualTypeOf() + expectTypeOf(config.storageEventApi).toEqualTypeOf< + StorageEventApi | undefined + >() + }) + + it(`should properly constrain StorageApi and StorageEventApi interfaces`, () => { + // Test that our interfaces match the expected DOM APIs + const localStorage: Pick = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + } + + const windowEventApi: { + addEventListener: ( + type: `storage`, + listener: (event: StorageEvent) => void + ) => void + removeEventListener: ( + type: `storage`, + listener: (event: StorageEvent) => void + ) => void + } = { + addEventListener: () => {}, + removeEventListener: () => {}, + } + + // These should be assignable to our interfaces + expectTypeOf(localStorage).toMatchTypeOf() + expectTypeOf(windowEventApi).toMatchTypeOf() + }) +}) diff --git a/packages/db-collections/tests/local-storage.test.ts b/packages/db-collections/tests/local-storage.test.ts new file mode 100644 index 000000000..18991700b --- /dev/null +++ b/packages/db-collections/tests/local-storage.test.ts @@ -0,0 +1,770 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { createCollection } from "@tanstack/db" +import { localStorageCollectionOptions } from "../src/local-storage" +import type { StorageEventApi } from "../src/local-storage" + +// Mock storage implementation for testing that properly implements Storage interface +class MockStorage implements Storage { + private store: Record = {} + + get length(): number { + return Object.keys(this.store).length + } + + getItem(key: string): string | null { + return this.store[key] || null + } + + setItem(key: string, value: string): void { + this.store[key] = value + } + + removeItem(key: string): void { + delete this.store[key] + } + + clear(): void { + this.store = {} + } + + key(index: number): string | null { + const keys = Object.keys(this.store) + return keys[index] || null + } +} + +// Mock storage event API for testing +class MockStorageEventApi implements StorageEventApi { + private listeners: Array<(event: StorageEvent) => void> = [] + + addEventListener( + type: `storage`, + listener: (event: StorageEvent) => void + ): void { + this.listeners.push(listener) + } + + removeEventListener( + type: `storage`, + listener: (event: StorageEvent) => void + ): void { + const index = this.listeners.indexOf(listener) + if (index > -1) { + this.listeners.splice(index, 1) + } + } + + // Helper method for tests to trigger storage events + triggerStorageEvent(event: StorageEvent): void { + this.listeners.forEach((listener) => listener(event)) + } +} + +// Test interface for todo items +interface Todo { + id: string + title: string + completed: boolean + createdAt: Date +} + +describe(`localStorage collection`, () => { + let mockStorage: MockStorage + let mockStorageEventApi: MockStorageEventApi + + beforeEach(() => { + mockStorage = new MockStorage() + mockStorageEventApi = new MockStorageEventApi() + }) + + afterEach(() => { + mockStorage.clear() + vi.clearAllMocks() + }) + + describe(`basic functionality`, () => { + it(`should create a localStorage collection with required config`, () => { + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + }) + ) + + expect(collection).toBeDefined() + expect(collection.utils.clearStorage).toBeDefined() + expect(collection.utils.getStorageSize).toBeDefined() + }) + + it(`should default id to local-collection:storageKey pattern`, () => { + const options = localStorageCollectionOptions({ + storageKey: `my-todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + }) + + expect(options.id).toBe(`local-collection:my-todos`) + }) + + it(`should use provided id when specified`, () => { + const options = localStorageCollectionOptions({ + storageKey: `my-todos`, + id: `custom-collection-id`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + }) + + expect(options.id).toBe(`custom-collection-id`) + }) + + it(`should throw error when storageKey is missing`, () => { + expect(() => + localStorageCollectionOptions({ + storageKey: ``, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (item: any) => item.id, + }) + ).toThrow(`[LocalStorageCollection] storageKey must be provided.`) + }) + + it(`should throw error when no storage is available`, () => { + // Mock window to be undefined globally + const originalWindow = globalThis.window + // @ts-ignore - Temporarily delete window to test error condition + delete globalThis.window + + expect(() => + localStorageCollectionOptions({ + storageKey: `test`, + storageEventApi: mockStorageEventApi, + getKey: (item: any) => item.id, + }) + ).toThrow(`[LocalStorageCollection] No storage available.`) + + // Restore window + globalThis.window = originalWindow + }) + + it(`should throw error when no storage event API is available`, () => { + // Mock window to be undefined globally + const originalWindow = globalThis.window + // @ts-ignore - Temporarily delete window to test error condition + delete globalThis.window + + expect(() => + localStorageCollectionOptions({ + storageKey: `test`, + storage: mockStorage, + getKey: (item: any) => item.id, + }) + ).toThrow(`[LocalStorageCollection] No storage event API available.`) + + // Restore window + globalThis.window = originalWindow + }) + }) + + describe(`data persistence`, () => { + it(`should load existing data from storage on initialization`, () => { + // Pre-populate storage with new versioned format + const existingTodos = { + "1": { + versionKey: `test-version-1`, + data: { + id: `1`, + title: `Existing Todo`, + completed: false, + createdAt: new Date(), + }, + }, + } + + mockStorage.setItem(`todos`, JSON.stringify(existingTodos)) + + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + }) + ) + + // Subscribe to trigger sync + const unsubscribe = collection.subscribeChanges(() => {}) + + // Should load the existing data + expect(collection.size).toBe(1) + expect(collection.get(`1`)?.title).toBe(`Existing Todo`) + + unsubscribe() + }) + + it(`should handle corrupted storage data gracefully`, () => { + // Set invalid JSON data + mockStorage.setItem(`todos`, `invalid json data`) + + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + }) + ) + + // Should initialize with empty collection + expect(collection.size).toBe(0) + }) + + it(`should handle empty storage gracefully`, () => { + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + }) + ) + + // Should initialize with empty collection + expect(collection.size).toBe(0) + }) + }) + + describe(`mutation handlers with storage operations`, () => { + it(`should persist data even without mutation handlers`, async () => { + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + // No onInsert, onUpdate, or onDelete handlers provided + }) + ) + + // Subscribe to trigger sync + const unsubscribe = collection.subscribeChanges(() => {}) + + const todo: Todo = { + id: `1`, + title: `Test Todo Without Handlers`, + completed: false, + createdAt: new Date(), + } + + // Insert without handlers should still persist + const insertTx = collection.insert(todo) + await insertTx.isPersisted.promise + + // Check that it was saved to storage + let storedData = mockStorage.getItem(`todos`) + expect(storedData).toBeDefined() + let parsed = JSON.parse(storedData!) + expect(parsed[`1`].data.title).toBe(`Test Todo Without Handlers`) + + // Update without handlers should still persist + const updateTx = collection.update(`1`, (draft) => { + draft.title = `Updated Without Handlers` + }) + await updateTx.isPersisted.promise + + // Check that update was saved to storage + storedData = mockStorage.getItem(`todos`) + expect(storedData).toBeDefined() + parsed = JSON.parse(storedData!) + expect(parsed[`1`].data.title).toBe(`Updated Without Handlers`) + + // Delete without handlers should still persist + const deleteTx = collection.delete(`1`) + await deleteTx.isPersisted.promise + + // Check that deletion was saved to storage + storedData = mockStorage.getItem(`todos`) + expect(storedData).toBeDefined() + parsed = JSON.parse(storedData!) + expect(parsed[`1`]).toBeUndefined() + + unsubscribe() + }) + + it(`should call mutation handlers when provided and still persist data`, async () => { + const insertSpy = vi.fn().mockResolvedValue({ success: true }) + const updateSpy = vi.fn().mockResolvedValue({ success: true }) + const deleteSpy = vi.fn().mockResolvedValue({ success: true }) + + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + onInsert: insertSpy, + onUpdate: updateSpy, + onDelete: deleteSpy, + }) + ) + + // Subscribe to trigger sync + const unsubscribe = collection.subscribeChanges(() => {}) + + const todo: Todo = { + id: `1`, + title: `Test Todo With Handlers`, + completed: false, + createdAt: new Date(), + } + + // Insert should call handler AND persist + const insertTx = collection.insert(todo) + await insertTx.isPersisted.promise + + expect(insertSpy).toHaveBeenCalledOnce() + let storedData = mockStorage.getItem(`todos`) + expect(storedData).toBeDefined() + let parsed = JSON.parse(storedData!) + expect(parsed[`1`].data.title).toBe(`Test Todo With Handlers`) + + // Update should call handler AND persist + const updateTx = collection.update(`1`, (draft) => { + draft.title = `Updated With Handlers` + }) + await updateTx.isPersisted.promise + + expect(updateSpy).toHaveBeenCalledOnce() + storedData = mockStorage.getItem(`todos`) + expect(storedData).toBeDefined() + parsed = JSON.parse(storedData!) + expect(parsed[`1`].data.title).toBe(`Updated With Handlers`) + + // Delete should call handler AND persist + const deleteTx = collection.delete(`1`) + await deleteTx.isPersisted.promise + + expect(deleteSpy).toHaveBeenCalledOnce() + storedData = mockStorage.getItem(`todos`) + expect(storedData).toBeDefined() + parsed = JSON.parse(storedData!) + expect(parsed[`1`]).toBeUndefined() + + unsubscribe() + }) + + it(`should perform insert operations and update storage`, async () => { + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + onInsert: () => Promise.resolve({ success: true }), + }) + ) + + const todo: Todo = { + id: `1`, + title: `Test Todo`, + completed: false, + createdAt: new Date(), + } + + // When a collection has mutation handlers, calling insert() automatically creates + // a transaction and calls the onInsert handler + const tx = collection.insert(todo) + await tx.isPersisted.promise + + // Check that it was saved to storage with version key structure + const storedData = mockStorage.getItem(`todos`) + expect(storedData).toBeDefined() + + const parsed = JSON.parse(storedData!) + expect(typeof parsed).toBe(`object`) + expect(parsed[`1`]).toBeDefined() + expect(parsed[`1`].versionKey).toBeDefined() + expect(typeof parsed[`1`].versionKey).toBe(`string`) + expect(parsed[`1`].data.id).toBe(`1`) + expect(parsed[`1`].data.title).toBe(`Test Todo`) + }) + + it(`should perform update operations and update storage`, async () => { + // Pre-populate storage + const initialData = { + "1": { + versionKey: `initial-version`, + data: { + id: `1`, + title: `Initial Todo`, + completed: false, + createdAt: new Date(), + }, + }, + } + mockStorage.setItem(`todos`, JSON.stringify(initialData)) + + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + onUpdate: () => Promise.resolve({ success: true }), + }) + ) + + // Subscribe to trigger sync + const unsubscribe = collection.subscribeChanges(() => {}) + + // Update the todo - this automatically creates a transaction and calls onUpdate + const tx = collection.update(`1`, (draft) => { + draft.title = `Updated Todo` + }) + await tx.isPersisted.promise + + // Check that it was updated in storage with a new version key + const storedData = mockStorage.getItem(`todos`) + expect(storedData).toBeDefined() + + const parsed = JSON.parse(storedData!) + expect(parsed[`1`].versionKey).not.toBe(`initial-version`) // Should have new version key + expect(parsed[`1`].data.title).toBe(`Updated Todo`) + + unsubscribe() + }) + + it(`should perform delete operations and update storage`, async () => { + // Pre-populate storage + const initialData = { + "1": { + versionKey: `test-version`, + data: { + id: `1`, + title: `To Delete`, + completed: false, + createdAt: new Date(), + }, + }, + } + mockStorage.setItem(`todos`, JSON.stringify(initialData)) + + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + onDelete: () => Promise.resolve({ success: true }), + }) + ) + + // Subscribe to trigger sync + const unsubscribe = collection.subscribeChanges(() => {}) + + // Delete the todo - this automatically creates a transaction and calls onDelete + const tx = collection.delete(`1`) + await tx.isPersisted.promise + + // Check that it was removed from storage + const storedData = mockStorage.getItem(`todos`) + expect(storedData).toBeDefined() + + const parsed = JSON.parse(storedData!) + expect(parsed[`1`]).toBeUndefined() + + unsubscribe() + }) + }) + + describe(`cross-tab synchronization`, () => { + it(`should detect changes from other tabs using version keys`, () => { + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + }) + ) + + // Subscribe to trigger sync + const unsubscribe = collection.subscribeChanges(() => {}) + + // Simulate data being added from another tab + const newTodoData = { + "1": { + versionKey: `from-other-tab`, + data: { + id: `1`, + title: `From Another Tab`, + completed: false, + createdAt: new Date(), + }, + }, + } + + // Directly update storage (simulating another tab) + mockStorage.setItem(`todos`, JSON.stringify(newTodoData)) + + // Create a mock storage event (avoiding JSDOM constructor issues) + const storageEvent = { + type: `storage`, + key: `todos`, + oldValue: null, + newValue: JSON.stringify(newTodoData), + url: `http://localhost`, + storageArea: mockStorage, + } as unknown as StorageEvent + + // Trigger the storage event + mockStorageEventApi.triggerStorageEvent(storageEvent) + + // The collection should now have the new todo + expect(collection.size).toBe(1) + expect(collection.get(`1`)?.title).toBe(`From Another Tab`) + + unsubscribe() + }) + + it(`should ignore storage events for different keys`, () => { + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + }) + ) + + // Create a mock storage event for different key + const storageEvent = { + type: `storage`, + key: `other-key`, + oldValue: null, + newValue: JSON.stringify({ test: `data` }), + url: `http://localhost`, + storageArea: mockStorage, + } as unknown as StorageEvent + + // Trigger the storage event + mockStorageEventApi.triggerStorageEvent(storageEvent) + + // Collection should remain empty + expect(collection.size).toBe(0) + }) + + it(`should ignore storage events from different storage areas`, () => { + const otherStorage = new MockStorage() + + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + }) + ) + + // Create a mock storage event from different storage area + const storageEvent = { + type: `storage`, + key: `todos`, + oldValue: null, + newValue: JSON.stringify({ test: `data` }), + url: `http://localhost`, + storageArea: otherStorage, + } as unknown as StorageEvent + + // Trigger the storage event + mockStorageEventApi.triggerStorageEvent(storageEvent) + + // Collection should remain empty + expect(collection.size).toBe(0) + }) + }) + + describe(`utility functions`, () => { + it(`should clear storage`, () => { + mockStorage.setItem(`todos`, JSON.stringify({ test: `data` })) + + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + }) + ) + + collection.utils.clearStorage() + + expect(mockStorage.getItem(`todos`)).toBeNull() + }) + + it(`should get storage size`, () => { + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + }) + ) + + expect(collection.utils.getStorageSize()).toBe(0) + + mockStorage.setItem(`todos`, JSON.stringify({ test: `data` })) + + const size = collection.utils.getStorageSize() + expect(size).toBeGreaterThan(0) + }) + }) + + describe(`getSyncMetadata`, () => { + it(`should return correct metadata`, () => { + const options = localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + }) + + const metadata = options.sync.getSyncMetadata?.() + + expect(metadata).toEqual({ + storageKey: `todos`, + storageType: `custom`, + }) + }) + }) + + describe(`version key change detection`, () => { + it(`should detect version key changes for updates`, () => { + // Pre-populate storage + const initialData = { + "1": { + versionKey: `version-1`, + data: { + id: `1`, + title: `Initial`, + completed: false, + createdAt: new Date(), + }, + }, + } + mockStorage.setItem(`todos`, JSON.stringify(initialData)) + + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + }) + ) + + // Subscribe to trigger sync + const unsubscribe = collection.subscribeChanges(() => {}) + + expect(collection.size).toBe(1) + expect(collection.get(`1`)?.title).toBe(`Initial`) + + // Simulate change from another tab with different version key but same data + const updatedData = { + "1": { + versionKey: `version-2`, // Different version key + data: { + id: `1`, + title: `Updated`, // Different title + completed: false, + createdAt: new Date(), + }, + }, + } + + mockStorage.setItem(`todos`, JSON.stringify(updatedData)) + + // Create a mock storage event + const storageEvent = { + type: `storage`, + key: `todos`, + oldValue: JSON.stringify(initialData), + newValue: JSON.stringify(updatedData), + url: `http://localhost`, + storageArea: mockStorage, + } as unknown as StorageEvent + + mockStorageEventApi.triggerStorageEvent(storageEvent) + + // Should detect the change based on version key difference + expect(collection.size).toBe(1) + expect(collection.get(`1`)?.title).toBe(`Updated`) + + unsubscribe() + }) + + it(`should not trigger unnecessary updates for same version key`, () => { + const changesSpy = vi.fn() + + // Pre-populate storage + const initialData = { + "1": { + versionKey: `version-1`, + data: { + id: `1`, + title: `Same`, + completed: false, + createdAt: new Date(), + }, + }, + } + mockStorage.setItem(`todos`, JSON.stringify(initialData)) + + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + }) + ) + + // Subscribe to changes to monitor + collection.subscribeChanges(changesSpy) + + // Simulate "change" from another tab with same version key + const sameData = { + "1": { + versionKey: `version-1`, // Same version key + data: { + id: `1`, + title: `Same`, + completed: false, + createdAt: new Date(), + }, + }, + } + + mockStorage.setItem(`todos`, JSON.stringify(sameData)) + + // Create a mock storage event + const storageEvent = { + type: `storage`, + key: `todos`, + oldValue: JSON.stringify(initialData), + newValue: JSON.stringify(sameData), + url: `http://localhost`, + storageArea: mockStorage, + } as unknown as StorageEvent + + mockStorageEventApi.triggerStorageEvent(storageEvent) + + // Should not trigger any changes since version key is the same + expect(changesSpy).not.toHaveBeenCalled() + }) + }) +})