From d58d2d42f30d585f6ee7da0c344afbe46c5fce64 Mon Sep 17 00:00:00 2001 From: Ravi Atluri Date: Sun, 25 Jan 2026 14:14:23 +0530 Subject: [PATCH] feat(indexeddb-db-collection): Add IndexedDB-backed collection Add a new collection package that persists data to IndexedDB with cross-tab synchronization via BroadcastChannel. Features: - Persistent storage that survives page reloads - Cross-tab sync using BroadcastChannel with version tracking - Utility functions: clearObjectStore, deleteDatabase, exportData, importData - Comprehensive test suite (108 tests) --- packages/indexeddb-db-collection/README.md | 266 +++++ packages/indexeddb-db-collection/package.json | 47 + .../indexeddb-db-collection/src/errors.ts | 82 ++ packages/indexeddb-db-collection/src/index.ts | 34 + .../indexeddb-db-collection/src/indexeddb.ts | 1034 +++++++++++++++++ .../indexeddb-db-collection/src/wrapper.ts | 671 +++++++++++ .../indexeddb-db-collection/tests/.gitkeep | 0 .../tests/cross-tab.test.ts | 930 +++++++++++++++ .../tests/indexeddb.test-d.ts | 479 ++++++++ .../tests/indexeddb.test.ts | 937 +++++++++++++++ .../tests/wrapper.test.ts | 793 +++++++++++++ .../tsconfig.docs.json | 9 + .../indexeddb-db-collection/tsconfig.json | 22 + .../indexeddb-db-collection/vite.config.ts | 24 + pnpm-lock.yaml | 365 +++++- 15 files changed, 5637 insertions(+), 56 deletions(-) create mode 100644 packages/indexeddb-db-collection/README.md create mode 100644 packages/indexeddb-db-collection/package.json create mode 100644 packages/indexeddb-db-collection/src/errors.ts create mode 100644 packages/indexeddb-db-collection/src/index.ts create mode 100644 packages/indexeddb-db-collection/src/indexeddb.ts create mode 100644 packages/indexeddb-db-collection/src/wrapper.ts create mode 100644 packages/indexeddb-db-collection/tests/.gitkeep create mode 100644 packages/indexeddb-db-collection/tests/cross-tab.test.ts create mode 100644 packages/indexeddb-db-collection/tests/indexeddb.test-d.ts create mode 100644 packages/indexeddb-db-collection/tests/indexeddb.test.ts create mode 100644 packages/indexeddb-db-collection/tests/wrapper.test.ts create mode 100644 packages/indexeddb-db-collection/tsconfig.docs.json create mode 100644 packages/indexeddb-db-collection/tsconfig.json create mode 100644 packages/indexeddb-db-collection/vite.config.ts diff --git a/packages/indexeddb-db-collection/README.md b/packages/indexeddb-db-collection/README.md new file mode 100644 index 000000000..98d8ab261 --- /dev/null +++ b/packages/indexeddb-db-collection/README.md @@ -0,0 +1,266 @@ +# @tanstack/indexeddb-db-collection + +**IndexedDB-backed collections for TanStack DB** + +Persistent local storage with automatic cross-tab synchronization for TanStack DB collections. Data persists across browser sessions and stays in sync across all open tabs. + +## Installation + +```bash +npm install @tanstack/indexeddb-db-collection @tanstack/db +``` + +## Quick Start + +```typescript +import { createCollection } from '@tanstack/db' +import { createIndexedDB, indexedDBCollectionOptions } from '@tanstack/indexeddb-db-collection' + +interface Todo { + id: string + text: string + completed: boolean +} + +// Step 1: Create the database with all stores defined upfront +const db = await createIndexedDB({ + name: 'myApp', + version: 1, + stores: ['todos'], +}) + +// Step 2: Create collections using the shared database +const todosCollection = createCollection( + indexedDBCollectionOptions({ + db, + name: 'todos', + getKey: (todo) => todo.id, + }) +) +``` + +## Features + +- **Persistent Storage** - Data survives browser refreshes and sessions +- **Cross-Tab Sync** - Changes automatically propagate to all open tabs via BroadcastChannel +- **Multiple Collections** - Share a single database across multiple collections +- **Schema Validation** - Optional schema support with Standard Schema (Zod, Valibot, etc.) +- **Full TypeScript Support** - Complete type inference for items and keys +- **Utility Functions** - Export, import, clear, and inspect your data + +## Usage + +### Multiple Collections Sharing a Database + +```typescript +import { createCollection } from '@tanstack/db' +import { createIndexedDB, indexedDBCollectionOptions } from '@tanstack/indexeddb-db-collection' + +// Create database with all stores at once +const db = await createIndexedDB({ + name: 'myApp', + version: 1, + stores: ['todos', 'users', 'settings'], +}) + +// Create multiple collections using the same database +const todosCollection = createCollection( + indexedDBCollectionOptions({ + db, + name: 'todos', + getKey: (todo) => todo.id, + }) +) + +const usersCollection = createCollection( + indexedDBCollectionOptions({ + db, + name: 'users', + getKey: (user) => user.id, + }) +) + +const settingsCollection = createCollection( + indexedDBCollectionOptions({ + db, + name: 'settings', + getKey: (setting) => setting.key, + }) +) +``` + +### With Schema Validation + +```typescript +import { z } from 'zod' +import { createCollection } from '@tanstack/db' +import { createIndexedDB, indexedDBCollectionOptions } from '@tanstack/indexeddb-db-collection' + +const todoSchema = z.object({ + id: z.string(), + text: z.string(), + completed: z.boolean(), +}) + +const db = await createIndexedDB({ + name: 'myApp', + version: 1, + stores: ['todos'], +}) + +const todosCollection = createCollection( + indexedDBCollectionOptions({ + db, + name: 'todos', + schema: todoSchema, + getKey: (todo) => todo.id, + }) +) +``` + +### Configuration Options + +#### createIndexedDB Options + +```typescript +const db = await createIndexedDB({ + // Required: Name of the IndexedDB database + name: 'myApp', + + // Required: Schema version (increment when adding/removing stores) + version: 1, + + // Required: Object store names to create + stores: ['todos', 'users'], + + // Optional: Custom IDBFactory for testing + idbFactory: fakeIndexedDB, +}) +``` + +#### indexedDBCollectionOptions + +```typescript +indexedDBCollectionOptions({ + // Required: IndexedDB instance from createIndexedDB() + db, + + // Required: Name of the object store within the database + name: 'todos', + + // Required: Function to extract the unique key from each item + getKey: (item) => item.id, + + // Optional: Schema for validation (Standard Schema compatible) + schema: todoSchema, +}) +``` + +### Utility Functions + +The collection exposes utility functions via `collection.utils`: + +```typescript +// Clear all data from the object store +await todosCollection.utils.clearObjectStore() + +// Delete the entire database +await todosCollection.utils.deleteDatabase() + +// Get database info for debugging +const info = await todosCollection.utils.getDatabaseInfo() +// { name: 'myApp', version: 1, objectStores: ['todos', '_versions'] } + +// Export all data as an array +const backup = await todosCollection.utils.exportData() + +// Import data (clears existing data first) +await todosCollection.utils.importData([ + { id: '1', text: 'Buy milk', completed: false }, + { id: '2', text: 'Walk dog', completed: true }, +]) + +// Accept mutations from a manual transaction +await todosCollection.utils.acceptMutations({ mutations }) +``` + +## Low-Level API + +For advanced use cases, the package also exports low-level IndexedDB utilities: + +```typescript +import { + openDatabase, + executeTransaction, + getAll, + getByKey, + put, + deleteByKey, + clear, + deleteDatabase, +} from '@tanstack/indexeddb-db-collection' + +// Open a database with custom upgrade logic +const db = await openDatabase('myApp', 1, (db, oldVersion) => { + if (oldVersion < 1) { + db.createObjectStore('items', { keyPath: 'id' }) + } +}) + +// Execute operations within a transaction +await executeTransaction(db, 'items', 'readwrite', async (tx, stores) => { + await put(stores.items, { id: '1', value: 'hello' }) + const item = await getByKey(stores.items, '1') +}) +``` + +## Error Handling + +The package provides specific error classes for different failure scenarios: + +```typescript +import { + // Low-level IndexedDB errors + IndexedDBError, + IndexedDBNotSupportedError, + IndexedDBConnectionError, + IndexedDBTransactionError, + IndexedDBOperationError, + // Configuration errors + DatabaseRequiredError, + ObjectStoreNotFoundError, + NameRequiredError, + GetKeyRequiredError, +} from '@tanstack/indexeddb-db-collection' +``` + +## Cross-Tab Synchronization + +Changes made in one tab automatically sync to other tabs via the BroadcastChannel API. Each tab maintains an in-memory version cache to detect changes efficiently. + +```typescript +// Tab 1: Insert a todo +todosCollection.insert({ id: '1', text: 'Buy milk', completed: false }) + +// Tab 2: Automatically receives the update via BroadcastChannel +// No additional code needed - the collection state stays in sync +``` + +## Testing + +When testing, pass a custom `idbFactory` (e.g., from `fake-indexeddb`): + +```typescript +import { indexedDB } from 'fake-indexeddb' + +const db = await createIndexedDB({ + name: 'test-db', + version: 1, + stores: ['items'], + idbFactory: indexedDB, +}) +``` + +## License + +MIT diff --git a/packages/indexeddb-db-collection/package.json b/packages/indexeddb-db-collection/package.json new file mode 100644 index 000000000..7f32a810b --- /dev/null +++ b/packages/indexeddb-db-collection/package.json @@ -0,0 +1,47 @@ +{ + "name": "@tanstack/indexeddb-db-collection", + "version": "0.0.2", + "description": "IndexedDB collection for TanStack DB", + "author": "Ravi Atluri", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/db.git", + "directory": "packages/indexeddb-db-collection" + }, + "homepage": "https://tanstack.com/db", + "keywords": ["indexeddb", "tanstack", "optimistic", "typescript", "offline"], + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "lint": "eslint . --fix", + "test": "vitest run" + }, + "type": "module", + "main": "dist/cjs/index.cjs", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": ["dist", "src"], + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@tanstack/db": "workspace:*" + }, + "devDependencies": { + "@vitest/coverage-istanbul": "^3.2.4", + "fake-indexeddb": "^6.0.0" + } +} diff --git a/packages/indexeddb-db-collection/src/errors.ts b/packages/indexeddb-db-collection/src/errors.ts new file mode 100644 index 000000000..38014b676 --- /dev/null +++ b/packages/indexeddb-db-collection/src/errors.ts @@ -0,0 +1,82 @@ +export class IndexedDBError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = `IndexedDBError` + } +} + +/** + * Thrown when IndexedDB is not available in the environment. + * This can happen in server-side rendering, older browsers, + * or when running in contexts where IndexedDB is disabled. + */ +export class IndexedDBNotSupportedError extends IndexedDBError { + constructor() { + super(`IndexedDB is not supported in this environment`) + this.name = `IndexedDBNotSupportedError` + } +} + +/** + * Thrown when a database connection fails. + * Includes the database name and the underlying error as cause. + */ +export class IndexedDBConnectionError extends IndexedDBError { + databaseName: string + + constructor(databaseName: string, cause?: unknown) { + super(`Failed to connect to IndexedDB database "${databaseName}"`, { + cause, + }) + this.name = `IndexedDBConnectionError` + this.databaseName = databaseName + } +} + +/** + * Thrown when a transaction fails. + * Includes the transaction mode and store names for context. + */ +export class IndexedDBTransactionError extends IndexedDBError { + mode: IDBTransactionMode + storeNames: Array + + constructor( + mode: IDBTransactionMode, + storeNames: Array, + cause?: unknown, + ) { + const storeNamesStr = + storeNames.length === 1 + ? `store "${storeNames[0]}"` + : `stores [${storeNames.map((s) => `"${s}"`).join(`, `)}]` + super(`IndexedDB transaction failed in "${mode}" mode on ${storeNamesStr}`, { + cause, + }) + this.name = `IndexedDBTransactionError` + this.mode = mode + this.storeNames = storeNames + } +} + +/** + * Thrown when a CRUD operation fails. + * Includes the operation type and store name for context. + */ +export class IndexedDBOperationError extends IndexedDBError { + operation: `get` | `put` | `delete` | `clear` + storeName: string + + constructor( + operation: `get` | `put` | `delete` | `clear`, + storeName: string, + cause?: unknown, + ) { + super(`IndexedDB "${operation}" operation failed on store "${storeName}"`, { + cause, + }) + this.name = `IndexedDBOperationError` + this.operation = operation + this.storeName = storeName + } +} diff --git a/packages/indexeddb-db-collection/src/index.ts b/packages/indexeddb-db-collection/src/index.ts new file mode 100644 index 000000000..5eb9fb567 --- /dev/null +++ b/packages/indexeddb-db-collection/src/index.ts @@ -0,0 +1,34 @@ +export { + createIndexedDB, + type CreateIndexedDBOptions, + type IndexedDBInstance, + indexedDBCollectionOptions, + type IndexedDBCollectionConfig, + type IndexedDBCollectionUtils, + type DatabaseInfo, + DatabaseRequiredError, + ObjectStoreNotFoundError, + NameRequiredError, + GetKeyRequiredError, +} from './indexeddb' + +export { + openDatabase, + createObjectStore, + executeTransaction, + getAll, + getAllKeys, + getByKey, + put, + deleteByKey, + clear, + deleteDatabase, +} from './wrapper' + +export { + IndexedDBError, + IndexedDBNotSupportedError, + IndexedDBConnectionError, + IndexedDBTransactionError, + IndexedDBOperationError, +} from './errors' diff --git a/packages/indexeddb-db-collection/src/indexeddb.ts b/packages/indexeddb-db-collection/src/indexeddb.ts new file mode 100644 index 000000000..97bcc5dc4 --- /dev/null +++ b/packages/indexeddb-db-collection/src/indexeddb.ts @@ -0,0 +1,1034 @@ +/** + * IndexedDB Collection for TanStack DB + * + * This module provides a factory function that creates IndexedDB-backed collections + * compatible with TanStack DB's collection system. + */ + +import { + clear, + deleteByKey, + deleteDatabase as deleteIDBDatabase, + executeTransaction, + getAll, + openDatabase, + put, +} from './wrapper' +import type { + BaseCollectionConfig, + ChangeMessageOrDeleteKeyMessage, + CollectionConfig, + DeleteMutationFnParams, + InsertMutationFnParams, + OperationType, + PendingMutation, + SyncConfig, + UpdateMutationFnParams, + UtilsRecord, +} from '@tanstack/db' +import type { StandardSchemaV1 } from '@standard-schema/spec' + +export class DatabaseRequiredError extends Error { + constructor() { + super( + `IndexedDB collection requires a "db" configuration option. ` + + `Create a database instance using createIndexedDB() and pass it to the collection.`, + ) + this.name = `DatabaseRequiredError` + } +} + +/** + * Thrown when the specified object store doesn't exist in the database + */ +export class ObjectStoreNotFoundError extends Error { + constructor( + storeName: string, + databaseName: string, + availableStores: ReadonlyArray, + ) { + super( + `Object store "${storeName}" not found in database "${databaseName}". ` + + `Available stores: [${availableStores.join(', ')}]. ` + + `Add "${storeName}" to the stores array when calling createIndexedDB().`, + ) + this.name = 'ObjectStoreNotFoundError' + } +} + +/** + * Thrown when the name (object store) configuration is missing + */ +export class NameRequiredError extends Error { + constructor() { + super( + `IndexedDB collection requires a "name" configuration option. ` + + `This is the name of the object store within the database.`, + ) + this.name = `NameRequiredError` + } +} + +/** + * Thrown when the getKey function is missing + */ +export class GetKeyRequiredError extends Error { + constructor() { + super( + `IndexedDB collection requires a "getKey" configuration option. ` + + `This function extracts the unique key from each item.`, + ) + this.name = `GetKeyRequiredError` + } +} + +export interface CreateIndexedDBOptions { + /** Database name */ + name: string + /** Schema version (increment when adding/removing stores) */ + version: number + /** Object store names to create */ + stores: ReadonlyArray + /** Custom IDBFactory for testing/mocking */ + idbFactory?: IDBFactory +} + +/** + * A shared IndexedDB database instance. + * Create with createIndexedDB() and pass to collections. + */ +export interface IndexedDBInstance { + /** The underlying IDBDatabase connection */ + readonly db: IDBDatabase + /** Database name */ + readonly name: string + /** Database version */ + readonly version: number + /** List of available object stores (frozen) */ + readonly stores: ReadonlyArray + /** IDBFactory used to create this database (for testing) */ + readonly idbFactory?: IDBFactory + /** Close the database connection */ + close: () => void +} + +type InferSchemaOutput = T extends StandardSchemaV1 + ? StandardSchemaV1.InferOutput extends object + ? StandardSchemaV1.InferOutput + : Record + : Record + +/** + * Schema input type inference helper + */ +type InferSchemaInput = T extends StandardSchemaV1 + ? StandardSchemaV1.InferInput extends object + ? StandardSchemaV1.InferInput + : Record + : Record + +/** + * Configuration options for creating an IndexedDB Collection + */ +export interface IndexedDBCollectionConfig< + T extends object = object, + TSchema extends StandardSchemaV1 = never, + TKey extends string | number = string | number, +> extends BaseCollectionConfig { + /** + * IndexedDB instance from createIndexedDB() + * REQUIRED - must create database before collections + */ + db: IndexedDBInstance + + /** + * Name of the object store within the database + * Must exist in db.stores array + */ + name: string +} + +/** + * Version entry stored in the shared _versions object store + * Key is a tuple: [name, itemKey] + */ +interface VersionEntry { + versionKey: string // UUID for change detection + updatedAt: number // Timestamp for conflict resolution +} + +/** + * Cross-tab message format via BroadcastChannel + */ +interface CrossTabMessage { + type: 'data-changed' | 'database-cleared' + database: string + name: string // Object store name + collectionVersion: string // Quick "anything changed?" check + changedKeys: Array // Keys that changed (for targeted loading) + timestamp: number // For conflict resolution + tabId: string // To avoid processing own messages +} + +/** + * Database information returned by getDatabaseInfo() + */ +export interface DatabaseInfo { + name: string + version: number + objectStores: Array + estimatedSize?: number // via StorageManager API if available +} + +/** + * Utility functions exposed on collection.utils + */ +export interface IndexedDBCollectionUtils< + TItem extends object = Record, + _TKey extends string | number = string | number, + TInsertInput extends object = TItem, +> extends UtilsRecord { + /** + * Removes all data from the object store + * Does NOT delete the database itself + */ + clearObjectStore: () => Promise + + /** + * Deletes the entire database + * Use with caution - removes all object stores and indexes + */ + deleteDatabase: () => Promise + + /** + * Returns database information for debugging + */ + getDatabaseInfo: () => Promise + + /** + * Accepts mutations from a manual transaction and persists to IndexedDB + */ + acceptMutations: (transaction: { + mutations: Array> + }) => Promise + + /** + * Exports all data from the object store as an array + * Useful for backup/debugging + */ + exportData: () => Promise> + + /** + * Imports data into the object store + * Clears existing data first + */ + importData: (items: Array) => Promise +} + +const VERSIONS_STORE_NAME = '_versions' + +/** + * Creates or opens an IndexedDB database with the specified stores. + * Call this once at app startup, then pass the instance to collections. + * + * All stores are created in a single upgrade transaction, avoiding + * version race conditions when multiple collections share a database. + * + * @example + * ```typescript + * const db = await createIndexedDB({ + * name: 'myApp', + * version: 1, + * stores: ['todos', 'users', 'settings'], + * }) + * + * const todosCollection = createCollection( + * indexedDBCollectionOptions({ + * db, + * name: 'todos', + * getKey: (item) => item.id, + * }) + * ) + * ``` + */ +export async function createIndexedDB( + options: CreateIndexedDBOptions, +): Promise { + const { name, version, stores, idbFactory } = options + + // Validate options + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime safety for JS consumers + if (!stores || stores.length === 0) { + throw new Error( + 'createIndexedDB requires at least one store in the stores array.', + ) + } + + const storeSet = new Set(stores) + if (storeSet.size !== stores.length) { + throw new Error( + 'createIndexedDB stores array contains duplicate store names.', + ) + } + + for (const storeName of stores) { + if (!storeName || typeof storeName !== 'string') { + throw new Error( + 'createIndexedDB stores array contains invalid store names. ' + + 'Each store name must be a non-empty string.', + ) + } + } + + const db = await openDatabase( + name, + version, + (database) => { + // Always create _versions store for cross-tab sync + if (!database.objectStoreNames.contains(VERSIONS_STORE_NAME)) { + database.createObjectStore(VERSIONS_STORE_NAME) + } + + // Create each requested store + for (const storeName of stores) { + if (!database.objectStoreNames.contains(storeName)) { + database.createObjectStore(storeName) + } + } + }, + idbFactory, + ) + + // Create frozen stores array for immutability + const frozenStores = Object.freeze([...stores]) + + return Object.freeze({ + db, + name, + version: db.version, + stores: frozenStores, + idbFactory, + close: () => db.close(), + }) +} + +/** + * Creates IndexedDB collection options for use with a standard Collection. + * This provides persistent local storage with cross-tab synchronization. + * + * IMPORTANT: You must first create the database with createIndexedDB() and + * pass the instance to this function. This ensures all stores are created + * upfront in a single upgrade transaction. + * + * @example + * // Step 1: Create database with all stores + * const db = await createIndexedDB({ + * name: 'myApp', + * version: 1, + * stores: ['todos', 'users'], + * }) + * + * // Step 2: Create collections using the shared database + * const todosCollection = createCollection( + * indexedDBCollectionOptions({ + * db, + * name: 'todos', + * schema: todoSchema, + * getKey: (item) => item.id, + * }) + * ) + * + * @example + * // Without schema (explicit type) + * const todosCollection = createCollection( + * indexedDBCollectionOptions({ + * db, + * name: 'todos', + * getKey: (item) => item.id, + * }) + * ) + */ + +// Overload for when schema is provided +export function indexedDBCollectionOptions< + T extends StandardSchemaV1, + TKey extends string | number = string | number, +>( + config: IndexedDBCollectionConfig, T, TKey> & { + schema: T + }, +): CollectionConfig< + InferSchemaOutput, + TKey, + T, + IndexedDBCollectionUtils, TKey, InferSchemaInput> +> & { + schema: T + utils: IndexedDBCollectionUtils< + InferSchemaOutput, + TKey, + InferSchemaInput + > +} + +// Overload for when no schema is provided +export function indexedDBCollectionOptions< + T extends object, + TKey extends string | number = string | number, +>( + config: IndexedDBCollectionConfig & { + schema?: never + }, +): CollectionConfig> & { + schema?: never + utils: IndexedDBCollectionUtils +} + +export function indexedDBCollectionOptions( + config: IndexedDBCollectionConfig>, +): CollectionConfig< + Record, + string | number, + never, + IndexedDBCollectionUtils +> & { + utils: IndexedDBCollectionUtils +} { + const { + db: dbInstance, + name, + getKey, + onInsert, + onUpdate, + onDelete, + ...baseCollectionConfig + } = config + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime safety for JS consumers + if (!dbInstance) { + throw new DatabaseRequiredError() + } + + if (!name) { + throw new NameRequiredError() + } + + // Validate that the store exists in the database (sync check) + if (!dbInstance.db.objectStoreNames.contains(name)) { + const availableStores = Array.from(dbInstance.db.objectStoreNames) + throw new ObjectStoreNotFoundError(name, dbInstance.name, availableStores) + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!getKey) { + throw new GetKeyRequiredError() + } + + const tabId = crypto.randomUUID() + + // In-memory cache of version entries for change detection + const versionCache = new Map() + + // References to sync protocol functions (set during sync initialization) + let syncBegin: ((options?: { immediate?: boolean }) => void) | null = null + let syncWrite: + | ((message: ChangeMessageOrDeleteKeyMessage) => void) + | null = null + let syncCommit: (() => void) | null = null + + // BroadcastChannel for cross-tab sync + let broadcastChannel: BroadcastChannel | null = null + + function getDatabase(): IDBDatabase { + return dbInstance.db + } + + /** + * Broadcasts a cross-tab message + */ + function broadcastChange( + changedKeys: Array, + type: CrossTabMessage['type'] = 'data-changed', + ): void { + if (!broadcastChannel) { + return + } + + const message: CrossTabMessage = { + type, + database: dbInstance.name, + name, + collectionVersion: crypto.randomUUID(), + changedKeys, + timestamp: Date.now(), + tabId, + } + + broadcastChannel.postMessage(message) + } + + /** + * Generates a new version key (UUID) + */ + function generateVersionKey(): string { + return crypto.randomUUID() + } + + /** + * Writes data and version entry atomically + */ + async function writeWithVersion( + key: string | number, + value: Record, + _operation: 'insert' | 'update', + ): Promise { + const db = getDatabase() + const versionKey = generateVersionKey() + + await executeTransaction( + db, + [name, VERSIONS_STORE_NAME], + 'readwrite', + (_, stores) => { + // Write data to data store (out-of-line key) + put(stores[name]!, value, key as IDBValidKey) + + // Write version entry with array key [name, key] + const versionEntry: VersionEntry = { + versionKey, + updatedAt: Date.now(), + } + put(stores[VERSIONS_STORE_NAME]!, versionEntry, [name, key] as IDBValidKey) + }, + ) + + // Update in-memory cache + versionCache.set(key, versionKey) + } + + /** + * Deletes data and version entry atomically + */ + async function deleteWithVersion(key: string | number): Promise { + const db = getDatabase() + + await executeTransaction( + db, + [name, VERSIONS_STORE_NAME], + 'readwrite', + (_, stores) => { + // Delete from data store + deleteByKey(stores[name]!, key as IDBValidKey) + + // Delete version entry + deleteByKey(stores[VERSIONS_STORE_NAME]!, [name, key] as IDBValidKey) + }, + ) + + // Update in-memory cache + versionCache.delete(key) + } + + /** + * Queues a sync confirmation via microtask + */ + function queueSyncConfirmation( + mutations: Array<{ type: OperationType; key: string | number; value: any }>, + ): void { + queueMicrotask(() => { + if (!syncBegin || !syncWrite || !syncCommit) { + return + } + + syncBegin({ immediate: true }) + for (const mutation of mutations) { + syncWrite({ + type: mutation.type, + value: mutation.value, + }) + } + syncCommit() + }) + } + + const internalSync: SyncConfig['sync'] = (params) => { + const { begin, write, commit, markReady, collection } = params + + // Store references for later use in mutation handlers + syncBegin = begin + syncWrite = write + syncCommit = commit + + // Initialize BroadcastChannel for cross-tab sync + const channelName = `tanstack-db:${dbInstance.name}` + try { + broadcastChannel = new BroadcastChannel(channelName) + + // Handle cross-tab messages + broadcastChannel.onmessage = async (event: MessageEvent) => { + const message = event.data + + // Skip our own messages + if (message.tabId === tabId) { + return + } + + // Skip messages for other databases/stores + if (message.database !== dbInstance.name || message.name !== name) { + return + } + + // Handle database clear + if (message.type === 'database-cleared') { + begin() + // Delete all items from collection state + for (const key of versionCache.keys()) { + const item = collection.get(key) + if (item) { + write({ type: 'delete', value: item }) + } + } + versionCache.clear() + commit() + return + } + + // Handle data changes - load changed items + if (message.changedKeys.length > 0) { + try { + const db = getDatabase() + + await executeTransaction( + db, + [name, VERSIONS_STORE_NAME], + 'readonly', + async (_, stores) => { + const changes: Array<{ type: 'insert' | 'update' | 'delete'; key: string | number; value: any }> = [] + + for (const key of message.changedKeys) { + // Load version entry + const versionRequest = stores[VERSIONS_STORE_NAME]!.get([name, key] as IDBValidKey) + const dataRequest = stores[name]!.get(key as IDBValidKey) + + // Wait for both requests + await new Promise((resolve) => { + let completed = 0 + const checkComplete = () => { + completed++ + if (completed === 2) resolve() + } + versionRequest.onsuccess = checkComplete + versionRequest.onerror = checkComplete + dataRequest.onsuccess = checkComplete + dataRequest.onerror = checkComplete + }) + + const versionEntry = versionRequest.result as VersionEntry | undefined + const data = dataRequest.result + + const cachedVersion = versionCache.get(key) + + if (versionEntry && data) { + if (!cachedVersion) { + // New item - insert + changes.push({ type: 'insert', key, value: data }) + versionCache.set(key, versionEntry.versionKey) + } else if (cachedVersion !== versionEntry.versionKey) { + // Changed item - update + changes.push({ type: 'update', key, value: data }) + versionCache.set(key, versionEntry.versionKey) + } + } else if (cachedVersion && !versionEntry) { + // Deleted item + const existingItem = collection.get(key) + if (existingItem) { + changes.push({ type: 'delete', key, value: existingItem }) + } + versionCache.delete(key) + } + } + + // Apply changes via sync protocol + if (changes.length > 0) { + begin() + for (const change of changes) { + write({ + type: change.type, + value: change.value, + }) + } + commit() + } + }, + ) + } catch (error) { + console.error('[IndexedDB Collection] Error processing cross-tab message:', error) + } + } + } + } catch { + // BroadcastChannel not available (e.g., in tests or older browsers) + // Cross-tab sync will be disabled but collection still works + } + + // Perform initial load + ;(async () => { + try { + const db = getDatabase() + + await executeTransaction( + db, + [name, VERSIONS_STORE_NAME], + 'readonly', + async (_, stores) => { + // Load all data + const items = await getAll>(stores[name]!) + + // Load version entries for this collection + // Use a cursor to get all entries with keys starting with [name, ...] + const versionEntries = new Map() + + await new Promise((resolve) => { + const range = IDBKeyRange.bound([name], [name, []]) + const cursorRequest = stores[VERSIONS_STORE_NAME]!.openCursor(range) + + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result + if (cursor) { + const keyArray = cursor.key as [string, string | number] + const itemKey = keyArray[1] + const entry = cursor.value as VersionEntry + versionEntries.set(itemKey, entry.versionKey) + cursor.continue() + } else { + resolve() + } + } + + cursorRequest.onerror = () => { + resolve() + } + }) + + // Build version cache + for (const [itemKey, versionKey] of versionEntries) { + versionCache.set(itemKey, versionKey) + } + + // Write items to collection via sync protocol + if (items.length > 0) { + begin() + for (const item of items) { + write({ type: 'insert', value: item }) + } + commit() + } + + // Mark collection as ready + markReady() + }, + ) + } catch (error) { + console.error('[IndexedDB Collection] Error during initial load:', error) + // Mark ready even on error to avoid blocking + markReady() + } + })() + + // Return cleanup function + return { + cleanup: () => { + if (broadcastChannel) { + broadcastChannel.close() + broadcastChannel = null + } + // Database connection managed by caller via dbInstance.close() + // We don't close it here as other collections may share the same db + syncBegin = null + syncWrite = null + syncCommit = null + versionCache.clear() + }, + } + } + + const wrappedOnInsert = async ( + params: InsertMutationFnParams, + ): Promise => { + const { transaction } = params + const mutations = transaction.mutations + + // Persist to IndexedDB + for (const mutation of mutations) { + const key = getKey(mutation.modified) + await writeWithVersion(key, mutation.modified, 'insert') + } + + // Queue sync confirmation + const syncMutations = mutations.map((m) => ({ + type: 'insert' as const, + key: getKey(m.modified), + value: m.modified, + })) + queueSyncConfirmation(syncMutations) + + // Broadcast to other tabs + const changedKeys = mutations.map((m) => getKey(m.modified)) + broadcastChange(changedKeys) + + // Call user's onInsert handler if provided + if (onInsert) { + return onInsert(params) + } + } + + const wrappedOnUpdate = async ( + params: UpdateMutationFnParams, + ): Promise => { + const { transaction } = params + const mutations = transaction.mutations + + // Persist to IndexedDB + for (const mutation of mutations) { + const key = mutation.key + await writeWithVersion(key, mutation.modified, 'update') + } + + // Queue sync confirmation + const syncMutations = mutations.map((m) => ({ + type: 'update' as const, + key: m.key, + value: m.modified, + })) + queueSyncConfirmation(syncMutations) + + // Broadcast to other tabs + const changedKeys = mutations.map((m) => m.key) + broadcastChange(changedKeys) + + // Call user's onUpdate handler if provided + if (onUpdate) { + return onUpdate(params) + } + } + + const wrappedOnDelete = async ( + params: DeleteMutationFnParams, + ): Promise => { + const { transaction } = params + const mutations = transaction.mutations + + // Persist to IndexedDB + for (const mutation of mutations) { + const key = mutation.key + await deleteWithVersion(key) + } + + // Queue sync confirmation + const syncMutations = mutations.map((m) => ({ + type: 'delete' as const, + key: m.key, + value: m.original, + })) + queueSyncConfirmation(syncMutations) + + // Broadcast to other tabs + const changedKeys = mutations.map((m) => m.key) + broadcastChange(changedKeys) + + // Call user's onDelete handler if provided + if (onDelete) { + return onDelete(params) + } + } + + const clearObjectStore = async (): Promise => { + const db = getDatabase() + + await executeTransaction( + db, + [name, VERSIONS_STORE_NAME], + 'readwrite', + async (_, stores) => { + // Clear data store + await clear(stores[name]!) + + // Clear version entries for this collection + // Use a cursor to delete entries with keys starting with [name, ...] + await new Promise((resolve) => { + const range = IDBKeyRange.bound([name], [name, []]) + const cursorRequest = stores[VERSIONS_STORE_NAME]!.openCursor(range) + + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result + if (cursor) { + cursor.delete() + cursor.continue() + } else { + resolve() + } + } + + cursorRequest.onerror = () => { + resolve() + } + }) + }, + ) + + // Clear in-memory cache + versionCache.clear() + + // Broadcast clear to other tabs + broadcastChange([], 'database-cleared') + + // Update collection state + if (syncBegin && syncCommit) { + syncBegin({ immediate: true }) + // The sync protocol will handle the state update + syncCommit() + } + } + + const deleteDatabaseUtil = async (): Promise => { + // Close the database connection + dbInstance.close() + + await deleteIDBDatabase(dbInstance.name, dbInstance.idbFactory) + + // Clear in-memory cache + versionCache.clear() + + // Broadcast to other tabs + broadcastChange([], 'database-cleared') + } + + const getDatabaseInfo = async (): Promise => { + const db = getDatabase() + + const info: DatabaseInfo = { + name: db.name, + version: db.version, + objectStores: Array.from(db.objectStoreNames), + } + + // Try to get estimated size via StorageManager API + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime check needed + if (typeof navigator !== 'undefined' && navigator.storage) { + try { + const estimate = await navigator.storage.estimate() + info.estimatedSize = estimate.usage + } catch { + // StorageManager not available or estimate failed + } + } + + return info + } + + const acceptMutations = async (transaction: { + mutations: Array>> + }): Promise => { + const { mutations } = transaction + + for (const mutation of mutations) { + const key = mutation.key + + if (mutation.type === 'insert' || mutation.type === 'update') { + await writeWithVersion(key, mutation.modified, mutation.type) + } else { + await deleteWithVersion(key) + } + } + + // Queue sync confirmation + const syncMutations = mutations.map((m) => ({ + type: m.type as 'insert' | 'update' | 'delete', + key: m.key, + value: m.type === 'delete' ? m.original : m.modified, + })) + queueSyncConfirmation(syncMutations) + + // Broadcast to other tabs + const changedKeys = mutations.map((m) => m.key) + broadcastChange(changedKeys) + } + + const exportData = async (): Promise>> => { + const db = getDatabase() + + return executeTransaction(db, name, 'readonly', async (_, stores) => { + return getAll>(stores[name]!) + }) + } + + const importData = async ( + items: Array>, + ): Promise => { + // Clear existing data first + await clearObjectStore() + + const db = getDatabase() + + await executeTransaction( + db, + [name, VERSIONS_STORE_NAME], + 'readwrite', + (_, stores) => { + for (const item of items) { + const key = getKey(item) + const versionKey = generateVersionKey() + + // Write data + put(stores[name]!, item, key as IDBValidKey) + + // Write version entry + const versionEntry: VersionEntry = { + versionKey, + updatedAt: Date.now(), + } + put(stores[VERSIONS_STORE_NAME]!, versionEntry, [name, key] as IDBValidKey) + + // Update cache + versionCache.set(key, versionKey) + } + }, + ) + + // Queue sync confirmation for all imported items + const syncMutations = items.map((item) => ({ + type: 'insert' as const, + key: getKey(item), + value: item, + })) + queueSyncConfirmation(syncMutations) + + // Broadcast to other tabs + const changedKeys = items.map((item) => getKey(item)) + broadcastChange(changedKeys) + } + + const utils: IndexedDBCollectionUtils = { + clearObjectStore, + deleteDatabase: deleteDatabaseUtil, + getDatabaseInfo, + acceptMutations, + exportData, + importData, + } + + // Generate default ID if not provided + const collectionId = + baseCollectionConfig.id ?? `indexeddb-collection:${dbInstance.name}:${name}` + + return { + ...baseCollectionConfig, + id: collectionId, + getKey, + sync: { sync: internalSync }, + onInsert: wrappedOnInsert, + onUpdate: wrappedOnUpdate, + onDelete: wrappedOnDelete, + utils, + } +} diff --git a/packages/indexeddb-db-collection/src/wrapper.ts b/packages/indexeddb-db-collection/src/wrapper.ts new file mode 100644 index 000000000..e8101e8a1 --- /dev/null +++ b/packages/indexeddb-db-collection/src/wrapper.ts @@ -0,0 +1,671 @@ +/** + * IndexedDB Database Wrapper + * + * This module provides promise-based utilities for working with IndexedDB. + * All functions return promises and wrap IndexedDB errors with descriptive messages. + */ + +/** + * Gets the IndexedDB factory, with cross-environment support. + * @param idbFactory - Optional custom IDBFactory for testing + * @returns The IDBFactory to use + * @throws Error if IndexedDB is not available + */ +function getIDBFactory(idbFactory?: IDBFactory): IDBFactory { + if (idbFactory) { + return idbFactory + } + + // Try window.indexedDB first (browser environment) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime check needed + if (typeof window !== 'undefined' && window.indexedDB) { + return window.indexedDB + } + + // Try globalThis.indexedDB (modern environments, including Node.js with polyfill) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime check needed + if (typeof globalThis !== 'undefined' && globalThis.indexedDB) { + return globalThis.indexedDB + } + + throw new Error( + 'IndexedDB is not available in this environment. ' + + 'Ensure you are running in a browser or provide a custom IDBFactory for testing.', + ) +} + +/** + * Opens an IndexedDB database with the specified name and version. + * + * @param name - The name of the database to open + * @param version - The version number of the database schema + * @param onUpgrade - Optional callback that runs during the onupgradeneeded event. + * Use this to create object stores and indexes. + * @param idbFactory - Optional IDBFactory for testing/mocking (defaults to window.indexedDB or globalThis.indexedDB) + * @returns A promise that resolves to the IDBDatabase instance + * + * @example + * ```typescript + * const db = await openDatabase('myApp', 1, (db, oldVersion, newVersion, transaction) => { + * if (oldVersion < 1) { + * db.createObjectStore('todos', { keyPath: 'id' }) + * } + * }) + * ``` + */ +export function openDatabase( + name: string, + version: number, + onUpgrade?: ( + db: IDBDatabase, + oldVersion: number, + newVersion: number, + transaction: IDBTransaction, + ) => void, + idbFactory?: IDBFactory, +): Promise { + return new Promise((resolve, reject) => { + let factory: IDBFactory + try { + factory = getIDBFactory(idbFactory) + } catch (error) { + reject(error) + return + } + + let request: IDBOpenDBRequest + try { + request = factory.open(name, version) + } catch (error) { + reject( + new Error( + `Failed to open IndexedDB database "${name}": ${error instanceof Error ? error.message : String(error)}`, + ), + ) + return + } + + request.onupgradeneeded = (event) => { + const db = request.result + const transaction = request.transaction + if (onUpgrade && transaction) { + try { + onUpgrade( + db, + event.oldVersion, + event.newVersion ?? version, + transaction, + ) + } catch (error) { + // If the upgrade callback throws, abort the transaction + transaction.abort() + reject( + new Error( + `Database upgrade failed for "${name}": ${error instanceof Error ? error.message : String(error)}`, + ), + ) + } + } + } + + request.onsuccess = () => { + resolve(request.result) + } + + request.onerror = () => { + const errorMessage = request.error?.message || 'Unknown error' + reject( + new Error(`Failed to open IndexedDB database "${name}": ${errorMessage}`), + ) + } + + request.onblocked = () => { + reject( + new Error( + `Opening IndexedDB database "${name}" was blocked. ` + + 'Close other tabs/connections to this database and try again.', + ), + ) + } + }) +} + +/** + * Creates an object store during a database upgrade. + * + * This function must be called within an onupgradeneeded callback + * (i.e., within a versionchange transaction). Calling it outside of + * an upgrade context will throw an error. + * + * @param db - The IDBDatabase instance + * @param storeName - The name of the object store to create + * @param options - Optional configuration for the object store (keyPath, autoIncrement) + * @returns The created IDBObjectStore + * @throws Error if not called during a version change transaction + * + * @example + * ```typescript + * const db = await openDatabase('myApp', 1, (db) => { + * createObjectStore(db, 'todos', { keyPath: 'id' }) + * createObjectStore(db, 'users', { keyPath: 'id', autoIncrement: true }) + * }) + * ``` + */ +export function createObjectStore( + db: IDBDatabase, + storeName: string, + options?: IDBObjectStoreParameters, +): IDBObjectStore { + try { + return db.createObjectStore(storeName, options) + } catch (error) { + // Check if this is being called outside of a version change transaction + if ( + error instanceof DOMException && + error.name === 'InvalidStateError' + ) { + throw new Error( + `Cannot create object store "${storeName}": This operation is only allowed during a database upgrade. ` + + 'Ensure you are calling createObjectStore within the onUpgrade callback of openDatabase.', + ) + } + + // Check if the object store already exists + if ( + error instanceof DOMException && + error.name === 'ConstraintError' + ) { + throw new Error( + `Object store "${storeName}" already exists in the database. ` + + 'Check the database version and only create stores when needed.', + ) + } + + throw new Error( + `Failed to create object store "${storeName}": ${error instanceof Error ? error.message : String(error)}`, + ) + } +} + +/** + * Executes a callback within an IndexedDB transaction. + * + * This function handles transaction lifecycle automatically: + * - Creates the transaction with the specified mode + * - Provides the transaction and object stores to the callback + * - Waits for the transaction to complete (or abort) + * - Returns the callback's result or rejects with an error + * + * @template T - The return type of the callback + * @param db - The IDBDatabase instance + * @param storeNames - A single store name or array of store names to include in the transaction + * @param mode - The transaction mode ('readonly', 'readwrite', or 'readwriteflush') + * @param callback - A function that performs operations within the transaction. + * Receives the transaction and a record of object stores keyed by name. + * Can be sync or async. + * @returns A promise that resolves to the callback's return value when the transaction completes + * + * @example + * ```typescript + * // Single store + * const result = await executeTransaction(db, 'todos', 'readwrite', (tx, stores) => { + * stores.todos.put({ id: 1, text: 'Buy milk' }) + * return 'done' + * }) + * + * // Multiple stores + * await executeTransaction(db, ['todos', 'users'], 'readwrite', (tx, stores) => { + * stores.todos.put({ id: 1, text: 'Task' }) + * stores.users.put({ id: 1, name: 'Alice' }) + * }) + * ``` + */ +export function executeTransaction( + db: IDBDatabase, + storeNames: string | Array, + mode: IDBTransactionMode, + callback: ( + transaction: IDBTransaction, + stores: Record, + ) => T | Promise, +): Promise { + return new Promise((resolve, reject) => { + const storeNamesArray = Array.isArray(storeNames) ? storeNames : [storeNames] + let transaction: IDBTransaction + + try { + transaction = db.transaction(storeNamesArray, mode) + } catch (error) { + reject( + new Error( + `Failed to create transaction for stores [${storeNamesArray.join(', ')}]: ${error instanceof Error ? error.message : String(error)}`, + ), + ) + return + } + + // Build the stores record + const stores: Record = {} + for (const storeName of storeNamesArray) { + try { + stores[storeName] = transaction.objectStore(storeName) + } catch { + reject( + new Error( + `Object store "${storeName}" not found in the database. ` + + 'Ensure the store was created during the database upgrade.', + ), + ) + return + } + } + + let callbackResult: T + let callbackError: Error | undefined + + // Handle transaction completion + transaction.oncomplete = () => { + if (callbackError) { + reject(callbackError) + } else { + resolve(callbackResult) + } + } + + transaction.onerror = () => { + const errorMessage = transaction.error?.message || 'Unknown error' + reject(new Error(`Transaction failed: ${errorMessage}`)) + } + + transaction.onabort = () => { + const errorMessage = transaction.error?.message || 'Transaction was aborted' + reject(new Error(`Transaction aborted: ${errorMessage}`)) + } + + // Execute the callback + try { + const result = callback(transaction, stores) + + // Handle async callbacks + if (result instanceof Promise) { + result + .then((value) => { + callbackResult = value + }) + .catch((error) => { + callbackError = error instanceof Error ? error : new Error(String(error)) + // Abort the transaction on callback error + try { + transaction.abort() + } catch { + // Transaction may already be finished + } + }) + } else { + callbackResult = result + } + } catch (error) { + callbackError = error instanceof Error ? error : new Error(String(error)) + // Abort the transaction on callback error + try { + transaction.abort() + } catch { + // Transaction may already be finished + } + } + }) +} + +/** + * Retrieves all items from an object store. + * + * Uses the native `getAll()` method for efficient bulk retrieval. + * + * @template T - The type of items in the object store + * @param objectStore - The IDBObjectStore to read from + * @returns A promise that resolves to an array of all items in the store + * + * @example + * ```typescript + * await executeTransaction(db, 'todos', 'readonly', async (tx, stores) => { + * const allTodos = await getAll(stores.todos) + * console.log('All todos:', allTodos) + * }) + * ``` + */ +export function getAll(objectStore: IDBObjectStore): Promise> { + return new Promise((resolve, reject) => { + let request: IDBRequest> + try { + request = objectStore.getAll() + } catch (error) { + reject( + new Error( + `Failed to get all items from object store "${objectStore.name}": ${error instanceof Error ? error.message : String(error)}`, + ), + ) + return + } + + request.onsuccess = () => { + resolve(request.result) + } + + request.onerror = () => { + const errorMessage = request.error?.message || 'Unknown error' + reject( + new Error( + `Failed to get all items from object store "${objectStore.name}": ${errorMessage}`, + ), + ) + } + }) +} + +/** + * Retrieves all keys from an object store. + * + * Uses the native `getAllKeys()` method for efficient bulk key retrieval. + * + * @param objectStore - The IDBObjectStore to read keys from + * @returns A promise that resolves to an array of all keys in the store + * + * @example + * ```typescript + * await executeTransaction(db, 'todos', 'readonly', async (tx, stores) => { + * const allKeys = await getAllKeys(stores.todos) + * console.log('All keys:', allKeys) + * }) + * ``` + */ +export function getAllKeys(objectStore: IDBObjectStore): Promise> { + return new Promise((resolve, reject) => { + let request: IDBRequest> + try { + request = objectStore.getAllKeys() + } catch (error) { + reject( + new Error( + `Failed to get all keys from object store "${objectStore.name}": ${error instanceof Error ? error.message : String(error)}`, + ), + ) + return + } + + request.onsuccess = () => { + resolve(request.result) + } + + request.onerror = () => { + const errorMessage = request.error?.message || 'Unknown error' + reject( + new Error( + `Failed to get all keys from object store "${objectStore.name}": ${errorMessage}`, + ), + ) + } + }) +} + +/** + * Retrieves a single item by its key from an object store. + * + * @template T - The type of the item + * @param objectStore - The IDBObjectStore to read from + * @param key - The key of the item to retrieve + * @returns A promise that resolves to the item, or undefined if not found + * + * @example + * ```typescript + * await executeTransaction(db, 'todos', 'readonly', async (tx, stores) => { + * const todo = await getByKey(stores.todos, 1) + * if (todo) { + * console.log('Found todo:', todo) + * } + * }) + * ``` + */ +export function getByKey( + objectStore: IDBObjectStore, + key: IDBValidKey, +): Promise { + return new Promise((resolve, reject) => { + let request: IDBRequest + try { + request = objectStore.get(key) + } catch (error) { + reject( + new Error( + `Failed to get item with key "${String(key)}" from object store "${objectStore.name}": ${error instanceof Error ? error.message : String(error)}`, + ), + ) + return + } + + request.onsuccess = () => { + resolve(request.result) + } + + request.onerror = () => { + const errorMessage = request.error?.message || 'Unknown error' + reject( + new Error( + `Failed to get item with key "${String(key)}" from object store "${objectStore.name}": ${errorMessage}`, + ), + ) + } + }) +} + +/** + * Writes an item to an object store using upsert semantics. + * + * If an item with the same key exists, it will be replaced. + * If no item with the key exists, a new one will be created. + * + * @template T - The type of the item + * @param objectStore - The IDBObjectStore to write to + * @param value - The item to write + * @param key - Optional key for the item. Required if the object store doesn't have a keyPath. + * @returns A promise that resolves to the key of the written item + * + * @example + * ```typescript + * // With keyPath (key extracted from value) + * await executeTransaction(db, 'todos', 'readwrite', async (tx, stores) => { + * const key = await put(stores.todos, { id: 1, text: 'Buy milk' }) + * console.log('Wrote item with key:', key) + * }) + * + * // Without keyPath (explicit key) + * await executeTransaction(db, 'items', 'readwrite', async (tx, stores) => { + * const key = await put(stores.items, { text: 'Some data' }, 'myKey') + * console.log('Wrote item with key:', key) + * }) + * ``` + */ +export function put( + objectStore: IDBObjectStore, + value: T, + key?: IDBValidKey, +): Promise { + return new Promise((resolve, reject) => { + let request: IDBRequest + try { + // Use the key parameter if provided, otherwise rely on keyPath + request = key !== undefined ? objectStore.put(value, key) : objectStore.put(value) + } catch (error) { + reject( + new Error( + `Failed to write item to object store "${objectStore.name}": ${error instanceof Error ? error.message : String(error)}`, + ), + ) + return + } + + request.onsuccess = () => { + resolve(request.result) + } + + request.onerror = () => { + const errorMessage = request.error?.message || 'Unknown error' + reject( + new Error( + `Failed to write item to object store "${objectStore.name}": ${errorMessage}`, + ), + ) + } + }) +} + +/** + * Deletes an item by its key from an object store. + * + * @param objectStore - The IDBObjectStore to delete from + * @param key - The key of the item to delete + * @returns A promise that resolves when the item is deleted + * + * @example + * ```typescript + * await executeTransaction(db, 'todos', 'readwrite', async (tx, stores) => { + * await deleteByKey(stores.todos, 1) + * console.log('Todo deleted') + * }) + * ``` + */ +export function deleteByKey( + objectStore: IDBObjectStore, + key: IDBValidKey, +): Promise { + return new Promise((resolve, reject) => { + let request: IDBRequest + try { + request = objectStore.delete(key) + } catch (error) { + reject( + new Error( + `Failed to delete item with key "${String(key)}" from object store "${objectStore.name}": ${error instanceof Error ? error.message : String(error)}`, + ), + ) + return + } + + request.onsuccess = () => { + resolve() + } + + request.onerror = () => { + const errorMessage = request.error?.message || 'Unknown error' + reject( + new Error( + `Failed to delete item with key "${String(key)}" from object store "${objectStore.name}": ${errorMessage}`, + ), + ) + } + }) +} + +/** + * Removes all items from an object store. + * + * @param objectStore - The IDBObjectStore to clear + * @returns A promise that resolves when all items are removed + * + * @example + * ```typescript + * await executeTransaction(db, 'todos', 'readwrite', async (tx, stores) => { + * await clear(stores.todos) + * console.log('All todos cleared') + * }) + * ``` + */ +export function clear(objectStore: IDBObjectStore): Promise { + return new Promise((resolve, reject) => { + let request: IDBRequest + try { + request = objectStore.clear() + } catch (error) { + reject( + new Error( + `Failed to clear object store "${objectStore.name}": ${error instanceof Error ? error.message : String(error)}`, + ), + ) + return + } + + request.onsuccess = () => { + resolve() + } + + request.onerror = () => { + const errorMessage = request.error?.message || 'Unknown error' + reject( + new Error( + `Failed to clear object store "${objectStore.name}": ${errorMessage}`, + ), + ) + } + }) +} + +/** + * Deletes an entire IndexedDB database. + * + * Use with caution - this removes the database and all of its object stores and data. + * + * @param name - The name of the database to delete + * @param idbFactory - Optional IDBFactory for testing/mocking + * @returns A promise that resolves when the database is deleted + * + * @example + * ```typescript + * await deleteDatabase('myApp') + * console.log('Database deleted') + * ``` + */ +export function deleteDatabase( + name: string, + idbFactory?: IDBFactory, +): Promise { + return new Promise((resolve, reject) => { + let factory: IDBFactory + try { + factory = getIDBFactory(idbFactory) + } catch (error) { + reject(error) + return + } + + let request: IDBOpenDBRequest + try { + request = factory.deleteDatabase(name) + } catch (error) { + reject( + new Error( + `Failed to delete IndexedDB database "${name}": ${error instanceof Error ? error.message : String(error)}`, + ), + ) + return + } + + request.onsuccess = () => { + resolve() + } + + request.onerror = () => { + const errorMessage = request.error?.message || 'Unknown error' + reject( + new Error(`Failed to delete IndexedDB database "${name}": ${errorMessage}`), + ) + } + + request.onblocked = () => { + reject( + new Error( + `Deleting IndexedDB database "${name}" was blocked. ` + + 'Close all connections to this database and try again.', + ), + ) + } + }) +} diff --git a/packages/indexeddb-db-collection/tests/.gitkeep b/packages/indexeddb-db-collection/tests/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/indexeddb-db-collection/tests/cross-tab.test.ts b/packages/indexeddb-db-collection/tests/cross-tab.test.ts new file mode 100644 index 000000000..9b362d4a5 --- /dev/null +++ b/packages/indexeddb-db-collection/tests/cross-tab.test.ts @@ -0,0 +1,930 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { indexedDB } from 'fake-indexeddb' +import { createCollection } from '@tanstack/db' +import { + + createIndexedDB, + deleteDatabase, + indexedDBCollectionOptions +} from '../src' +import type {IndexedDBInstance} from '../src'; + +class MockBroadcastChannel { + static channels = new Map>() + name: string + onmessage: ((ev: MessageEvent) => void) | null = null + + constructor(name: string) { + this.name = name + if (!MockBroadcastChannel.channels.has(name)) { + MockBroadcastChannel.channels.set(name, new Set()) + } + MockBroadcastChannel.channels.get(name)!.add(this) + } + + postMessage(data: unknown) { + const channels = MockBroadcastChannel.channels.get(this.name) + if (channels) { + channels.forEach((channel) => { + if (channel !== this && channel.onmessage) { + // Use queueMicrotask to simulate async delivery + queueMicrotask(() => { + channel.onmessage!(new MessageEvent('message', { data })) + }) + } + }) + } + } + + close() { + MockBroadcastChannel.channels.get(this.name)?.delete(this) + } + + static reset() { + this.channels.clear() + } +} + +// Install mock globally before tests +globalThis.BroadcastChannel = MockBroadcastChannel as unknown as typeof BroadcastChannel + +interface TestItem { + id: number + name: string + value?: number +} + +// Helper to flush promises and microtasks +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 50)) + +// Mock localStorage (required by @tanstack/db proxy.ts) +const mockLocalStorage = { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + length: 0, + key: vi.fn(() => null), +} + +// Helper to create database with stores +const createTestDB = async ( + dbName: string, + stores: Array, +): Promise => { + return createIndexedDB({ + name: dbName, + version: 1, + stores, + idbFactory: indexedDB, + }) +} + +describe(`Cross-Tab Synchronization`, () => { + // Use unique database names per test to avoid conflicts + let dbNameCounter = 0 + const getUniqueDbName = () => `cross-tab-test-${Date.now()}-${dbNameCounter++}` + + beforeEach(() => { + // Mock localStorage globally + vi.stubGlobal(`localStorage`, mockLocalStorage) + // Reset MockBroadcastChannel state + MockBroadcastChannel.reset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + MockBroadcastChannel.reset() + }) + + describe(`Insert propagation`, () => { + let dbName: string + let db: IndexedDBInstance + + beforeEach(async () => { + dbName = getUniqueDbName() + db = await createTestDB(dbName, ['items']) + }) + + afterEach(async () => { + db.close() + await deleteDatabase(dbName, indexedDB) + }) + + it(`should propagate insert from collection1 to collection2`, async () => { + // Create two collection instances sharing the same database/object store + const collection1 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + const collection2 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection1.preload() + await collection2.preload() + + // Insert item in collection1 + await collection1.insert({ id: 1, name: `Test Item`, value: 100 }) + await flushPromises() + + // Verify item exists in collection1 + expect(collection1.has(1)).toBe(true) + expect(collection1.get(1)).toEqual({ id: 1, name: `Test Item`, value: 100 }) + + // Wait for cross-tab propagation + await flushPromises() + + // Verify item appears in collection2 + expect(collection2.has(1)).toBe(true) + expect(collection2.get(1)).toEqual({ id: 1, name: `Test Item`, value: 100 }) + }) + + it(`should have same version entry in _versions store for both collections`, async () => { + const collection1 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + const collection2 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection1.preload() + await collection2.preload() + + // Insert item in collection1 + await collection1.insert({ id: 1, name: `Versioned Item` }) + await flushPromises() + + // Wait for propagation + await flushPromises() + + // Both collections should report the same database info + const info1 = await collection1.utils.getDatabaseInfo() + const info2 = await collection2.utils.getDatabaseInfo() + + expect(info1.objectStores).toContain(`_versions`) + expect(info2.objectStores).toContain(`_versions`) + + // Both should have the item + expect(collection1.get(1)).toEqual({ id: 1, name: `Versioned Item` }) + expect(collection2.get(1)).toEqual({ id: 1, name: `Versioned Item` }) + }) + }) + + describe(`Update propagation`, () => { + let dbName: string + let db: IndexedDBInstance + + beforeEach(async () => { + dbName = getUniqueDbName() + db = await createTestDB(dbName, ['items']) + }) + + afterEach(async () => { + db.close() + await deleteDatabase(dbName, indexedDB) + }) + + it(`should propagate update from collection1 to collection2`, async () => { + const collection1 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + const collection2 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection1.preload() + await collection2.preload() + + // Insert initial item via collection1 + await collection1.insert({ id: 1, name: `Original`, value: 50 }) + await flushPromises() + + // Wait for initial propagation + await flushPromises() + + // Verify both collections have the item + expect(collection1.get(1)?.name).toBe(`Original`) + expect(collection2.get(1)?.name).toBe(`Original`) + + // Update item in collection1 + await collection1.update(1, (draft) => { + draft.name = `Updated` + draft.value = 100 + }) + await flushPromises() + + // Wait for update propagation + await flushPromises() + + // Verify update reflects in collection2 + expect(collection2.get(1)).toEqual({ id: 1, name: `Updated`, value: 100 }) + }) + + it(`should have new versionKey in both _versions entries after update`, async () => { + const collection1 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + const collection2 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection1.preload() + await collection2.preload() + + // Insert and get initial state + await collection1.insert({ id: 1, name: `Initial` }) + await flushPromises() + await flushPromises() + + const exportBefore = await collection1.utils.exportData() + expect(exportBefore.length).toBe(1) + + // Update item + await collection1.update(1, (draft) => { + draft.name = `Modified` + }) + await flushPromises() + await flushPromises() + + // Verify update persisted + const exportAfter = await collection1.utils.exportData() + expect(exportAfter.length).toBe(1) + expect(exportAfter[0]).toEqual({ id: 1, name: `Modified` }) + + // Collection2 should also see the update + expect(collection2.get(1)?.name).toBe(`Modified`) + }) + }) + + describe(`Delete propagation`, () => { + let dbName: string + let db: IndexedDBInstance + + beforeEach(async () => { + dbName = getUniqueDbName() + db = await createTestDB(dbName, ['items']) + }) + + afterEach(async () => { + db.close() + await deleteDatabase(dbName, indexedDB) + }) + + it(`should propagate delete from collection1 to collection2`, async () => { + const collection1 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + const collection2 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection1.preload() + await collection2.preload() + + // Insert initial item + await collection1.insert({ id: 1, name: `To Be Deleted` }) + await flushPromises() + await flushPromises() + + // Verify both have the item + expect(collection1.has(1)).toBe(true) + expect(collection2.has(1)).toBe(true) + + // Delete item in collection1 + await collection1.delete(1) + await flushPromises() + + // Wait for delete propagation + await flushPromises() + + // Verify item disappears from collection2 + expect(collection2.has(1)).toBe(false) + expect(collection2.get(1)).toBeUndefined() + }) + + it(`should remove item from IndexedDB when deleted`, async () => { + const collection1 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection1.preload() + + // Insert multiple items + await collection1.insert({ id: 1, name: `Item 1` }) + await collection1.insert({ id: 2, name: `Item 2` }) + await collection1.insert({ id: 3, name: `Item 3` }) + await flushPromises() + + // Delete middle item + await collection1.delete(2) + await flushPromises() + + // Verify only remaining items are in IndexedDB + const exported = await collection1.utils.exportData() + expect(exported.length).toBe(2) + expect(exported.some((item) => item.id === 1)).toBe(true) + expect(exported.some((item) => item.id === 2)).toBe(false) + expect(exported.some((item) => item.id === 3)).toBe(true) + }) + }) + + describe(`Concurrent inserts`, () => { + let dbName: string + let db: IndexedDBInstance + + beforeEach(async () => { + dbName = getUniqueDbName() + db = await createTestDB(dbName, ['items']) + }) + + afterEach(async () => { + db.close() + await deleteDatabase(dbName, indexedDB) + }) + + it(`should handle concurrent inserts from both collections`, async () => { + const collection1 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + const collection2 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection1.preload() + await collection2.preload() + + // Insert different items simultaneously in both collections + await Promise.all([ + collection1.insert({ id: 1, name: `From Collection 1` }), + collection2.insert({ id: 2, name: `From Collection 2` }), + ]) + await flushPromises() + + // Wait for cross-tab propagation + await flushPromises() + await flushPromises() + + // Both collections should eventually contain both items + expect(collection1.has(1)).toBe(true) + expect(collection1.has(2)).toBe(true) + expect(collection2.has(1)).toBe(true) + expect(collection2.has(2)).toBe(true) + + expect(collection1.get(1)?.name).toBe(`From Collection 1`) + expect(collection1.get(2)?.name).toBe(`From Collection 2`) + expect(collection2.get(1)?.name).toBe(`From Collection 1`) + expect(collection2.get(2)?.name).toBe(`From Collection 2`) + }) + + it(`should maintain consistency with interleaved operations`, async () => { + const collection1 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + const collection2 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection1.preload() + await collection2.preload() + + // Interleave inserts from both collections + await collection1.insert({ id: 1, name: `C1 Item 1` }) + await collection2.insert({ id: 2, name: `C2 Item 2` }) + await collection1.insert({ id: 3, name: `C1 Item 3` }) + await collection2.insert({ id: 4, name: `C2 Item 4` }) + await flushPromises() + + // Wait for all propagations + await flushPromises() + await flushPromises() + + // Verify both collections have all 4 items + expect(collection1.size).toBe(4) + expect(collection2.size).toBe(4) + + // Verify data integrity + const exported = await collection1.utils.exportData() + expect(exported.length).toBe(4) + }) + }) + + describe(`Message filtering`, () => { + let dbName1: string + let dbName2: string + let db1: IndexedDBInstance + let db2: IndexedDBInstance + + beforeEach(() => { + dbName1 = getUniqueDbName() + dbName2 = getUniqueDbName() + }) + + afterEach(async () => { + try { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- db1 may not be assigned if test fails early + db1?.close() + } catch { + // May already be closed + } + try { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- db2 may not be assigned if test fails early + db2?.close() + } catch { + // May already be closed + } + await deleteDatabase(dbName1, indexedDB) + await deleteDatabase(dbName2, indexedDB) + }) + + it(`should ignore messages from different databases`, async () => { + db1 = await createTestDB(dbName1, ['items']) + db2 = await createTestDB(dbName2, ['items']) + + // Collection1 uses database1 + const collection1 = createCollection({ + ...indexedDBCollectionOptions({ + db: db1, + name: `items`, + getKey: (item) => item.id, + }), + }) + + // Collection2 uses database2 (different database) + const collection2 = createCollection({ + ...indexedDBCollectionOptions({ + db: db2, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection1.preload() + await collection2.preload() + + // Insert item in collection1 (database1) + await collection1.insert({ id: 1, name: `DB1 Item` }) + await flushPromises() + await flushPromises() + + // Collection2 (different database) should NOT have the item + expect(collection1.has(1)).toBe(true) + expect(collection2.has(1)).toBe(false) + + // Insert item in collection2 (database2) + await collection2.insert({ id: 2, name: `DB2 Item` }) + await flushPromises() + await flushPromises() + + // Collection1 should NOT have the item from database2 + expect(collection1.has(2)).toBe(false) + expect(collection2.has(2)).toBe(true) + }) + + it(`should ignore own messages (same tabId)`, async () => { + db1 = await createTestDB(dbName1, ['items']) + + // Create a single collection - it should not process its own messages + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db: db1, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + // Insert item + await collection.insert({ id: 1, name: `Single Tab Item` }) + await flushPromises() + + // The collection should have the item (from direct insertion) + expect(collection.has(1)).toBe(true) + + // The item should be persisted + const exported = await collection.utils.exportData() + expect(exported.length).toBe(1) + expect(exported[0]).toEqual({ id: 1, name: `Single Tab Item` }) + + // No duplicate processing should have occurred + expect(collection.size).toBe(1) + }) + + it(`should handle collections with different object store names`, async () => { + // Create database with BOTH stores upfront + db1 = await createTestDB(dbName1, ['items1', 'items2']) + + // Both use same database but different object stores + const collection1 = createCollection({ + ...indexedDBCollectionOptions({ + db: db1, + name: `items1`, + getKey: (item) => item.id, + }), + }) + + const collection2 = createCollection({ + ...indexedDBCollectionOptions({ + db: db1, + name: `items2`, + getKey: (item) => item.id, + }), + }) + + await collection1.preload() + await collection2.preload() + + // Insert in collection1 (items1 store) + await collection1.insert({ id: 1, name: `Store1 Item` }) + await flushPromises() + await flushPromises() + + // Collection2 (items2 store) should NOT have the item + expect(collection1.has(1)).toBe(true) + expect(collection2.has(1)).toBe(false) + }) + }) + + describe(`Rapid mutations`, () => { + let dbName: string + let db: IndexedDBInstance + + beforeEach(async () => { + dbName = getUniqueDbName() + db = await createTestDB(dbName, ['items']) + }) + + afterEach(async () => { + db.close() + await deleteDatabase(dbName, indexedDB) + }) + + it(`should handle multiple rapid inserts`, async () => { + const collection1 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + const collection2 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection1.preload() + await collection2.preload() + + // Perform multiple mutations in quick succession + const insertCount = 20 + for (let i = 1; i <= insertCount; i++) { + await collection1.insert({ id: i, name: `Rapid Item ${i}` }) + } + await flushPromises() + + // Wait for all propagations + await flushPromises() + await flushPromises() + await flushPromises() + + // Both collections should eventually be consistent + expect(collection1.size).toBe(insertCount) + expect(collection2.size).toBe(insertCount) + + // Verify all items are present + for (let i = 1; i <= insertCount; i++) { + expect(collection1.has(i)).toBe(true) + expect(collection2.has(i)).toBe(true) + } + }) + + it(`should handle rapid mixed operations`, async () => { + const collection1 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + const collection2 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection1.preload() + await collection2.preload() + + // Initial inserts + for (let i = 1; i <= 5; i++) { + await collection1.insert({ id: i, name: `Item ${i}`, value: i * 10 }) + } + await flushPromises() + + // Rapid mixed operations + await collection1.update(1, (draft) => { + draft.name = `Updated 1` + }) + await collection1.update(2, (draft) => { + draft.name = `Updated 2` + }) + await collection1.delete(3) + await collection1.insert({ id: 6, name: `Item 6` }) + await collection1.update(4, (draft) => { + draft.name = `Updated 4` + }) + await collection1.delete(5) + await flushPromises() + + // Wait for all propagations + await flushPromises() + await flushPromises() + + // Final state should have items 1, 2, 4, 6 + expect(collection1.size).toBe(4) + expect(collection2.size).toBe(4) + + expect(collection1.has(1)).toBe(true) + expect(collection1.has(2)).toBe(true) + expect(collection1.has(3)).toBe(false) + expect(collection1.has(4)).toBe(true) + expect(collection1.has(5)).toBe(false) + expect(collection1.has(6)).toBe(true) + + expect(collection2.has(1)).toBe(true) + expect(collection2.has(2)).toBe(true) + expect(collection2.has(3)).toBe(false) + expect(collection2.has(4)).toBe(true) + expect(collection2.has(5)).toBe(false) + expect(collection2.has(6)).toBe(true) + + // Verify updates propagated + expect(collection2.get(1)?.name).toBe(`Updated 1`) + expect(collection2.get(2)?.name).toBe(`Updated 2`) + expect(collection2.get(4)?.name).toBe(`Updated 4`) + }) + + it(`should maintain data integrity after rapid operations`, async () => { + const collection1 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection1.preload() + + // Rapid inserts and updates + for (let i = 1; i <= 10; i++) { + await collection1.insert({ id: i, name: `Original ${i}` }) + } + await flushPromises() + + // Rapid updates + for (let i = 1; i <= 10; i++) { + await collection1.update(i, (draft) => { + draft.name = `Updated ${i}` + draft.value = i * 100 + }) + } + await flushPromises() + + // Verify persistence + const exported = await collection1.utils.exportData() + expect(exported.length).toBe(10) + + // All items should have updated values + for (const item of exported) { + expect(item.name).toMatch(/^Updated \d+$/) + expect(item.value).toBeDefined() + expect(item.value).toBe(item.id * 100) + } + }) + }) + + describe(`Edge cases`, () => { + let dbName: string + let db: IndexedDBInstance + + beforeEach(async () => { + dbName = getUniqueDbName() + db = await createTestDB(dbName, ['items']) + }) + + afterEach(async () => { + db.close() + await deleteDatabase(dbName, indexedDB) + }) + + it(`should handle empty collections`, async () => { + const collection1 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + const collection2 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection1.preload() + await collection2.preload() + + // Both should start empty + expect(collection1.size).toBe(0) + expect(collection2.size).toBe(0) + + // No errors should occur + const exported1 = await collection1.utils.exportData() + const exported2 = await collection2.utils.exportData() + expect(exported1).toEqual([]) + expect(exported2).toEqual([]) + }) + + it(`should handle insert-then-delete of same item`, async () => { + const collection1 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + const collection2 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection1.preload() + await collection2.preload() + + // Insert and immediately delete + await collection1.insert({ id: 1, name: `Ephemeral Item` }) + await flushPromises() + await collection1.delete(1) + await flushPromises() + + // Wait for propagation + await flushPromises() + await flushPromises() + + // Both should end up empty + expect(collection1.has(1)).toBe(false) + expect(collection2.has(1)).toBe(false) + + const exported = await collection1.utils.exportData() + expect(exported.length).toBe(0) + }) + + it(`should handle update on non-existent item gracefully`, async () => { + const collection1 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection1.preload() + + // Try to update non-existent item - should not throw + try { + await collection1.update(999, (draft) => { + draft.name = `Does not exist` + }) + } catch { + // Expected - item doesn't exist + } + + // Collection should still be functional + await collection1.insert({ id: 1, name: `Real Item` }) + await flushPromises() + + expect(collection1.has(1)).toBe(true) + }) + + it(`should handle string keys with special characters`, async () => { + interface StringKeyItem { + uuid: string + title: string + } + + const collection1 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.uuid, + }), + }) + + const collection2 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.uuid, + }), + }) + + await collection1.preload() + await collection2.preload() + + // Insert with special characters in key + await collection1.insert({ + uuid: `item-with-special-chars!@#$%`, + title: `Special Item`, + }) + await flushPromises() + await flushPromises() + + // Verify propagation + expect(collection2.has(`item-with-special-chars!@#$%`)).toBe(true) + expect(collection2.get(`item-with-special-chars!@#$%`)?.title).toBe( + `Special Item`, + ) + }) + }) +}) diff --git a/packages/indexeddb-db-collection/tests/indexeddb.test-d.ts b/packages/indexeddb-db-collection/tests/indexeddb.test-d.ts new file mode 100644 index 000000000..9fddf6c4a --- /dev/null +++ b/packages/indexeddb-db-collection/tests/indexeddb.test-d.ts @@ -0,0 +1,479 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { createCollection } from '@tanstack/db' +import { z } from 'zod' +import { + indexedDBCollectionOptions, +} from '../src' +import type { + DatabaseInfo, + IndexedDBCollectionConfig, + IndexedDBCollectionUtils, + IndexedDBInstance, +} from '../src' +import type { + DeleteMutationFnParams, + InsertMutationFnParams, + UpdateMutationFnParams, +} from '@tanstack/db' + +// Mock IndexedDBInstance for type testing +const mockDbInstance: IndexedDBInstance = { + db: { + objectStoreNames: { contains: () => true }, + } as unknown as IDBDatabase, + name: `test-db`, + version: 1, + stores: [`test-store`, `users`, `todos`, `numeric`, `items`], + close: () => {}, +} + +describe(`IndexedDB collection type resolution tests`, () => { + // Define test types + type ExplicitType = { id: string; explicit: boolean } + + it(`should prioritize explicit type in IndexedDBCollectionConfig`, () => { + const options = indexedDBCollectionOptions({ + db: mockDbInstance, + name: `test-store`, + getKey: (item) => item.id, + }) + + // The getKey function should have the resolved type + expectTypeOf(options.getKey).parameters.toEqualTypeOf<[ExplicitType]>() + }) + + it(`should properly type the onInsert, onUpdate, and onDelete handlers`, () => { + const options = indexedDBCollectionOptions({ + db: mockDbInstance, + name: `test-store`, + 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() + }, + onUpdate: (params) => { + // Verify that the mutation value has the correct type + expectTypeOf( + params.transaction.mutations[0].modified, + ).toEqualTypeOf() + return Promise.resolve() + }, + onDelete: (params) => { + // Verify that the mutation value has the correct type + expectTypeOf( + params.transaction.mutations[0].original, + ).toEqualTypeOf() + return Promise.resolve() + }, + }) + + // Verify that the handlers are properly typed + expectTypeOf(options.onInsert).parameters.toEqualTypeOf< + [ + InsertMutationFnParams< + ExplicitType, + string | number, + IndexedDBCollectionUtils + >, + ] + >() + + expectTypeOf(options.onUpdate).parameters.toEqualTypeOf< + [ + UpdateMutationFnParams< + ExplicitType, + string | number, + IndexedDBCollectionUtils + >, + ] + >() + + expectTypeOf(options.onDelete).parameters.toEqualTypeOf< + [ + DeleteMutationFnParams< + ExplicitType, + string | number, + IndexedDBCollectionUtils + >, + ] + >() + }) + + it(`should create collection with explicit types`, () => { + // Define a user type + type UserType = { + id: string + name: string + age: number + email: string + active: boolean + } + + // Create IndexedDB collection options with explicit type + const idbOptions = indexedDBCollectionOptions({ + db: mockDbInstance, + name: `users`, + getKey: (item) => item.id, + }) + + // Create a collection using the options + const usersCollection = createCollection(idbOptions) + + // Test that the collection itself has the correct type + expectTypeOf(usersCollection.toArray).toEqualTypeOf>() + + // Test that the getKey function has the correct parameter type + expectTypeOf(idbOptions.getKey).parameters.toEqualTypeOf<[UserType]>() + }) + + it(`should infer types from Zod schema`, () => { + // Define a Zod schema for a user with basic field types + const userSchema = z.object({ + id: z.string(), + name: z.string(), + age: z.number(), + email: z.string().email(), + active: z.boolean(), + }) + + type UserType = z.infer + + // Create IndexedDB collection options with the schema + const idbOptions = indexedDBCollectionOptions({ + db: mockDbInstance, + name: `users`, + schema: userSchema, + getKey: (item) => item.id, + }) + + // Create a collection using the options + const usersCollection = createCollection(idbOptions) + + // Test that the collection itself has the correct type + expectTypeOf(usersCollection.toArray).toEqualTypeOf>() + + // Test that the getKey function has the correct parameter type + expectTypeOf(idbOptions.getKey).parameters.toEqualTypeOf<[UserType]>() + }) + + describe(`Key type inference`, () => { + interface TodoType { + id: string + title: string + completed: boolean + } + + interface NumericKeyType { + num: number + value: string + } + + it(`should infer string key type from getKey`, () => { + const options = indexedDBCollectionOptions({ + db: mockDbInstance, + name: `todos`, + getKey: (item) => item.id, + }) + + // getKey should return string + expectTypeOf(options.getKey).returns.toEqualTypeOf() + }) + + it(`should infer number key type from getKey`, () => { + const options = indexedDBCollectionOptions({ + db: mockDbInstance, + name: `numeric`, + getKey: (item) => item.num, + }) + + // getKey should return number + expectTypeOf(options.getKey).returns.toEqualTypeOf() + }) + + it(`should use default key type (string | number) when not specified`, () => { + const options = indexedDBCollectionOptions({ + db: mockDbInstance, + name: `todos`, + getKey: (item) => item.id, + }) + + // getKey should accept string | number return by default + expectTypeOf(options.getKey).returns.toMatchTypeOf() + }) + }) + + describe(`Config options type checking`, () => { + interface TestItem { + id: string + name: string + } + + it(`should require db option`, () => { + const config: IndexedDBCollectionConfig = { + db: mockDbInstance, + name: `test-store`, + getKey: (item) => item.id, + } + + expectTypeOf(config.db).toEqualTypeOf() + }) + + it(`should require name option`, () => { + const config: IndexedDBCollectionConfig = { + db: mockDbInstance, + name: `test-store`, + getKey: (item) => item.id, + } + + expectTypeOf(config.name).toEqualTypeOf() + }) + + it(`should require getKey option`, () => { + const config: IndexedDBCollectionConfig = { + db: mockDbInstance, + name: `test-store`, + getKey: (item) => item.id, + } + + expectTypeOf(config.getKey).toBeFunction() + expectTypeOf(config.getKey).parameters.toEqualTypeOf<[TestItem]>() + }) + + it(`should accept optional id`, () => { + const config: IndexedDBCollectionConfig = { + db: mockDbInstance, + name: `test-store`, + getKey: (item) => item.id, + id: `custom-id`, + } + + expectTypeOf(config.id).toEqualTypeOf() + }) + }) + + describe(`Utility function types`, () => { + interface TestItem { + id: string + name: string + } + + it(`should type clearObjectStore as returning Promise`, () => { + const options = indexedDBCollectionOptions({ + db: mockDbInstance, + name: `test-store`, + getKey: (item) => item.id, + }) + + expectTypeOf(options.utils.clearObjectStore).returns.toEqualTypeOf< + Promise + >() + }) + + it(`should type deleteDatabase as returning Promise`, () => { + const options = indexedDBCollectionOptions({ + db: mockDbInstance, + name: `test-store`, + getKey: (item) => item.id, + }) + + expectTypeOf(options.utils.deleteDatabase).returns.toEqualTypeOf< + Promise + >() + }) + + it(`should type getDatabaseInfo as returning Promise`, () => { + const options = indexedDBCollectionOptions({ + db: mockDbInstance, + name: `test-store`, + getKey: (item) => item.id, + }) + + expectTypeOf(options.utils.getDatabaseInfo).returns.toEqualTypeOf< + Promise + >() + }) + + it(`should type exportData as returning Promise`, () => { + const options = indexedDBCollectionOptions({ + db: mockDbInstance, + name: `test-store`, + getKey: (item) => item.id, + }) + + expectTypeOf(options.utils.exportData).returns.toEqualTypeOf< + Promise> + >() + }) + + it(`should type importData as accepting T[] and returning Promise`, () => { + const options = indexedDBCollectionOptions({ + db: mockDbInstance, + name: `test-store`, + getKey: (item) => item.id, + }) + + expectTypeOf(options.utils.importData).parameters.toEqualTypeOf< + [Array] + >() + expectTypeOf(options.utils.importData).returns.toEqualTypeOf< + Promise + >() + }) + + it(`should type DatabaseInfo correctly`, () => { + const info: DatabaseInfo = { + name: `test-db`, + version: 1, + objectStores: [`store1`, `store2`], + estimatedSize: 1024, + } + + expectTypeOf(info.name).toEqualTypeOf() + expectTypeOf(info.version).toEqualTypeOf() + expectTypeOf(info.objectStores).toEqualTypeOf>() + expectTypeOf(info.estimatedSize).toEqualTypeOf() + }) + }) + + describe(`Schema vs no schema usage`, () => { + it(`should work without schema using explicit generic`, () => { + interface Todo { + id: number + title: string + done: boolean + } + + const options = indexedDBCollectionOptions({ + db: mockDbInstance, + name: `todos`, + getKey: (item) => item.id, + }) + + expectTypeOf(options.getKey).parameter(0).toMatchTypeOf() + expectTypeOf(options.schema).toEqualTypeOf() + }) + + it(`should infer item type from schema when provided`, () => { + const todoSchema = z.object({ + id: z.number(), + title: z.string(), + done: z.boolean(), + }) + + const options = indexedDBCollectionOptions({ + db: mockDbInstance, + name: `todos`, + schema: todoSchema, + getKey: (item) => item.id, + }) + + type ExpectedType = z.infer + expectTypeOf(options.getKey).parameter(0).toMatchTypeOf() + expectTypeOf(options.schema).toEqualTypeOf() + }) + }) + + describe(`Mutation handler parameter types`, () => { + interface Item { + id: string + value: number + } + + it(`should type onInsert params correctly`, () => { + indexedDBCollectionOptions({ + db: mockDbInstance, + name: `items`, + getKey: (item) => item.id, + onInsert: (params) => { + // transaction should have mutations array + expectTypeOf(params.transaction.mutations).toBeArray() + // Each mutation should have modified field with correct type + expectTypeOf( + params.transaction.mutations[0].modified, + ).toEqualTypeOf() + // collection should be accessible and have correct type + expectTypeOf(params.collection.get).toBeFunction() + return Promise.resolve() + }, + }) + }) + + it(`should type onUpdate params correctly`, () => { + indexedDBCollectionOptions({ + db: mockDbInstance, + name: `items`, + getKey: (item) => item.id, + onUpdate: (params) => { + // transaction should have mutations array + expectTypeOf(params.transaction.mutations).toBeArray() + // Each mutation should have modified field with correct type + expectTypeOf( + params.transaction.mutations[0].modified, + ).toEqualTypeOf() + // Each mutation should have original field with correct type + expectTypeOf( + params.transaction.mutations[0].original, + ).toEqualTypeOf() + // collection should be accessible and have correct type + expectTypeOf(params.collection.get).toBeFunction() + return Promise.resolve() + }, + }) + }) + + it(`should type onDelete params correctly`, () => { + indexedDBCollectionOptions({ + db: mockDbInstance, + name: `items`, + getKey: (item) => item.id, + onDelete: (params) => { + // transaction should have mutations array + expectTypeOf(params.transaction.mutations).toBeArray() + // Each mutation should have original field with correct type + expectTypeOf( + params.transaction.mutations[0].original, + ).toEqualTypeOf() + // Each mutation should have key + expectTypeOf( + params.transaction.mutations[0].key, + ).toMatchTypeOf() + // collection should be accessible and have correct type + expectTypeOf(params.collection.get).toBeFunction() + return Promise.resolve() + }, + }) + }) + }) + + describe(`Utils type inference with schema`, () => { + it(`should properly type utils with schema inference`, () => { + const itemSchema = z.object({ + id: z.string(), + name: z.string(), + count: z.number(), + }) + + type ItemType = z.infer + + const options = indexedDBCollectionOptions({ + db: mockDbInstance, + name: `items`, + schema: itemSchema, + getKey: (item) => item.id, + }) + + // exportData should return Promise + expectTypeOf(options.utils.exportData).returns.toEqualTypeOf< + Promise> + >() + + // importData should accept ItemType[] + expectTypeOf(options.utils.importData).parameters.toMatchTypeOf< + [Array] + >() + }) + }) +}) diff --git a/packages/indexeddb-db-collection/tests/indexeddb.test.ts b/packages/indexeddb-db-collection/tests/indexeddb.test.ts new file mode 100644 index 000000000..0ac169865 --- /dev/null +++ b/packages/indexeddb-db-collection/tests/indexeddb.test.ts @@ -0,0 +1,937 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { indexedDB } from 'fake-indexeddb' +import { createCollection } from '@tanstack/db' +import { + GetKeyRequiredError, + + NameRequiredError, + ObjectStoreNotFoundError, + createIndexedDB, + deleteDatabase, + indexedDBCollectionOptions +} from '../src' +import type {IndexedDBInstance} from '../src'; + +interface TestItem { + id: number + name: string + value?: number +} + +// Helper to advance timers and flush microtasks +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 10)) + +// Mock localStorage for tests (required by @tanstack/db proxy.ts) +const mockLocalStorage = { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + length: 0, + key: vi.fn(() => null), +} + +describe(`indexedDBCollectionOptions`, () => { + // Use unique database names per test to avoid conflicts + let dbNameCounter = 0 + const getUniqueDbName = () => `test-db-${Date.now()}-${dbNameCounter++}` + + beforeEach(() => { + // Mock localStorage globally + vi.stubGlobal(`localStorage`, mockLocalStorage) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe(`createIndexedDB`, () => { + let dbName: string + + beforeEach(() => { + dbName = getUniqueDbName() + }) + + afterEach(async () => { + await deleteDatabase(dbName, indexedDB) + }) + + it(`should create database with specified stores`, async () => { + const db = await createIndexedDB({ + name: dbName, + version: 1, + stores: ['store1', 'store2'], + idbFactory: indexedDB, + }) + + expect(db.db.objectStoreNames.contains('store1')).toBe(true) + expect(db.db.objectStoreNames.contains('store2')).toBe(true) + expect(db.db.objectStoreNames.contains('_versions')).toBe(true) + expect(db.name).toBe(dbName) + expect(db.version).toBe(1) + expect(db.stores).toContain('store1') + expect(db.stores).toContain('store2') + + db.close() + }) + + it(`should throw if no stores provided`, async () => { + await expect( + createIndexedDB({ + name: dbName, + version: 1, + stores: [], + idbFactory: indexedDB, + }), + ).rejects.toThrow('at least one store') + }) + + it(`should throw if duplicate store names`, async () => { + await expect( + createIndexedDB({ + name: dbName, + version: 1, + stores: ['items', 'items'], + idbFactory: indexedDB, + }), + ).rejects.toThrow('duplicate store names') + }) + + it(`should throw if invalid store name`, async () => { + await expect( + createIndexedDB({ + name: dbName, + version: 1, + stores: ['valid', ''], + idbFactory: indexedDB, + }), + ).rejects.toThrow('invalid store names') + }) + + it(`should allow multiple collections to share database`, async () => { + const db = await createIndexedDB({ + name: dbName, + version: 1, + stores: ['items1', 'items2'], + idbFactory: indexedDB, + }) + + const collection1 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: 'items1', + getKey: (item) => item.id, + }), + }) + + const collection2 = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: 'items2', + getKey: (item) => item.id, + }), + }) + + await collection1.preload() + await collection2.preload() + + await collection1.insert({ id: 1, name: 'Item 1' }) + await collection2.insert({ id: 2, name: 'Item 2' }) + await flushPromises() + + expect(collection1.has(1)).toBe(true) + expect(collection2.has(2)).toBe(true) + expect(collection1.has(2)).toBe(false) + expect(collection2.has(1)).toBe(false) + + db.close() + }) + }) + + describe(`Configuration validation`, () => { + let dbName: string + let db: IndexedDBInstance + + beforeEach(async () => { + dbName = getUniqueDbName() + db = await createIndexedDB({ + name: dbName, + version: 1, + stores: ['items'], + idbFactory: indexedDB, + }) + }) + + afterEach(async () => { + db.close() + await deleteDatabase(dbName, indexedDB) + }) + + it(`should throw ObjectStoreNotFoundError when store not in database`, () => { + expect(() => + indexedDBCollectionOptions({ + db, + name: `missing`, + getKey: (item) => item.id, + }), + ).toThrow(ObjectStoreNotFoundError) + }) + + it(`should throw NameRequiredError when name not provided`, () => { + expect(() => + indexedDBCollectionOptions({ + db, + name: ``, + getKey: (item: TestItem) => item.id, + } as any), + ).toThrow(NameRequiredError) + }) + + it(`should throw GetKeyRequiredError when getKey not provided`, () => { + expect(() => + indexedDBCollectionOptions({ + db, + name: `items`, + } as any), + ).toThrow(GetKeyRequiredError) + }) + + it(`should create options successfully with valid config`, () => { + const options = indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item: TestItem) => item.id, + }) + + expect(options).toBeDefined() + expect(options.getKey).toBeDefined() + expect(options.sync).toBeDefined() + expect(options.utils).toBeDefined() + }) + }) + + describe(`Initial load`, () => { + let dbName: string + let db: IndexedDBInstance + + beforeEach(async () => { + dbName = getUniqueDbName() + db = await createIndexedDB({ + name: dbName, + version: 1, + stores: ['items'], + idbFactory: indexedDB, + }) + }) + + afterEach(async () => { + db.close() + await deleteDatabase(dbName, indexedDB) + }) + + it(`should return empty collection for empty store`, async () => { + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + expect(collection.size).toBe(0) + expect(Array.from(collection.state.values())).toEqual([]) + }) + }) + + describe(`Insert operations`, () => { + let dbName: string + let db: IndexedDBInstance + + beforeEach(async () => { + dbName = getUniqueDbName() + db = await createIndexedDB({ + name: dbName, + version: 1, + stores: ['items'], + idbFactory: indexedDB, + }) + }) + + afterEach(async () => { + db.close() + await deleteDatabase(dbName, indexedDB) + }) + + it(`should add item to collection state`, async () => { + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + await collection.insert({ id: 1, name: `Test Item` }) + await flushPromises() + + expect(collection.size).toBe(1) + expect(collection.get(1)).toEqual({ id: 1, name: `Test Item` }) + }) + + it(`should persist item to IndexedDB`, async () => { + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + await collection.insert({ id: 42, name: `Persisted Item`, value: 123 }) + await flushPromises() + + // Verify using exportData utility + const exported = await collection.utils.exportData() + expect(exported.length).toBe(1) + expect(exported[0]).toEqual({ + id: 42, + name: `Persisted Item`, + value: 123, + }) + }) + + it(`should update version entry in _versions store`, async () => { + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + await collection.insert({ id: 1, name: `Item 1` }) + await flushPromises() + + // Verify version entry exists by checking database info + const info = await collection.utils.getDatabaseInfo() + expect(info.objectStores).toContain(`_versions`) + }) + + it(`should handle multiple inserts correctly`, async () => { + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + await collection.insert({ id: 1, name: `First` }) + await collection.insert({ id: 2, name: `Second` }) + await collection.insert({ id: 3, name: `Third` }) + await flushPromises() + + expect(collection.size).toBe(3) + expect(collection.get(1)).toEqual({ id: 1, name: `First` }) + expect(collection.get(2)).toEqual({ id: 2, name: `Second` }) + expect(collection.get(3)).toEqual({ id: 3, name: `Third` }) + + // Verify persistence + const exported = await collection.utils.exportData() + expect(exported.length).toBe(3) + }) + }) + + describe(`Update operations`, () => { + let dbName: string + let db: IndexedDBInstance + + beforeEach(async () => { + dbName = getUniqueDbName() + db = await createIndexedDB({ + name: dbName, + version: 1, + stores: ['items'], + idbFactory: indexedDB, + }) + }) + + afterEach(async () => { + db.close() + await deleteDatabase(dbName, indexedDB) + }) + + it(`should modify item in collection state`, async () => { + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + await collection.insert({ id: 1, name: `Original`, value: 10 }) + await flushPromises() + + await collection.update(1, (draft) => { + draft.name = `Updated` + draft.value = 20 + }) + await flushPromises() + + expect(collection.get(1)).toEqual({ id: 1, name: `Updated`, value: 20 }) + }) + + it(`should persist update changes to IndexedDB`, async () => { + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + await collection.insert({ id: 1, name: `Original` }) + await flushPromises() + + await collection.update(1, (draft) => { + draft.name = `Modified` + }) + await flushPromises() + + // Verify persistence + const exported = await collection.utils.exportData() + expect(exported.length).toBe(1) + expect(exported[0]).toEqual({ id: 1, name: `Modified` }) + }) + + it(`should update version entry on update`, async () => { + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + await collection.insert({ id: 1, name: `Original` }) + await flushPromises() + + // Export data before update + const beforeUpdate = await collection.utils.exportData() + expect(beforeUpdate.length).toBe(1) + + await collection.update(1, (draft) => { + draft.name = `Updated` + }) + await flushPromises() + + // Export data after update + const afterUpdate = await collection.utils.exportData() + expect(afterUpdate.length).toBe(1) + expect(afterUpdate[0]).toEqual({ id: 1, name: `Updated` }) + }) + }) + + describe(`Delete operations`, () => { + let dbName: string + let db: IndexedDBInstance + + beforeEach(async () => { + dbName = getUniqueDbName() + db = await createIndexedDB({ + name: dbName, + version: 1, + stores: ['items'], + idbFactory: indexedDB, + }) + }) + + afterEach(async () => { + db.close() + await deleteDatabase(dbName, indexedDB) + }) + + it(`should remove item from collection state`, async () => { + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + await collection.insert({ id: 1, name: `To Delete` }) + await flushPromises() + + expect(collection.size).toBe(1) + expect(collection.has(1)).toBe(true) + + await collection.delete(1) + await flushPromises() + + expect(collection.size).toBe(0) + expect(collection.has(1)).toBe(false) + }) + + it(`should remove item from IndexedDB`, async () => { + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + await collection.insert({ id: 1, name: `To Delete` }) + await collection.insert({ id: 2, name: `To Keep` }) + await flushPromises() + + await collection.delete(1) + await flushPromises() + + // Verify persistence using export + const exported = await collection.utils.exportData() + expect(exported.length).toBe(1) + expect(exported[0]).toEqual({ id: 2, name: `To Keep` }) + }) + + it(`should remove version entry on delete`, async () => { + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + await collection.insert({ id: 1, name: `Item 1` }) + await collection.insert({ id: 2, name: `Item 2` }) + await flushPromises() + + const beforeDelete = await collection.utils.exportData() + expect(beforeDelete.length).toBe(2) + + await collection.delete(1) + await flushPromises() + + const afterDelete = await collection.utils.exportData() + expect(afterDelete.length).toBe(1) + expect(afterDelete[0]).toEqual({ id: 2, name: `Item 2` }) + }) + }) + + describe(`Utility functions`, () => { + let dbName: string + let db: IndexedDBInstance + + beforeEach(async () => { + dbName = getUniqueDbName() + db = await createIndexedDB({ + name: dbName, + version: 1, + stores: ['items'], + idbFactory: indexedDB, + }) + }) + + afterEach(async () => { + try { + db.close() + } catch { + // May already be closed + } + await deleteDatabase(dbName, indexedDB) + }) + + describe(`deleteDatabase`, () => { + it(`should remove database`, async () => { + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + await collection.insert({ id: 1, name: `Item 1` }) + await flushPromises() + + await collection.utils.deleteDatabase() + + // Creating a new collection should start fresh + const db2 = await createIndexedDB({ + name: dbName, + version: 1, + stores: ['items'], + idbFactory: indexedDB, + }) + + const collection2 = createCollection({ + ...indexedDBCollectionOptions({ + db: db2, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection2.preload() + + expect(collection2.size).toBe(0) + db2.close() + }) + }) + + describe(`getDatabaseInfo`, () => { + it(`should return correct database info`, async () => { + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + const info = await collection.utils.getDatabaseInfo() + + expect(info.name).toBe(dbName) + expect(info.version).toBe(1) + expect(info.objectStores).toContain(`items`) + expect(info.objectStores).toContain(`_versions`) + }) + }) + + describe(`exportData`, () => { + it(`should return all items`, async () => { + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + const items: Array = [ + { id: 1, name: `Item 1`, value: 100 }, + { id: 2, name: `Item 2`, value: 200 }, + { id: 3, name: `Item 3`, value: 300 }, + ] + + for (const item of items) { + await collection.insert(item) + } + await flushPromises() + + const exported = await collection.utils.exportData() + + expect(exported.length).toBe(3) + expect(exported).toContainEqual(items[0]) + expect(exported).toContainEqual(items[1]) + expect(exported).toContainEqual(items[2]) + }) + + it(`should return empty array for empty store`, async () => { + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + const exported = await collection.utils.exportData() + expect(exported).toEqual([]) + }) + }) + }) + + describe(`Custom ID configuration`, () => { + let dbName: string + let db: IndexedDBInstance + + beforeEach(async () => { + dbName = getUniqueDbName() + db = await createIndexedDB({ + name: dbName, + version: 1, + stores: ['items'], + idbFactory: indexedDB, + }) + }) + + afterEach(async () => { + db.close() + await deleteDatabase(dbName, indexedDB) + }) + + it(`should use provided id configuration`, () => { + const options = indexedDBCollectionOptions({ + id: `custom-collection-id`, + db, + name: `items`, + getKey: (item: TestItem) => item.id, + }) + + expect(options.id).toBe(`custom-collection-id`) + }) + + it(`should generate default id when not provided`, () => { + const options = indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item: TestItem) => item.id, + }) + + expect(options.id).toBe(`indexeddb-collection:${dbName}:items`) + }) + }) + + describe(`String keys`, () => { + interface StringKeyItem { + uuid: string + title: string + } + + let dbName: string + let db: IndexedDBInstance + + beforeEach(async () => { + dbName = getUniqueDbName() + db = await createIndexedDB({ + name: dbName, + version: 1, + stores: ['items'], + idbFactory: indexedDB, + }) + }) + + afterEach(async () => { + db.close() + await deleteDatabase(dbName, indexedDB) + }) + + it(`should work with string keys`, async () => { + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.uuid, + }), + }) + + await collection.preload() + + await collection.insert({ uuid: `abc-123`, title: `First` }) + await collection.insert({ uuid: `def-456`, title: `Second` }) + await flushPromises() + + expect(collection.size).toBe(2) + expect(collection.get(`abc-123`)).toEqual({ + uuid: `abc-123`, + title: `First`, + }) + expect(collection.get(`def-456`)).toEqual({ + uuid: `def-456`, + title: `Second`, + }) + + // Verify persistence via export + const exported = await collection.utils.exportData() + expect(exported.length).toBe(2) + }) + }) + + describe(`Error handling`, () => { + let dbName: string + let db: IndexedDBInstance + + beforeEach(async () => { + dbName = getUniqueDbName() + db = await createIndexedDB({ + name: dbName, + version: 1, + stores: ['items'], + idbFactory: indexedDB, + }) + }) + + afterEach(async () => { + // Clean up database if it exists + try { + db.close() + } catch { + // May already be closed + } + try { + await deleteDatabase(dbName, indexedDB) + } catch { + // Ignore cleanup errors + } + }) + + it(`should throw error when IndexedDB is not available`, async () => { + // Import the wrapper function directly for testing + const { openDatabase } = await import(`../src/wrapper`) + + // Store original values + const originalWindow = + typeof window !== `undefined` ? window.indexedDB : undefined + const originalGlobal = globalThis.indexedDB + + // Remove indexedDB from global scope + if (typeof window !== `undefined`) { + Object.defineProperty(window, `indexedDB`, { + value: undefined, + writable: true, + configurable: true, + }) + } + Object.defineProperty(globalThis, `indexedDB`, { + value: undefined, + writable: true, + configurable: true, + }) + + try { + // Test that the openDatabase wrapper throws when no IDB is available + await expect(openDatabase(dbName, 1)).rejects.toThrow( + /IndexedDB is not available/, + ) + } finally { + // Restore original values + if (typeof window !== `undefined` && originalWindow !== undefined) { + Object.defineProperty(window, `indexedDB`, { + value: originalWindow, + writable: true, + configurable: true, + }) + } + Object.defineProperty(globalThis, `indexedDB`, { + value: originalGlobal, + writable: true, + configurable: true, + }) + } + }) + + it(`should handle transaction abort gracefully`, async () => { + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + // Insert initial data + await collection.insert({ id: 1, name: `Initial Item` }) + await flushPromises() + + // Verify initial state + expect(collection.size).toBe(1) + expect(collection.get(1)).toEqual({ id: 1, name: `Initial Item` }) + + // Verify the data is persisted + const exported = await collection.utils.exportData() + expect(exported.length).toBe(1) + + // The collection should remain usable after any transaction issues + await collection.insert({ id: 2, name: `Second Item` }) + await flushPromises() + + expect(collection.size).toBe(2) + expect(collection.get(2)).toEqual({ id: 2, name: `Second Item` }) + }) + + it(`should handle invalid key errors`, async () => { + interface ItemWithComplexKey { + id: { nested: string } + name: string + } + + // IndexedDB doesn't support object keys directly + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + // Attempting to insert with an object key should throw + let thrownError: Error | null = null + try { + await collection.insert({ id: { nested: `value` }, name: `Test` }) + } catch (error) { + thrownError = error as Error + } + + expect(thrownError).not.toBeNull() + expect(thrownError?.message).toMatch(/invalid key type/i) + }) + + it(`should remain functional after recoverable errors`, async () => { + const collection = createCollection({ + ...indexedDBCollectionOptions({ + db, + name: `items`, + getKey: (item) => item.id, + }), + }) + + await collection.preload() + + // Insert valid data + await collection.insert({ id: 1, name: `Valid Item 1` }) + await flushPromises() + + expect(collection.size).toBe(1) + + // Try to insert more valid data + await collection.insert({ id: 2, name: `Valid Item 2` }) + await flushPromises() + + expect(collection.size).toBe(2) + + // Verify persistence + const exported = await collection.utils.exportData() + expect(exported.length).toBe(2) + expect(exported).toContainEqual({ id: 1, name: `Valid Item 1` }) + expect(exported).toContainEqual({ id: 2, name: `Valid Item 2` }) + }) + }) +}) diff --git a/packages/indexeddb-db-collection/tests/wrapper.test.ts b/packages/indexeddb-db-collection/tests/wrapper.test.ts new file mode 100644 index 000000000..bf2c7922d --- /dev/null +++ b/packages/indexeddb-db-collection/tests/wrapper.test.ts @@ -0,0 +1,793 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { indexedDB } from 'fake-indexeddb' +import { + clear, + createObjectStore, + deleteByKey, + deleteDatabase, + executeTransaction, + getAll, + getAllKeys, + getByKey, + openDatabase, + put, +} from '../src/wrapper.js' + +interface TestItem { + id: string + name: string + value?: number +} + +describe(`IndexedDB Wrapper`, () => { + const testDbName = `test-db` + let db: IDBDatabase | null = null + + afterEach(async () => { + // Close database connection if open + if (db) { + db.close() + db = null + } + // Clean up test database + await deleteDatabase(testDbName, indexedDB) + }) + + // Helper to get a store from the stores record (handles the index signature type) + function getStore( + stores: Record, + name: string, + ): IDBObjectStore { + const store = stores[name] + if (!store) { + throw new Error(`Store ${name} not found`) + } + return store + } + + describe(`openDatabase`, () => { + it(`should successfully open a database`, async () => { + db = await openDatabase(testDbName, 1, undefined, indexedDB) + + expect(db).toBeDefined() + expect(db.name).toBe(testDbName) + expect(db.version).toBe(1) + }) + + it(`should call upgrade callback with correct parameters`, async () => { + const upgradeFn = vi.fn() + + db = await openDatabase( + testDbName, + 1, + (database, oldVersion, newVersion, transaction) => { + upgradeFn(database, oldVersion, newVersion, transaction) + }, + indexedDB, + ) + + expect(upgradeFn).toHaveBeenCalledTimes(1) + expect(upgradeFn).toHaveBeenCalledWith( + expect.objectContaining({ name: testDbName }), + 0, // oldVersion for new database + 1, // newVersion + expect.objectContaining({ mode: `versionchange` }), + ) + }) + + it(`should handle version upgrades correctly`, async () => { + // First, create version 1 + db = await openDatabase( + testDbName, + 1, + (database) => { + database.createObjectStore(`store1`, { keyPath: `id` }) + }, + indexedDB, + ) + db.close() + db = null + + // Now upgrade to version 2 + const upgradeFn = vi.fn() + db = await openDatabase( + testDbName, + 2, + (database, oldVersion, newVersion, _transaction) => { + upgradeFn(oldVersion, newVersion) + if (oldVersion < 2) { + database.createObjectStore(`store2`, { keyPath: `id` }) + } + }, + indexedDB, + ) + + expect(upgradeFn).toHaveBeenCalledWith(1, 2) + expect(db.objectStoreNames.contains(`store1`)).toBe(true) + expect(db.objectStoreNames.contains(`store2`)).toBe(true) + }) + + it(`should reject when upgrade callback throws an error`, async () => { + await expect( + openDatabase( + testDbName, + 1, + () => { + throw new Error(`Upgrade failed intentionally`) + }, + indexedDB, + ), + ).rejects.toThrow(`Database upgrade failed`) + }) + }) + + describe(`createObjectStore`, () => { + it(`should create object store during upgrade`, async () => { + db = await openDatabase( + testDbName, + 1, + (database) => { + createObjectStore(database, `testStore`) + }, + indexedDB, + ) + + expect(db.objectStoreNames.contains(`testStore`)).toBe(true) + }) + + it(`should create object store with keyPath`, async () => { + db = await openDatabase( + testDbName, + 1, + (database) => { + createObjectStore(database, `testStore`, { keyPath: `id` }) + }, + indexedDB, + ) + + const tx = db.transaction(`testStore`, `readonly`) + const store = tx.objectStore(`testStore`) + expect(store.keyPath).toBe(`id`) + }) + + it(`should create object store with autoIncrement`, async () => { + db = await openDatabase( + testDbName, + 1, + (database) => { + createObjectStore(database, `testStore`, { autoIncrement: true }) + }, + indexedDB, + ) + + const tx = db.transaction(`testStore`, `readonly`) + const store = tx.objectStore(`testStore`) + expect(store.autoIncrement).toBe(true) + }) + + it(`should create object store with both keyPath and autoIncrement`, async () => { + db = await openDatabase( + testDbName, + 1, + (database) => { + createObjectStore(database, `testStore`, { + keyPath: `id`, + autoIncrement: true, + }) + }, + indexedDB, + ) + + const tx = db.transaction(`testStore`, `readonly`) + const store = tx.objectStore(`testStore`) + expect(store.keyPath).toBe(`id`) + expect(store.autoIncrement).toBe(true) + }) + + it(`should throw when creating duplicate object store`, async () => { + await expect( + openDatabase( + testDbName, + 1, + (database) => { + createObjectStore(database, `testStore`) + createObjectStore(database, `testStore`) // Duplicate + }, + indexedDB, + ), + ).rejects.toThrow(`already exists`) + }) + }) + + describe(`executeTransaction`, () => { + beforeEach(async () => { + db = await openDatabase( + testDbName, + 1, + (database) => { + createObjectStore(database, `items`, { keyPath: `id` }) + createObjectStore(database, `users`, { keyPath: `id` }) + }, + indexedDB, + ) + }) + + it(`should execute read transaction`, async () => { + // First add some data + const tx = db!.transaction(`items`, `readwrite`) + const store = tx.objectStore(`items`) + store.put({ id: `1`, name: `test` }) + await new Promise((resolve, reject) => { + tx.oncomplete = resolve + tx.onerror = reject + }) + + // Now read via executeTransaction + const result = await executeTransaction( + db!, + `items`, + `readonly`, + async (transaction, stores) => { + return new Promise((resolve) => { + const request = getStore(stores, `items`).get(`1`) + request.onsuccess = () => resolve(request.result) + }) + }, + ) + + expect(result).toEqual({ id: `1`, name: `test` }) + }) + + it(`should execute write transaction`, async () => { + const result = await executeTransaction( + db!, + `items`, + `readwrite`, + async (transaction, stores) => { + return new Promise((resolve) => { + const request = getStore(stores, `items`).put({ + id: `1`, + name: `test`, + }) + request.onsuccess = () => resolve(`done`) + }) + }, + ) + + expect(result).toBe(`done`) + + // Verify the data was written + const readResult = await executeTransaction( + db!, + `items`, + `readonly`, + async (transaction, stores) => { + return new Promise((resolve) => { + const request = getStore(stores, `items`).get(`1`) + request.onsuccess = () => resolve(request.result) + }) + }, + ) + + expect(readResult).toEqual({ id: `1`, name: `test` }) + }) + + it(`should provide correct stores to callback`, async () => { + const storeNames: Array = [] + + await executeTransaction( + db!, + [`items`, `users`], + `readonly`, + (transaction, stores) => { + storeNames.push(...Object.keys(stores)) + }, + ) + + expect(storeNames).toContain(`items`) + expect(storeNames).toContain(`users`) + expect(storeNames.length).toBe(2) + }) + + it(`should auto-complete transaction after sync callback`, async () => { + // The executeTransaction promise should resolve after + // the transaction completes, not just when the callback returns + await executeTransaction( + db!, + `items`, + `readwrite`, + (transaction, stores) => { + getStore(stores, `items`).put({ id: `1`, name: `test` }) + }, + ) + + // Verify data was persisted - if transaction completed, data should be there + const item = await executeTransaction( + db!, + `items`, + `readonly`, + async (tx, stores) => { + return getByKey(getStore(stores, `items`), `1`) + }, + ) + + expect(item).toEqual({ id: `1`, name: `test` }) + }) + + it(`should reject when accessing non-existent store`, async () => { + await expect( + executeTransaction(db!, `nonExistent`, `readonly`, () => {}), + ).rejects.toThrow(`nonExistent`) + }) + + it(`should handle sync callbacks`, async () => { + const result = await executeTransaction( + db!, + `items`, + `readonly`, + (_transaction, _stores) => { + return `sync result` + }, + ) + + expect(result).toBe(`sync result`) + }) + + it(`should handle async callbacks that don't use await`, async () => { + // The wrapper resolves when transaction completes + // For async callbacks that return immediately without awaiting + // the transaction may complete before the async return + const result = await executeTransaction( + db!, + `items`, + `readonly`, + () => { + return Promise.resolve(`async result`) + }, + ) + + expect(result).toBe(`async result`) + }) + + it(`should abort transaction when callback throws synchronously`, async () => { + await expect( + executeTransaction(db!, `items`, `readonly`, () => { + throw new Error(`Sync error`) + }), + ).rejects.toThrow() + }) + + it(`should abort transaction when callback rejects asynchronously`, async () => { + await expect( + executeTransaction(db!, `items`, `readonly`, () => { + return Promise.reject(new Error(`Async error`)) + }), + ).rejects.toThrow() + }) + }) + + describe(`getAll`, () => { + beforeEach(async () => { + db = await openDatabase( + testDbName, + 1, + (database) => { + createObjectStore(database, `items`, { keyPath: `id` }) + }, + indexedDB, + ) + }) + + it(`should return all items from store`, async () => { + // Add test data + await executeTransaction( + db!, + `items`, + `readwrite`, + async (tx, stores) => { + const itemsStore = getStore(stores, `items`) + await put(itemsStore, { id: `1`, name: `Item 1` }) + await put(itemsStore, { id: `2`, name: `Item 2` }) + await put(itemsStore, { id: `3`, name: `Item 3` }) + }, + ) + + // Get all items + const items = await executeTransaction( + db!, + `items`, + `readonly`, + async (tx, stores) => { + return getAll(getStore(stores, `items`)) + }, + ) + + expect(items).toHaveLength(3) + expect(items).toContainEqual({ id: `1`, name: `Item 1` }) + expect(items).toContainEqual({ id: `2`, name: `Item 2` }) + expect(items).toContainEqual({ id: `3`, name: `Item 3` }) + }) + + it(`should return empty array for empty store`, async () => { + const items = await executeTransaction( + db!, + `items`, + `readonly`, + async (tx, stores) => { + return getAll(getStore(stores, `items`)) + }, + ) + + expect(items).toEqual([]) + }) + }) + + describe(`getAllKeys`, () => { + beforeEach(async () => { + db = await openDatabase( + testDbName, + 1, + (database) => { + createObjectStore(database, `items`, { keyPath: `id` }) + }, + indexedDB, + ) + }) + + it(`should return all keys from store`, async () => { + // Add test data + await executeTransaction( + db!, + `items`, + `readwrite`, + async (tx, stores) => { + const itemsStore = getStore(stores, `items`) + await put(itemsStore, { id: `a`, name: `Item A` }) + await put(itemsStore, { id: `b`, name: `Item B` }) + await put(itemsStore, { id: `c`, name: `Item C` }) + }, + ) + + // Get all keys + const keys = await executeTransaction( + db!, + `items`, + `readonly`, + async (tx, stores) => { + return getAllKeys(getStore(stores, `items`)) + }, + ) + + expect(keys).toHaveLength(3) + expect(keys).toContain(`a`) + expect(keys).toContain(`b`) + expect(keys).toContain(`c`) + }) + + it(`should return empty array for empty store`, async () => { + const keys = await executeTransaction( + db!, + `items`, + `readonly`, + async (tx, stores) => { + return getAllKeys(getStore(stores, `items`)) + }, + ) + + expect(keys).toEqual([]) + }) + }) + + describe(`getByKey`, () => { + beforeEach(async () => { + db = await openDatabase( + testDbName, + 1, + (database) => { + createObjectStore(database, `items`, { keyPath: `id` }) + }, + indexedDB, + ) + }) + + it(`should return item by key`, async () => { + // Add test data + await executeTransaction( + db!, + `items`, + `readwrite`, + async (tx, stores) => { + await put(getStore(stores, `items`), { + id: `test-id`, + name: `Test Item`, + value: 42, + }) + }, + ) + + // Get by key + const item = await executeTransaction( + db!, + `items`, + `readonly`, + async (tx, stores) => { + return getByKey(getStore(stores, `items`), `test-id`) + }, + ) + + expect(item).toEqual({ id: `test-id`, name: `Test Item`, value: 42 }) + }) + + it(`should return undefined for non-existent key`, async () => { + const item = await executeTransaction( + db!, + `items`, + `readonly`, + async (tx, stores) => { + return getByKey(getStore(stores, `items`), `non-existent`) + }, + ) + + expect(item).toBeUndefined() + }) + }) + + describe(`put`, () => { + beforeEach(async () => { + db = await openDatabase( + testDbName, + 1, + (database) => { + createObjectStore(database, `items`, { keyPath: `id` }) + createObjectStore(database, `noKeyPath`) + }, + indexedDB, + ) + }) + + it(`should insert new item`, async () => { + const key = await executeTransaction( + db!, + `items`, + `readwrite`, + async (tx, stores) => { + return put(getStore(stores, `items`), { + id: `new`, + name: `New Item`, + }) + }, + ) + + expect(key).toBe(`new`) + + // Verify it was stored + const item = await executeTransaction( + db!, + `items`, + `readonly`, + async (tx, stores) => { + return getByKey(getStore(stores, `items`), `new`) + }, + ) + + expect(item).toEqual({ id: `new`, name: `New Item` }) + }) + + it(`should update existing item`, async () => { + // Insert initial item + await executeTransaction(db!, `items`, `readwrite`, async (tx, stores) => { + await put(getStore(stores, `items`), { + id: `update`, + name: `Original`, + }) + }) + + // Update the item + await executeTransaction(db!, `items`, `readwrite`, async (tx, stores) => { + await put(getStore(stores, `items`), { + id: `update`, + name: `Updated`, + }) + }) + + // Verify it was updated + const item = await executeTransaction( + db!, + `items`, + `readonly`, + async (tx, stores) => { + return getByKey(getStore(stores, `items`), `update`) + }, + ) + + expect(item).toEqual({ id: `update`, name: `Updated` }) + }) + + it(`should support explicit key for stores without keyPath`, async () => { + const key = await executeTransaction( + db!, + `noKeyPath`, + `readwrite`, + async (tx, stores) => { + return put(getStore(stores, `noKeyPath`), { name: `Data` }, `explicit-key`) + }, + ) + + expect(key).toBe(`explicit-key`) + + // Verify it was stored with explicit key + const item = await executeTransaction( + db!, + `noKeyPath`, + `readonly`, + async (tx, stores) => { + return getByKey(getStore(stores, `noKeyPath`), `explicit-key`) + }, + ) + + expect(item).toEqual({ name: `Data` }) + }) + }) + + describe(`deleteByKey`, () => { + beforeEach(async () => { + db = await openDatabase( + testDbName, + 1, + (database) => { + createObjectStore(database, `items`, { keyPath: `id` }) + }, + indexedDB, + ) + }) + + it(`should delete item by key`, async () => { + // Add test data + await executeTransaction(db!, `items`, `readwrite`, async (tx, stores) => { + await put(getStore(stores, `items`), { + id: `delete-me`, + name: `To Delete`, + }) + }) + + // Verify it exists + let item = await executeTransaction( + db!, + `items`, + `readonly`, + async (tx, stores) => { + return getByKey(getStore(stores, `items`), `delete-me`) + }, + ) + expect(item).toBeDefined() + + // Delete it + await executeTransaction(db!, `items`, `readwrite`, async (tx, stores) => { + await deleteByKey(getStore(stores, `items`), `delete-me`) + }) + + // Verify it's gone + item = await executeTransaction( + db!, + `items`, + `readonly`, + async (tx, stores) => { + return getByKey(getStore(stores, `items`), `delete-me`) + }, + ) + expect(item).toBeUndefined() + }) + + it(`should succeed when deleting non-existent key`, async () => { + // Deleting non-existent key should not throw + await expect( + executeTransaction(db!, `items`, `readwrite`, async (tx, stores) => { + await deleteByKey(getStore(stores, `items`), `non-existent`) + }), + ).resolves.not.toThrow() + }) + }) + + describe(`clear`, () => { + beforeEach(async () => { + db = await openDatabase( + testDbName, + 1, + (database) => { + createObjectStore(database, `items`, { keyPath: `id` }) + }, + indexedDB, + ) + }) + + it(`should remove all items from store`, async () => { + // Add test data + await executeTransaction(db!, `items`, `readwrite`, async (tx, stores) => { + const itemsStore = getStore(stores, `items`) + await put(itemsStore, { id: `1`, name: `Item 1` }) + await put(itemsStore, { id: `2`, name: `Item 2` }) + await put(itemsStore, { id: `3`, name: `Item 3` }) + }) + + // Verify items exist + let items = await executeTransaction( + db!, + `items`, + `readonly`, + async (tx, stores) => { + return getAll(getStore(stores, `items`)) + }, + ) + expect(items).toHaveLength(3) + + // Clear the store + await executeTransaction(db!, `items`, `readwrite`, async (tx, stores) => { + await clear(getStore(stores, `items`)) + }) + + // Verify all items are gone + items = await executeTransaction( + db!, + `items`, + `readonly`, + async (tx, stores) => { + return getAll(getStore(stores, `items`)) + }, + ) + expect(items).toEqual([]) + }) + + it(`should succeed on empty store`, async () => { + // Clear empty store should not throw + await expect( + executeTransaction(db!, `items`, `readwrite`, async (tx, stores) => { + await clear(getStore(stores, `items`)) + }), + ).resolves.not.toThrow() + }) + }) + + describe(`deleteDatabase`, () => { + it(`should delete existing database`, async () => { + // Create a database + db = await openDatabase( + testDbName, + 1, + (database) => { + createObjectStore(database, `items`, { keyPath: `id` }) + }, + indexedDB, + ) + + // Close it first + db.close() + db = null + + // Delete the database + await expect( + deleteDatabase(testDbName, indexedDB), + ).resolves.not.toThrow() + + // Verify it was deleted by opening fresh (version should be 0 upgrade) + const upgradeFn = vi.fn() + db = await openDatabase( + testDbName, + 1, + (database, oldVersion) => { + upgradeFn(oldVersion) + }, + indexedDB, + ) + + // Old version should be 0 (new database) + expect(upgradeFn).toHaveBeenCalledWith(0) + }) + + it(`should succeed when deleting non-existent database`, async () => { + // Deleting non-existent database should succeed silently + await expect( + deleteDatabase(`non-existent-db-name`, indexedDB), + ).resolves.not.toThrow() + }) + }) +}) diff --git a/packages/indexeddb-db-collection/tsconfig.docs.json b/packages/indexeddb-db-collection/tsconfig.docs.json new file mode 100644 index 000000000..5a73feb02 --- /dev/null +++ b/packages/indexeddb-db-collection/tsconfig.docs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@tanstack/db": ["../db/src"] + } + }, + "include": ["src"] +} diff --git a/packages/indexeddb-db-collection/tsconfig.json b/packages/indexeddb-db-collection/tsconfig.json new file mode 100644 index 000000000..623d4bd91 --- /dev/null +++ b/packages/indexeddb-db-collection/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "paths": { + "@tanstack/store": ["../store/src"], + "@tanstack/db": ["../db/src"], + "@tanstack/db-ivm": ["../db-ivm/src"] + } + }, + "include": ["src", "tests", "vite.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/indexeddb-db-collection/vite.config.ts b/packages/indexeddb-db-collection/vite.config.ts new file mode 100644 index 000000000..6b9e3c255 --- /dev/null +++ b/packages/indexeddb-db-collection/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + include: [`tests/**/*.test.ts`], + environment: `jsdom`, + coverage: { enabled: true, provider: `istanbul`, include: [`src/**/*`] }, + typecheck: { + enabled: true, + include: [`tests/**/*.test.ts`, `tests/**/*.test-d.ts`], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: `./src/index.ts`, + srcDir: `./src`, + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c84f5c422..2f899ba0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 9.39.2 '@fast-check/vitest': specifier: ^0.2.0 - version: 0.2.4(vitest@3.2.4) + version: 0.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) '@svitejs/changesets-changelog-github-compact': specifier: ^1.2.0 version: 1.2.0(encoding@0.1.13) @@ -109,7 +109,7 @@ importers: version: 7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4(vitest@3.2.4))(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) zod: specifier: ^3.25.76 version: 3.25.76 @@ -136,10 +136,10 @@ importers: version: 20.3.15(@angular/common@19.2.17(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0))(@angular/platform-browser@19.2.17(@angular/common@19.2.17(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0)))(rxjs@7.8.2) '@tanstack/angular-db': specifier: ^0.1.47 - version: link:../../../packages/angular-db + version: 0.1.47(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2)(typescript@5.9.3) '@tanstack/db': specifier: ^0.5.21 - version: link:../../../packages/db + version: 0.5.21(typescript@5.9.3) rxjs: specifier: ^7.8.2 version: 7.8.2 @@ -152,7 +152,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.3.13 - version: 20.3.13(@angular/compiler-cli@20.3.15(@angular/compiler@20.3.16)(typescript@5.9.3))(@angular/compiler@20.3.16)(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0))(@angular/platform-browser@19.2.17(@angular/common@19.2.17(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0)))(@types/node@24.7.0)(chokidar@4.0.3)(jiti@2.6.1)(karma@6.4.4)(lightningcss@1.30.2)(postcss@8.5.6)(tailwindcss@4.1.18)(terser@5.44.0)(tslib@2.8.1)(tsx@4.21.0)(typescript@5.9.3)(vitest@3.2.4)(yaml@2.8.1) + version: 20.3.13(@angular/compiler-cli@20.3.15(@angular/compiler@20.3.16)(typescript@5.9.3))(@angular/compiler@20.3.16)(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0))(@angular/platform-browser@19.2.17(@angular/common@19.2.17(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0)))(@types/node@24.7.0)(chokidar@4.0.3)(jiti@2.6.1)(karma@6.4.4)(lightningcss@1.30.2)(postcss@8.5.6)(tailwindcss@4.1.18)(terser@5.44.0)(tslib@2.8.1)(tsx@4.21.0)(typescript@5.9.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4(vitest@3.2.4))(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(yaml@2.8.1) '@angular/cli': specifier: ^20.3.13 version: 20.3.13(@types/node@24.7.0)(chokidar@4.0.3) @@ -206,13 +206,13 @@ importers: version: 11.4.1(react-native@0.79.6(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3)) '@tanstack/offline-transactions': specifier: ^1.0.11 - version: link:../../../packages/offline-transactions + version: 1.0.11(@react-native-community/netinfo@11.4.1(react-native@0.79.6(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3)))(react-native@0.79.6(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3))(typescript@5.9.3) '@tanstack/query-db-collection': specifier: ^1.0.18 - version: link:../../../packages/query-db-collection + version: 1.0.18(@tanstack/query-core@5.90.16)(typescript@5.9.3) '@tanstack/react-db': specifier: ^0.1.65 - version: link:../../../packages/react-db + version: 0.1.65(react@19.2.3)(typescript@5.9.3) '@tanstack/react-query': specifier: ^5.90.16 version: 5.90.16(react@19.2.3) @@ -227,7 +227,7 @@ importers: version: 7.1.7(expo@53.0.25(@babel/core@7.28.5)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3)))(graphql@16.12.0)(react-native@0.79.6(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3))(react@19.2.3))(react-native@0.79.6(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) expo-router: specifier: ~5.1.10 - version: 5.1.10(4f28e76f0b81d75a7bbf5ddbb07ad280) + version: 5.1.10(j5wvl2emqw3bliva7npj4ca4f4) expo-status-bar: specifier: ~2.2.0 version: 2.2.3(react-native@0.79.6(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) @@ -279,13 +279,13 @@ importers: dependencies: '@tanstack/offline-transactions': specifier: ^1.0.11 - version: link:../../../packages/offline-transactions + version: 1.0.11(@react-native-community/netinfo@11.4.1(react-native@0.79.6(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3)))(react-native@0.79.6(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3))(typescript@5.9.3) '@tanstack/query-db-collection': specifier: ^1.0.18 - version: link:../../../packages/query-db-collection + version: 1.0.18(@tanstack/query-core@5.90.16)(typescript@5.9.3) '@tanstack/react-db': specifier: ^0.1.65 - version: link:../../../packages/react-db + version: 0.1.65(react@19.2.3)(typescript@5.9.3) '@tanstack/react-query': specifier: ^5.90.16 version: 5.90.16(react@19.2.3) @@ -346,10 +346,10 @@ importers: dependencies: '@tanstack/db': specifier: ^0.5.21 - version: link:../../../packages/db + version: 0.5.21(typescript@5.9.3) '@tanstack/react-db': specifier: ^0.1.65 - version: link:../../../packages/react-db + version: 0.1.65(react@19.2.3)(typescript@5.9.3) mitt: specifier: ^3.0.1 version: 3.0.1 @@ -386,10 +386,10 @@ importers: version: 5.90.16 '@tanstack/query-db-collection': specifier: ^1.0.18 - version: link:../../../packages/query-db-collection + version: 1.0.18(@tanstack/query-core@5.90.16)(typescript@5.9.3) '@tanstack/react-db': specifier: ^0.1.65 - version: link:../../../packages/react-db + version: 0.1.65(react@19.2.3)(typescript@5.9.3) '@tanstack/react-router': specifier: ^1.144.0 version: 1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -404,7 +404,7 @@ importers: version: 1.145.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) '@tanstack/router-plugin': specifier: ^1.145.4 - version: 1.145.4(@tanstack/react-router@1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 1.145.4(@tanstack/react-router@1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) '@trpc/client': specifier: ^11.8.1 version: 11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3) @@ -510,7 +510,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4(vitest@3.2.4))(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -519,25 +519,25 @@ importers: dependencies: '@tanstack/electric-db-collection': specifier: ^0.2.26 - version: link:../../../packages/electric-db-collection + version: 0.2.26(typescript@5.9.3) '@tanstack/query-core': specifier: ^5.90.16 version: 5.90.16 '@tanstack/query-db-collection': specifier: ^1.0.18 - version: link:../../../packages/query-db-collection + version: 1.0.18(@tanstack/query-core@5.90.16)(typescript@5.9.3) '@tanstack/react-db': specifier: ^0.1.65 - version: link:../../../packages/react-db + version: 0.1.65(react@19.2.3)(typescript@5.9.3) '@tanstack/react-router': specifier: ^1.144.0 version: 1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': specifier: ^1.145.5 - version: 1.145.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 1.145.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) '@tanstack/trailbase-db-collection': specifier: ^0.1.65 - version: link:../../../packages/trailbase-db-collection + version: 0.1.65(typescript@5.9.3) cors: specifier: ^2.8.5 version: 2.8.5 @@ -640,25 +640,25 @@ importers: dependencies: '@tanstack/electric-db-collection': specifier: ^0.2.26 - version: link:../../../packages/electric-db-collection + version: 0.2.26(typescript@5.9.3) '@tanstack/query-core': specifier: ^5.90.16 version: 5.90.16 '@tanstack/query-db-collection': specifier: ^1.0.18 - version: link:../../../packages/query-db-collection + version: 1.0.18(@tanstack/query-core@5.90.16)(typescript@5.9.3) '@tanstack/solid-db': specifier: ^0.2.1 - version: link:../../../packages/solid-db + version: 0.2.1(solid-js@1.9.10)(typescript@5.9.3) '@tanstack/solid-router': specifier: ^1.144.0 version: 1.144.0(solid-js@1.9.10) '@tanstack/solid-start': specifier: ^1.145.5 - version: 1.145.5(@tanstack/react-router@1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(solid-js@1.9.10)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 1.145.5(@tanstack/react-router@1.144.0)(solid-js@1.9.10)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) '@tanstack/trailbase-db-collection': specifier: ^0.1.65 - version: link:../../../packages/trailbase-db-collection + version: 0.1.65(typescript@5.9.3) cors: specifier: ^2.8.5 version: 2.8.5 @@ -765,7 +765,7 @@ importers: version: 19.2.17(@angular/common@19.2.17(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/compiler@20.3.16)(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0))(@angular/platform-browser@19.2.17(@angular/common@19.2.17(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0))) '@vitest/coverage-istanbul': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) rxjs: specifier: ^7.8.2 version: 7.8.2 @@ -793,7 +793,7 @@ importers: version: 0.22.2(@types/node@24.7.0)(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(rollup@4.52.5)(typescript@5.9.3)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) '@vitest/coverage-istanbul': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) arktype: specifier: ^2.1.29 version: 2.1.29 @@ -836,7 +836,7 @@ importers: version: 7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4(vitest@3.2.4))(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) packages/db-collections: {} @@ -857,7 +857,7 @@ importers: version: 4.1.12 '@vitest/coverage-istanbul': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) packages/electric-db-collection: dependencies: @@ -885,11 +885,27 @@ importers: version: 8.16.0 '@vitest/coverage-istanbul': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) pg: specifier: ^8.16.3 version: 8.16.3 + packages/indexeddb-db-collection: + dependencies: + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 + '@tanstack/db': + specifier: workspace:* + version: link:../db + devDependencies: + '@vitest/coverage-istanbul': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + fake-indexeddb: + specifier: ^6.0.0 + version: 6.2.5 + packages/offline-transactions: dependencies: '@tanstack/db': @@ -913,7 +929,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4(vitest@3.2.4))(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) packages/powersync-db-collection: dependencies: @@ -944,7 +960,7 @@ importers: version: 4.1.12 '@vitest/coverage-istanbul': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) packages/query-db-collection: dependencies: @@ -963,7 +979,7 @@ importers: version: 5.90.16 '@vitest/coverage-istanbul': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) packages/react-db: dependencies: @@ -991,7 +1007,7 @@ importers: version: 1.5.0 '@vitest/coverage-istanbul': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) react: specifier: ^19.2.3 version: 19.2.3 @@ -1028,7 +1044,7 @@ importers: version: 4.1.12 '@vitest/coverage-istanbul': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) packages/solid-db: dependencies: @@ -1047,7 +1063,7 @@ importers: version: 0.8.10(solid-js@1.9.10) '@vitest/coverage-istanbul': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) jsdom: specifier: ^27.4.0 version: 27.4.0 @@ -1059,7 +1075,7 @@ importers: version: 2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4(vitest@3.2.4))(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) packages/svelte-db: dependencies: @@ -1075,7 +1091,7 @@ importers: version: 6.2.1(svelte@5.46.4)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) '@vitest/coverage-istanbul': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) publint: specifier: ^0.3.16 version: 0.3.16 @@ -1112,7 +1128,7 @@ importers: version: 4.1.12 '@vitest/coverage-istanbul': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) packages/vue-db: dependencies: @@ -1128,7 +1144,7 @@ importers: version: 6.0.3(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.26(typescript@5.9.3)) '@vitest/coverage-istanbul': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) vue: specifier: ^3.5.26 version: 3.5.26(typescript@5.9.3) @@ -4467,11 +4483,30 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/angular-db@0.1.47': + resolution: {integrity: sha512-uCOhf+bVEkhhUM+E2LbZyP6UJVybskftrhpjSLVgJIF/ZDYQVAB9/4oS3keF1CpsUzZgWmGkRtpFRZn7dzF0OA==} + peerDependencies: + '@angular/core': '>=16.0.0' + rxjs: '>=6.0.0' + '@tanstack/config@0.22.2': resolution: {integrity: sha512-zP1b6xd864AOOJQ7u6r+kicohRc8dAyql6sBbQh2f2RjoEynAfn0GrMHGeqhl1LN8JThokCkcjfGnXbPEz3q3A==} engines: {node: '>=18'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + '@tanstack/db-ivm@0.1.17': + resolution: {integrity: sha512-DK7vm56CDxNuRAdsbiPs+gITJ+16tUtYgZg3BRTLYKGIDsy8sdIO7sQFq5zl7Y+aIKAPmMAbVp9UjJ75FTtwgQ==} + peerDependencies: + typescript: '>=4.7' + + '@tanstack/db@0.5.21': + resolution: {integrity: sha512-B6p3Lsczl/Sm3o2sk6dNcH9LZCWy1LFxUzLgCFoiNOVAMuB1fsF/ojOwBCl1ukoPNhEoSxWuBzeIZeKhyRnhdw==} + peerDependencies: + typescript: '>=4.7' + + '@tanstack/electric-db-collection@0.2.26': + resolution: {integrity: sha512-B/va8UA+YLbBvWoowndzesgD0tiTYc7x+KnlRRUmpkuEdGBXym+lxKpw9AqK47me8f96ibytayTxX4cgt/7q9g==} + '@tanstack/eslint-config@0.3.3': resolution: {integrity: sha512-8VFyAaIFV9onJcfc5yVj5WWl6DmN3W4m+t0Mb+nZrQmqHy+kDndw5O5Xv2BHVWRRPTqnhlJYh6wHWGh0R81ZzQ==} engines: {node: '>=18'} @@ -4486,6 +4521,17 @@ packages: resolution: {integrity: sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ==} engines: {node: '>=12'} + '@tanstack/offline-transactions@1.0.11': + resolution: {integrity: sha512-GdW1/2R1OeNNz8ZpgCP91xcUGwey6/ZQlnassOJkojLjzGw0zovmrOqFvFJk92tZwwqJ4yCg0xKFwEfA0fbglQ==} + peerDependencies: + '@react-native-community/netinfo': '>=11.0.0' + react-native: '>=0.70.0' + peerDependenciesMeta: + '@react-native-community/netinfo': + optional: true + react-native: + optional: true + '@tanstack/pacer-lite@0.2.0': resolution: {integrity: sha512-aT/tP+xu/FLSxI32LhyE3hF+3fB6iD1K3f57+y2tWAHAZ3jb+hqdCpXrpYBVdbgeTKJ9u1L+z4pW96R1/3VxBw==} engines: {node: '>=18'} @@ -4497,6 +4543,17 @@ packages: '@tanstack/query-core@5.90.16': resolution: {integrity: sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==} + '@tanstack/query-db-collection@1.0.18': + resolution: {integrity: sha512-8TtfeWa5Yg7NxB27JYB2u6/tLM2wPiAAm0OKN5q7cEJQoAoh1yyV7M+YzujR7/8zm299ome2lQ4Y1NsmySIWmQ==} + peerDependencies: + '@tanstack/query-core': ^5.0.0 + typescript: '>=4.7' + + '@tanstack/react-db@0.1.65': + resolution: {integrity: sha512-UtodzNrIWZrkqo1VTaNr1cECMqFMpJxCgrhhxd7n34VRqyoIS2N5n+pr7WIooJdXrDjb+L4C+gnFigW0zzNdFQ==} + peerDependencies: + react: '>=16.8.0' + '@tanstack/react-query@5.90.16': resolution: {integrity: sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==} peerDependencies: @@ -4603,6 +4660,11 @@ packages: resolution: {integrity: sha512-N24G4LpfyK8dOlnP8BvNdkuxg1xQljkyl6PcrdiPSA301pOjatRT1y8wuCCJZKVVD8gkd0MpCZ0VEjRMGILOtA==} engines: {node: '>=12'} + '@tanstack/solid-db@0.2.1': + resolution: {integrity: sha512-gFgCX9DLVgoEGJuhBt592soOZ4T16UGgFLv1hN6+iQPCygkAZJwh/c3WZKreeQOZdFmu54UOcGqfob3EGhepzQ==} + peerDependencies: + solid-js: '>=1.9.0' + '@tanstack/solid-router@1.144.0': resolution: {integrity: sha512-8S2BFvYE4MLw5SXCNwL69NF/E9fdOQmXokQDsM6NTfDuJGGuNvXRTiVxgnk9ungEmVbMZ3kowXMGxOuuVbZnIg==} engines: {node: '>=12'} @@ -4658,6 +4720,11 @@ packages: '@tanstack/store@0.8.0': resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} + '@tanstack/trailbase-db-collection@0.1.65': + resolution: {integrity: sha512-OhG69fJ6kTbiuJD+PHwpxztqnmiMYBUmwmzhlEh9akGJ6x9Q5vQjLhrsI08aMr+wkhY38WvKvhfmN10fUQkjBA==} + peerDependencies: + typescript: '>=4.7' + '@tanstack/typedoc-config@0.3.2': resolution: {integrity: sha512-4S6vIl2JmjvSQC87py/ZS9YWb1//1RRlHQRCUNLaGAdnptZYX7qJvSJS8GmAZ6nele2eRlpmPZGSw3MpCR22Tg==} engines: {node: '>=18'} @@ -6756,6 +6823,10 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-check@3.23.2: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} @@ -10800,7 +10871,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular/build@20.3.13(@angular/compiler-cli@20.3.15(@angular/compiler@20.3.16)(typescript@5.9.3))(@angular/compiler@20.3.16)(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0))(@angular/platform-browser@19.2.17(@angular/common@19.2.17(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0)))(@types/node@24.7.0)(chokidar@4.0.3)(jiti@2.6.1)(karma@6.4.4)(lightningcss@1.30.2)(postcss@8.5.6)(tailwindcss@4.1.18)(terser@5.44.0)(tslib@2.8.1)(tsx@4.21.0)(typescript@5.9.3)(vitest@3.2.4)(yaml@2.8.1)': + '@angular/build@20.3.13(@angular/compiler-cli@20.3.15(@angular/compiler@20.3.16)(typescript@5.9.3))(@angular/compiler@20.3.16)(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0))(@angular/platform-browser@19.2.17(@angular/common@19.2.17(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0)))(@types/node@24.7.0)(chokidar@4.0.3)(jiti@2.6.1)(karma@6.4.4)(lightningcss@1.30.2)(postcss@8.5.6)(tailwindcss@4.1.18)(terser@5.44.0)(tslib@2.8.1)(tsx@4.21.0)(typescript@5.9.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4(vitest@3.2.4))(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(yaml@2.8.1)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2003.13(chokidar@4.0.3) @@ -10839,7 +10910,7 @@ snapshots: lmdb: 3.4.2 postcss: 8.5.6 tailwindcss: 4.1.18 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4(vitest@3.2.4))(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - chokidar @@ -12455,10 +12526,10 @@ snapshots: find-up: 5.0.0 js-yaml: 4.1.1 - '@fast-check/vitest@0.2.4(vitest@3.2.4)': + '@fast-check/vitest@0.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))': dependencies: fast-check: 3.23.2 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4(vitest@3.2.4))(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) '@firebase/ai@1.4.1(@firebase/app-types@0.9.3)(@firebase/app@0.13.2)': dependencies: @@ -14349,6 +14420,14 @@ snapshots: tailwindcss: 4.1.18 vite: 7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + '@tanstack/angular-db@0.1.47(@angular/core@19.2.18(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2)(typescript@5.9.3)': + dependencies: + '@angular/core': 19.2.18(rxjs@7.8.2)(zone.js@0.16.0) + '@tanstack/db': 0.5.21(typescript@5.9.3) + rxjs: 7.8.2 + transitivePeerDependencies: + - typescript + '@tanstack/config@0.22.2(@types/node@24.7.0)(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(rollup@4.52.5)(typescript@5.9.3)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@tanstack/eslint-config': 0.3.3(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -14365,6 +14444,30 @@ snapshots: - typescript - vite + '@tanstack/db-ivm@0.1.17(typescript@5.9.3)': + dependencies: + fractional-indexing: 3.2.0 + sorted-btree: 1.8.1 + typescript: 5.9.3 + + '@tanstack/db@0.5.21(typescript@5.9.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@tanstack/db-ivm': 0.1.17(typescript@5.9.3) + '@tanstack/pacer-lite': 0.2.0 + typescript: 5.9.3 + + '@tanstack/electric-db-collection@0.2.26(typescript@5.9.3)': + dependencies: + '@electric-sql/client': 1.3.1 + '@standard-schema/spec': 1.1.0 + '@tanstack/db': 0.5.21(typescript@5.9.3) + '@tanstack/store': 0.8.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + - typescript + '@tanstack/eslint-config@0.3.3(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint/js': 9.39.2 @@ -14399,6 +14502,15 @@ snapshots: '@tanstack/history@1.141.0': {} + '@tanstack/offline-transactions@1.0.11(@react-native-community/netinfo@11.4.1(react-native@0.79.6(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3)))(react-native@0.79.6(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3))(typescript@5.9.3)': + dependencies: + '@tanstack/db': 0.5.21(typescript@5.9.3) + optionalDependencies: + '@react-native-community/netinfo': 11.4.1(react-native@0.79.6(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3)) + react-native: 0.79.6(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3) + transitivePeerDependencies: + - typescript + '@tanstack/pacer-lite@0.2.0': {} '@tanstack/publish-config@0.2.2': @@ -14412,6 +14524,21 @@ snapshots: '@tanstack/query-core@5.90.16': {} + '@tanstack/query-db-collection@1.0.18(@tanstack/query-core@5.90.16)(typescript@5.9.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@tanstack/db': 0.5.21(typescript@5.9.3) + '@tanstack/query-core': 5.90.16 + typescript: 5.9.3 + + '@tanstack/react-db@0.1.65(react@19.2.3)(typescript@5.9.3)': + dependencies: + '@tanstack/db': 0.5.21(typescript@5.9.3) + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) + transitivePeerDependencies: + - typescript + '@tanstack/react-query@5.90.16(react@19.2.3)': dependencies: '@tanstack/query-core': 5.90.16 @@ -14490,6 +14617,26 @@ snapshots: - vite-plugin-solid - webpack + '@tanstack/react-start@1.145.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))': + dependencies: + '@tanstack/react-router': 1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-start-client': 1.145.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-start-server': 1.145.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-utils': 1.143.11 + '@tanstack/start-client-core': 1.145.0 + '@tanstack/start-plugin-core': 1.145.5(@tanstack/react-router@1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + '@tanstack/start-server-core': 1.145.5 + pathe: 2.0.3 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + vite: 7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + transitivePeerDependencies: + - '@rsbuild/core' + - crossws + - supports-color + - vite-plugin-solid + - webpack + '@tanstack/react-store@0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/store': 0.8.0 @@ -14530,7 +14677,30 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.145.4(@tanstack/react-router@1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))': + '@tanstack/router-plugin@1.145.4(@tanstack/react-router@1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.5) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.28.5) + '@babel/template': 7.28.6 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + '@tanstack/router-core': 1.144.0 + '@tanstack/router-generator': 1.145.4 + '@tanstack/router-utils': 1.143.11 + '@tanstack/virtual-file-routes': 1.145.4 + babel-dead-code-elimination: 1.0.11 + chokidar: 3.6.0 + unplugin: 2.3.10 + zod: 3.25.76 + optionalDependencies: + '@tanstack/react-router': 1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + vite: 7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + vite-plugin-solid: 2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + transitivePeerDependencies: + - supports-color + + '@tanstack/router-plugin@1.145.4(@tanstack/react-router@1.144.0)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.5) @@ -14565,6 +14735,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@tanstack/solid-db@0.2.1(solid-js@1.9.10)(typescript@5.9.3)': + dependencies: + '@solid-primitives/map': 0.7.2(solid-js@1.9.10) + '@tanstack/db': 0.5.21(typescript@5.9.3) + solid-js: 1.9.10 + transitivePeerDependencies: + - typescript + '@tanstack/solid-router@1.144.0(solid-js@1.9.10)': dependencies: '@solid-devtools/logger': 0.9.11(solid-js@1.9.10) @@ -14599,13 +14777,13 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/solid-start@1.145.5(@tanstack/react-router@1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(solid-js@1.9.10)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))': + '@tanstack/solid-start@1.145.5(@tanstack/react-router@1.144.0)(solid-js@1.9.10)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@tanstack/solid-router': 1.144.0(solid-js@1.9.10) '@tanstack/solid-start-client': 1.145.0(solid-js@1.9.10) '@tanstack/solid-start-server': 1.145.5(solid-js@1.9.10) '@tanstack/start-client-core': 1.145.0 - '@tanstack/start-plugin-core': 1.145.5(@tanstack/react-router@1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + '@tanstack/start-plugin-core': 1.145.5(@tanstack/react-router@1.144.0)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) '@tanstack/start-server-core': 1.145.5 pathe: 2.0.3 solid-js: 1.9.10 @@ -14642,7 +14820,69 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.40 '@tanstack/router-core': 1.144.0 '@tanstack/router-generator': 1.145.4 - '@tanstack/router-plugin': 1.145.4(@tanstack/react-router@1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + '@tanstack/router-plugin': 1.145.4(@tanstack/react-router@1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + '@tanstack/router-utils': 1.143.11 + '@tanstack/start-client-core': 1.145.0 + '@tanstack/start-server-core': 1.145.5 + babel-dead-code-elimination: 1.0.11 + cheerio: 1.1.2 + exsolve: 1.0.7 + pathe: 2.0.3 + srvx: 0.10.1 + tinyglobby: 0.2.15 + ufo: 1.6.1 + vite: 7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + vitefu: 1.1.1(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + xmlbuilder2: 4.0.3 + zod: 3.25.76 + transitivePeerDependencies: + - '@rsbuild/core' + - '@tanstack/react-router' + - crossws + - supports-color + - vite-plugin-solid + - webpack + + '@tanstack/start-plugin-core@1.145.5(@tanstack/react-router@1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/core': 7.28.5 + '@babel/types': 7.28.6 + '@rolldown/pluginutils': 1.0.0-beta.40 + '@tanstack/router-core': 1.144.0 + '@tanstack/router-generator': 1.145.4 + '@tanstack/router-plugin': 1.145.4(@tanstack/react-router@1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + '@tanstack/router-utils': 1.143.11 + '@tanstack/start-client-core': 1.145.0 + '@tanstack/start-server-core': 1.145.5 + babel-dead-code-elimination: 1.0.11 + cheerio: 1.1.2 + exsolve: 1.0.7 + pathe: 2.0.3 + srvx: 0.10.1 + tinyglobby: 0.2.15 + ufo: 1.6.1 + vite: 7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + vitefu: 1.1.1(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + xmlbuilder2: 4.0.3 + zod: 3.25.76 + transitivePeerDependencies: + - '@rsbuild/core' + - '@tanstack/react-router' + - crossws + - supports-color + - vite-plugin-solid + - webpack + + '@tanstack/start-plugin-core@1.145.5(@tanstack/react-router@1.144.0)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/core': 7.28.5 + '@babel/types': 7.28.6 + '@rolldown/pluginutils': 1.0.0-beta.40 + '@tanstack/router-core': 1.144.0 + '@tanstack/router-generator': 1.145.4 + '@tanstack/router-plugin': 1.145.4(@tanstack/react-router@1.144.0)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) '@tanstack/router-utils': 1.143.11 '@tanstack/start-client-core': 1.145.0 '@tanstack/start-server-core': 1.145.5 @@ -14683,6 +14923,17 @@ snapshots: '@tanstack/store@0.8.0': {} + '@tanstack/trailbase-db-collection@0.1.65(typescript@5.9.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@tanstack/db': 0.5.21(typescript@5.9.3) + '@tanstack/store': 0.8.0 + debug: 4.4.3 + trailbase: 0.8.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@tanstack/typedoc-config@0.3.2(typescript@5.9.3)': dependencies: typedoc: 0.28.14(typescript@5.9.3) @@ -15219,7 +15470,7 @@ snapshots: vite: 7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) vue: 3.5.26(typescript@5.9.3) - '@vitest/coverage-istanbul@3.2.4(vitest@3.2.4)': + '@vitest/coverage-istanbul@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@istanbuljs/schema': 0.1.3 debug: 4.4.3 @@ -15231,7 +15482,7 @@ snapshots: magicast: 0.3.5 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4(vitest@3.2.4))(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -15280,7 +15531,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4(vitest@3.2.4))(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) '@vitest/utils@3.2.4': dependencies: @@ -17118,7 +17369,7 @@ snapshots: dependencies: invariant: 2.2.4 - expo-router@5.1.10(4f28e76f0b81d75a7bbf5ddbb07ad280): + expo-router@5.1.10(j5wvl2emqw3bliva7npj4ca4f4): dependencies: '@expo/metro-runtime': 5.0.5(react-native@0.79.6(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3)) '@expo/schema-utils': 0.1.8 @@ -17228,6 +17479,8 @@ snapshots: extendable-error@0.1.7: {} + fake-indexeddb@6.2.5: {} + fast-check@3.23.2: dependencies: pure-rand: 6.1.0 @@ -21169,7 +21422,7 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4(vitest@3.2.4))(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4