617 lines
16 KiB
TypeScript
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 }
|