@tanstack/db
Version:
A reactive client store for building super fast apps on sync
600 lines (570 loc) • 19.7 kB
text/typescript
import { codedMessage, devBuild } from './error-message'
/**
* 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(
devBuild() && process.env.NODE_ENV !== `production`
? 'IndexedDB is not available in this environment. ' +
'Ensure you are running in a browser or provide a custom IDBFactory for testing.'
: codedMessage(179),
)
}
function executeRequest<T>(
createRequest: () => IDBRequest<T>,
/** The whole message, given the native failure's text. */
describeFailure: (cause: string) => string,
): Promise<T> {
return new Promise((resolve, reject) => {
let request: IDBRequest<T>
try {
request = createRequest()
} catch (error) {
reject(
new Error(
describeFailure(
error instanceof Error ? error.message : String(error),
),
{ cause: error },
),
)
return
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => {
const errorMessage = request.error?.message || 'Unknown error'
reject(
new Error(describeFailure(errorMessage), {
cause: request.error,
}),
)
}
})
}
/**
* Opens an IndexedDB database with the specified name and version.
* A blocked request stays pending until native success or error. The caller
* owns the returned connection and must close it when no longer needed.
*
* @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)
* @param onBlocked - Optional diagnostic callback for native blocked events. The request stays pending.
* @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,
onBlocked?: (event: IDBVersionChangeEvent) => void,
): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const factory = getIDBFactory(idbFactory)
let request: IDBOpenDBRequest
try {
request = factory.open(name, version)
} catch (error) {
reject(
new Error(
devBuild() && process.env.NODE_ENV !== `production`
? `Failed to open IndexedDB database "${name}": ${error instanceof Error ? error.message : String(error)}`
: codedMessage(182, { name, error }),
{ cause: 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(
devBuild() && process.env.NODE_ENV !== `production`
? `Database upgrade failed for "${name}": ${error instanceof Error ? error.message : String(error)}`
: codedMessage(183, { name, error }),
{ cause: error },
),
)
}
}
}
if (onBlocked) request.addEventListener('blocked', onBlocked)
request.onsuccess = () => {
resolve(request.result)
}
request.onerror = () => {
const errorMessage = request.error?.message || 'Unknown error'
reject(
new Error(
devBuild() && process.env.NODE_ENV !== `production`
? `Failed to open IndexedDB database "${name}": ${errorMessage}`
: codedMessage(184, { name, errorMessage }),
{ cause: request.error },
),
)
}
})
}
/**
* 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(
devBuild() && process.env.NODE_ENV !== `production`
? `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.'
: codedMessage(185, { storeName }),
{ cause: error },
)
}
// Check if the object store already exists
if (error instanceof DOMException && error.name === 'ConstraintError') {
throw new Error(
devBuild() && process.env.NODE_ENV !== `production`
? `Object store "${storeName}" already exists in the database. ` +
'Check the database version and only create stores when needed.'
: codedMessage(186, { storeName }),
{ cause: error },
)
}
throw new Error(
devBuild() && process.env.NODE_ENV !== `production`
? `Failed to create object store "${storeName}": ${error instanceof Error ? error.message : String(error)}`
: codedMessage(187, { storeName, error }),
{ cause: 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 both the callback and the transaction to complete (or abort)
* - Returns the callback's result or rejects with an error
*
* IndexedDB can commit while an async callback is still pending. If that callback
* later rejects, this function rejects but cannot undo the committed writes.
*
* @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<T>(
db: IDBDatabase,
storeNames: string | Array<string>,
mode: IDBTransactionMode,
callback: (
transaction: IDBTransaction,
stores: Record<string, IDBObjectStore>,
) => T | Promise<T>,
): Promise<T> {
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(
devBuild() && process.env.NODE_ENV !== `production`
? `Failed to create transaction for stores [${storeNamesArray.join(', ')}]: ${error instanceof Error ? error.message : String(error)}`
: codedMessage(188, { storeNames: storeNamesArray, error }),
{ cause: error },
),
)
return
}
// Build the stores record
const stores: Record<string, IDBObjectStore> = {}
for (const storeName of storeNamesArray) {
try {
stores[storeName] = transaction.objectStore(storeName)
} catch (error) {
reject(
new Error(
devBuild() && process.env.NODE_ENV !== `production`
? `Object store "${storeName}" not found in the database. ` +
'Ensure the store was created during the database upgrade.'
: codedMessage(189, { storeName }),
{ cause: error },
),
)
return
}
}
// Success requires both obligations: callback result AND transaction
// completion. Request success alone is not a durability receipt.
const completed = new Promise<void>((complete, abort) => {
// The callback may also set native event-handler properties.
transaction.addEventListener('complete', () => complete())
transaction.addEventListener('abort', () =>
abort(
transaction.error ??
new Error(
devBuild() && process.env.NODE_ENV !== `production`
? 'Transaction was aborted'
: codedMessage(190),
),
),
)
// Request errors normally bubble before abort. Let abort report the
// transaction outcome; callback rejection retains its original cause.
})
let result: T | Promise<T>
try {
result = callback(transaction, stores)
} catch (error) {
result = Promise.reject(error)
}
const callbackResult = Promise.resolve(result).catch((error) => {
try {
transaction.abort()
} catch {
// An async callback may settle after the native transaction has ended.
}
throw error
})
Promise.all([callbackResult, completed]).then(
([value]) => resolve(value),
reject,
)
})
}
/**
* 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<Todo>(stores.todos)
* console.log('All todos:', allTodos)
* })
* ```
*/
export function getAll<T>(objectStore: IDBObjectStore): Promise<Array<T>> {
return executeRequest<Array<T>>(
() => objectStore.getAll(),
(cause) =>
devBuild() && process.env.NODE_ENV !== `production`
? `Failed to get all items from object store "${objectStore.name}": ${cause}`
: codedMessage(204, { name: objectStore.name, cause }),
)
}
/**
* 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<Array<IDBValidKey>> {
return executeRequest(
() => objectStore.getAllKeys(),
(cause) =>
devBuild() && process.env.NODE_ENV !== `production`
? `Failed to get all keys from object store "${objectStore.name}": ${cause}`
: codedMessage(200, { name: objectStore.name, cause }),
)
}
/**
* 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<Todo>(stores.todos, 1)
* if (todo) {
* console.log('Found todo:', todo)
* }
* })
* ```
*/
export function getByKey<T>(
objectStore: IDBObjectStore,
key: IDBValidKey,
): Promise<T | undefined> {
return executeRequest<T | undefined>(
() => objectStore.get(key),
(cause) =>
devBuild() && process.env.NODE_ENV !== `production`
? `Failed to get item with key "${String(key)}" from object store "${objectStore.name}": ${cause}`
: codedMessage(205, {
key: String(key),
name: objectStore.name,
cause,
}),
)
}
/**
* 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<T>(
objectStore: IDBObjectStore,
value: T,
key?: IDBValidKey,
): Promise<IDBValidKey> {
return executeRequest(
() =>
key !== undefined ? objectStore.put(value, key) : objectStore.put(value),
(cause) =>
devBuild() && process.env.NODE_ENV !== `production`
? `Failed to write item to object store "${objectStore.name}": ${cause}`
: codedMessage(201, { name: objectStore.name, cause }),
)
}
/**
* 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<void> {
return executeRequest(
() => objectStore.delete(key),
(cause) =>
devBuild() && process.env.NODE_ENV !== `production`
? `Failed to delete item with key "${String(key)}" from object store "${objectStore.name}": ${cause}`
: codedMessage(202, {
key: String(key),
name: objectStore.name,
cause,
}),
)
}
/**
* 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<void> {
return executeRequest(
() => objectStore.clear(),
(cause) =>
devBuild() && process.env.NODE_ENV !== `production`
? `Failed to clear object store "${objectStore.name}": ${cause}`
: codedMessage(203, { name: objectStore.name, cause }),
)
}
/**
* Deletes an entire IndexedDB database.
* A blocked request stays pending until native success or error.
*
* 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
* @param onBlocked - Optional diagnostic callback for native blocked events. The request stays pending.
* @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,
onBlocked?: (event: IDBVersionChangeEvent) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const factory = getIDBFactory(idbFactory)
let request: IDBOpenDBRequest
try {
request = factory.deleteDatabase(name)
} catch (error) {
reject(
new Error(
devBuild() && process.env.NODE_ENV !== `production`
? `Failed to delete IndexedDB database "${name}": ${error instanceof Error ? error.message : String(error)}`
: codedMessage(191, { name, error }),
{ cause: error },
),
)
return
}
if (onBlocked) request.addEventListener('blocked', onBlocked)
request.onsuccess = () => {
resolve()
}
request.onerror = () => {
const errorMessage = request.error?.message || 'Unknown error'
reject(
new Error(
devBuild() && process.env.NODE_ENV !== `production`
? `Failed to delete IndexedDB database "${name}": ${errorMessage}`
: codedMessage(192, { name, errorMessage }),
{ cause: request.error },
),
)
}
})
}