import { EncryptionUtils } from '@repo/utils'; import type { IStorageService } from './storage.interface'; import { ENCRYPTED_KEYS } from './storage.key'; // ─── Types ────────────────────────────────────────────────────── interface IndexedDBConfig { /** Database name. @default 'app_db' */ dbName?: string; /** Object store name. @default 'kv_store' */ storeName?: string; /** Database version. @default 1 */ version?: number; } // ─── Helpers ──────────────────────────────────────────────────── /** * Open (or create) an IndexedDB database with a simple key-value store. * Returns a Promise that resolves with the IDBDatabase instance. */ function openDatabase( dbName: string, storeName: string, version: number, ): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(dbName, version); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(storeName)) { db.createObjectStore(storeName); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } /** * Execute a single IndexedDB transaction and return the result. * Handles open → transaction → request → close lifecycle cleanly. */ function withTransaction( db: IDBDatabase, storeName: string, mode: IDBTransactionMode, operation: (store: IDBObjectStore) => IDBRequest, ): Promise { return new Promise((resolve, reject) => { const tx = db.transaction(storeName, mode); const store = tx.objectStore(storeName); const request = operation(store); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } // ─── Service ──────────────────────────────────────────────────── /** * Enterprise-grade IndexedDB wrapper with optional AES encryption. * * Uses a simple key-value object store pattern. Keys listed in * `ENCRYPTED_KEYS` are automatically encrypted/decrypted using * `@repo/utils` `EncryptionUtils`. * * Unlike localStorage, IndexedDB has no 5MB size limit — making * it suitable for large payloads like cached API responses, offline * data, or file blobs. * * @example * ```ts * const idb = new IndexedDBService({ dbName: 'my_app' }); * await idb.setItem('large_dataset', hugePayload); * const data = await idb.getItem('large_dataset'); * ``` */ export class IndexedDBService implements IStorageService { private readonly encryption: EncryptionUtils; private readonly dbName: string; private readonly storeName: string; private readonly version: number; private dbPromise: Promise | null = null; constructor(config?: IndexedDBConfig, encryptionUtils?: EncryptionUtils) { this.encryption = encryptionUtils ?? EncryptionUtils.getInstance(); this.dbName = config?.dbName ?? 'app_db'; this.storeName = config?.storeName ?? 'kv_store'; this.version = config?.version ?? 1; } /** Lazy-open the database connection (cached). */ private getDB(): Promise { if (!this.dbPromise) { this.dbPromise = openDatabase(this.dbName, this.storeName, this.version); } return this.dbPromise; } private shouldEncrypt(key: string): boolean { return ENCRYPTED_KEYS.has(key); } async setItem(key: string, value: T): Promise { const db = await this.getDB(); const serialized = JSON.stringify(value); const payload = this.shouldEncrypt(key) ? this.encryption.encrypt(serialized) : serialized; await withTransaction(db, this.storeName, 'readwrite', (store) => store.put(payload, key), ); } async getItem(key: string): Promise { const db = await this.getDB(); const raw = await withTransaction( db, this.storeName, 'readonly', (store) => store.get(key) as IDBRequest, ); if (raw === undefined || raw === null) return null; try { if (this.shouldEncrypt(key)) { const decrypted = this.encryption.decrypt(raw); if (!decrypted) return null; return JSON.parse(decrypted) as T; } return JSON.parse(raw) as T; } catch { console.warn(`[core-storage] Failed to parse IndexedDB key "${key}". Removing corrupt entry.`); await this.removeItem(key); return null; } } async removeItem(key: string): Promise { const db = await this.getDB(); await withTransaction(db, this.storeName, 'readwrite', (store) => store.delete(key), ); } async clear(): Promise { const db = await this.getDB(); await withTransaction(db, this.storeName, 'readwrite', (store) => store.clear(), ); } async hasItem(key: string): Promise { const value = await this.getItem(key); return value !== null; } async keys(): Promise { const db = await this.getDB(); return withTransaction( db, this.storeName, 'readonly', (store) => store.getAllKeys() as IDBRequest, ); } }