story-research-zapwall/lib/helpers/indexedDBHelper.ts
2026-01-07 03:10:40 +01:00

617 lines
16 KiB
TypeScript

/**
* 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 | undefined
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<void> | null = null
private readonly config: IndexedDBConfig
constructor(config: IndexedDBConfig) {
this.config = config
}
/**
* Initialize the IndexedDB database
*/
async init(): Promise<IDBDatabase> {
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<void> {
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<IDBObjectStore> {
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<IDBObjectStore> {
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<T = unknown>(key: string | number): Promise<T | null> {
try {
const store = await this.getStore('readonly')
return new Promise<T | null>((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<T = unknown>(indexName: string, key: string | number): Promise<T | null> {
try {
const store = await this.getStore('readonly')
const index = store.index(indexName)
return new Promise<T | null>((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<T = unknown>(indexName: string, key?: IDBValidKey | IDBKeyRange): Promise<T[]> {
try {
const store = await this.getStore('readonly')
const index = store.index(indexName)
return new Promise<T[]>((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<T = unknown>(value: T): Promise<void> {
try {
const store = await this.getStoreWrite('readwrite')
return new Promise<void>((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<T = unknown>(value: T): Promise<void> {
try {
const store = await this.getStoreWrite('readwrite')
return new Promise<void>((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<void> {
try {
const store = await this.getStoreWrite('readwrite')
return new Promise<void>((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<void> {
try {
const store = await this.getStoreWrite('readwrite')
return new Promise<void>((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<IDBCursorWithValue | null> {
try {
const store = await this.getStore('readonly')
return new Promise<IDBCursorWithValue | null>((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<IDBCursorWithValue | null> {
try {
const store = await this.getStore('readonly')
const index = store.index(indexName)
return new Promise<IDBCursorWithValue | null>((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<number> {
try {
const store = await this.getStore('readonly')
return new Promise<number>((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<number> {
try {
const store = await this.getStore('readonly')
const index = store.index(indexName)
return new Promise<number>((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<T = unknown>(range?: IDBKeyRange, count?: number): Promise<T[]> {
try {
const store = await this.getStore('readonly')
return new Promise<T[]>((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 }