/** * Centralized IndexedDB helper for initialization and transaction management * Provides unified API for all IndexedDB operations across the application */ export interface IndexedDBIndex { name: string keyPath: string | string[] unique?: boolean } export interface IndexedDBConfig { dbName: string version: number storeName: string keyPath: string indexes?: IndexedDBIndex[] onUpgrade?: (db: IDBDatabase, event: IDBVersionChangeEvent) => void } export class IndexedDBError extends Error { public readonly operation: string public readonly storeName: string | undefined public override readonly cause: unknown public override readonly name = 'IndexedDBError' constructor(message: string, operation: string, storeName?: string, cause?: unknown) { super(message) this.operation = operation this.storeName = storeName this.cause = cause console.error(`[IndexedDBError] ${operation}${storeName ? ` on ${storeName}` : ''}: ${message}`, cause) } } class IndexedDBHelper { private db: IDBDatabase | null = null private initPromise: Promise | null = null private readonly config: IndexedDBConfig constructor(config: IndexedDBConfig) { this.config = config } /** * Initialize the IndexedDB database */ async init(): Promise { if (this.db) { return this.db } if (this.initPromise) { await this.initPromise if (this.db) { return this.db } throw new IndexedDBError('Database initialization failed', 'init', this.config.storeName) } this.initPromise = this.openDatabase() try { await this.initPromise if (!this.db) { throw new IndexedDBError('Database not initialized after open', 'init', this.config.storeName) } return this.db } catch (error) { this.initPromise = null throw new IndexedDBError( error instanceof Error ? error.message : 'Unknown error', 'init', this.config.storeName, error ) } } private openDatabase(): Promise { return new Promise((resolve, reject) => { if (typeof window === 'undefined' || !window.indexedDB) { reject(new IndexedDBError('IndexedDB is not available', 'openDatabase', this.config.storeName)) return } const request = window.indexedDB.open(this.config.dbName, this.config.version) request.onerror = (): void => { reject( new IndexedDBError( `Failed to open IndexedDB: ${request.error}`, 'openDatabase', this.config.storeName, request.error ) ) } request.onsuccess = (): void => { this.db = request.result resolve() } request.onupgradeneeded = (event: IDBVersionChangeEvent): void => { this.handleUpgrade(event) } }) } private handleUpgrade(event: IDBVersionChangeEvent): void { const db = (event.target as IDBOpenDBRequest).result // Create object store if it doesn't exist if (!db.objectStoreNames.contains(this.config.storeName)) { this.createObjectStore(db) } else { // Store exists, check for missing indexes this.createMissingIndexes(db, event) } // Call custom upgrade handler if provided if (this.config.onUpgrade) { this.config.onUpgrade(db, event) } } private createObjectStore(db: IDBDatabase): void { const store = db.createObjectStore(this.config.storeName, { keyPath: this.config.keyPath }) // Create indexes if (this.config.indexes) { for (const index of this.config.indexes) { if (!store.indexNames.contains(index.name)) { store.createIndex(index.name, index.keyPath, { unique: index.unique ?? false }) } } } } private createMissingIndexes(_db: IDBDatabase, event: IDBVersionChangeEvent): void { const target = event.target as IDBOpenDBRequest const { transaction } = target if (!transaction) { return } const store = transaction.objectStore(this.config.storeName) if (this.config.indexes) { for (const index of this.config.indexes) { if (!store.indexNames.contains(index.name)) { store.createIndex(index.name, index.keyPath, { unique: index.unique ?? false }) } } } } /** * Get object store for read operations */ async getStore(mode: 'readonly'): Promise { const db = await this.init() const transaction = db.transaction([this.config.storeName], mode) return transaction.objectStore(this.config.storeName) } /** * Get object store for write operations */ async getStoreWrite(mode: 'readwrite'): Promise { const db = await this.init() const transaction = db.transaction([this.config.storeName], mode) return transaction.objectStore(this.config.storeName) } /** * Get a value from the store by key */ async get(key: string | number): Promise { try { const store = await this.getStore('readonly') return new Promise((resolve, reject) => { const request = store.get(key) request.onsuccess = (): void => { resolve((request.result as T) ?? null) } request.onerror = (): void => { reject( new IndexedDBError( `Failed to get value: ${request.error}`, 'get', this.config.storeName, request.error ) ) } }) } catch (error) { if (error instanceof IndexedDBError) { throw error } throw new IndexedDBError( error instanceof Error ? error.message : 'Unknown error', 'get', this.config.storeName, error ) } } /** * Get a value from an index */ async getByIndex(indexName: string, key: string | number): Promise { try { const store = await this.getStore('readonly') const index = store.index(indexName) return new Promise((resolve, reject) => { const request = index.get(key) request.onsuccess = (): void => { resolve((request.result as T) ?? null) } request.onerror = (): void => { reject( new IndexedDBError( `Failed to get value by index: ${request.error}`, 'getByIndex', this.config.storeName, request.error ) ) } }) } catch (error) { if (error instanceof IndexedDBError) { throw error } throw new IndexedDBError( error instanceof Error ? error.message : 'Unknown error', 'getByIndex', this.config.storeName, error ) } } /** * Get all values from an index */ async getAllByIndex(indexName: string, key?: IDBValidKey | IDBKeyRange): Promise { try { const store = await this.getStore('readonly') const index = store.index(indexName) return new Promise((resolve, reject) => { const request = key !== undefined ? index.getAll(key) : index.getAll() request.onsuccess = (): void => { resolve((request.result as T[]) ?? []) } request.onerror = (): void => { reject( new IndexedDBError( `Failed to get all values by index: ${request.error}`, 'getAllByIndex', this.config.storeName, request.error ) ) } }) } catch (error) { if (error instanceof IndexedDBError) { throw error } throw new IndexedDBError( error instanceof Error ? error.message : 'Unknown error', 'getAllByIndex', this.config.storeName, error ) } } /** * Put a value in the store */ async put(value: T): Promise { try { const store = await this.getStoreWrite('readwrite') return new Promise((resolve, reject) => { const request = store.put(value) request.onsuccess = (): void => { resolve() } request.onerror = (): void => { reject( new IndexedDBError( `Failed to put value: ${request.error}`, 'put', this.config.storeName, request.error ) ) } }) } catch (error) { if (error instanceof IndexedDBError) { throw error } throw new IndexedDBError( error instanceof Error ? error.message : 'Unknown error', 'put', this.config.storeName, error ) } } /** * Add a value to the store (fails if key exists) */ async add(value: T): Promise { try { const store = await this.getStoreWrite('readwrite') return new Promise((resolve, reject) => { const request = store.add(value) request.onsuccess = (): void => { resolve() } request.onerror = (): void => { reject( new IndexedDBError( `Failed to add value: ${request.error}`, 'add', this.config.storeName, request.error ) ) } }) } catch (error) { if (error instanceof IndexedDBError) { throw error } throw new IndexedDBError( error instanceof Error ? error.message : 'Unknown error', 'add', this.config.storeName, error ) } } /** * Delete a value from the store by key */ async delete(key: string | number): Promise { try { const store = await this.getStoreWrite('readwrite') return new Promise((resolve, reject) => { const request = store.delete(key) request.onsuccess = (): void => { resolve() } request.onerror = (): void => { reject( new IndexedDBError( `Failed to delete value: ${request.error}`, 'delete', this.config.storeName, request.error ) ) } }) } catch (error) { if (error instanceof IndexedDBError) { throw error } throw new IndexedDBError( error instanceof Error ? error.message : 'Unknown error', 'delete', this.config.storeName, error ) } } /** * Clear all values from the store */ async clear(): Promise { try { const store = await this.getStoreWrite('readwrite') return new Promise((resolve, reject) => { const request = store.clear() request.onsuccess = (): void => { resolve() } request.onerror = (): void => { reject( new IndexedDBError( `Failed to clear store: ${request.error}`, 'clear', this.config.storeName, request.error ) ) } }) } catch (error) { if (error instanceof IndexedDBError) { throw error } throw new IndexedDBError( error instanceof Error ? error.message : 'Unknown error', 'clear', this.config.storeName, error ) } } /** * Open a cursor on the store */ async openCursor( direction?: IDBCursorDirection, range?: IDBKeyRange ): Promise { try { const store = await this.getStore('readonly') return new Promise((resolve, reject) => { const request = range ? store.openCursor(range, direction) : store.openCursor(direction) request.onsuccess = (): void => { resolve(request.result) } request.onerror = (): void => { reject( new IndexedDBError( `Failed to open cursor: ${request.error}`, 'openCursor', this.config.storeName, request.error ) ) } }) } catch (error) { if (error instanceof IndexedDBError) { throw error } throw new IndexedDBError( error instanceof Error ? error.message : 'Unknown error', 'openCursor', this.config.storeName, error ) } } /** * Open a cursor on an index */ async openCursorOnIndex( indexName: string, direction?: IDBCursorDirection, range?: IDBKeyRange ): Promise { try { const store = await this.getStore('readonly') const index = store.index(indexName) return new Promise((resolve, reject) => { const request = range ? index.openCursor(range, direction) : index.openCursor(direction) request.onsuccess = (): void => { resolve(request.result) } request.onerror = (): void => { reject( new IndexedDBError( `Failed to open cursor on index: ${request.error}`, 'openCursorOnIndex', this.config.storeName, request.error ) ) } }) } catch (error) { if (error instanceof IndexedDBError) { throw error } throw new IndexedDBError( error instanceof Error ? error.message : 'Unknown error', 'openCursorOnIndex', this.config.storeName, error ) } } /** * Count records in the store */ async count(range?: IDBKeyRange): Promise { try { const store = await this.getStore('readonly') return new Promise((resolve, reject) => { const request = range ? store.count(range) : store.count() request.onsuccess = (): void => { resolve(request.result) } request.onerror = (): void => { reject( new IndexedDBError( `Failed to count records: ${request.error}`, 'count', this.config.storeName, request.error ) ) } }) } catch (error) { if (error instanceof IndexedDBError) { throw error } throw new IndexedDBError( error instanceof Error ? error.message : 'Unknown error', 'count', this.config.storeName, error ) } } /** * Count records in an index */ async countByIndex(indexName: string, range?: IDBKeyRange): Promise { try { const store = await this.getStore('readonly') const index = store.index(indexName) return new Promise((resolve, reject) => { const request = range ? index.count(range) : index.count() request.onsuccess = (): void => { resolve(request.result) } request.onerror = (): void => { reject( new IndexedDBError( `Failed to count records by index: ${request.error}`, 'countByIndex', this.config.storeName, request.error ) ) } }) } catch (error) { if (error instanceof IndexedDBError) { throw error } throw new IndexedDBError( error instanceof Error ? error.message : 'Unknown error', 'countByIndex', this.config.storeName, error ) } } /** * Get all values from the store */ async getAll(range?: IDBKeyRange, count?: number): Promise { try { const store = await this.getStore('readonly') return new Promise((resolve, reject) => { const request = range ? store.getAll(range, count) : store.getAll(undefined, count) request.onsuccess = (): void => { resolve((request.result as T[]) ?? []) } request.onerror = (): void => { reject( new IndexedDBError( `Failed to get all values: ${request.error}`, 'getAll', this.config.storeName, request.error ) ) } }) } catch (error) { if (error instanceof IndexedDBError) { throw error } throw new IndexedDBError( error instanceof Error ? error.message : 'Unknown error', 'getAll', this.config.storeName, error ) } } } /** * Create a new IndexedDB helper instance */ export function createIndexedDBHelper(config: IndexedDBConfig): IndexedDBHelper { return new IndexedDBHelper(config) } export { IndexedDBHelper }