175 lines
5.4 KiB
TypeScript
175 lines
5.4 KiB
TypeScript
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<IDBDatabase> {
|
|
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<R>(
|
|
db: IDBDatabase,
|
|
storeName: string,
|
|
mode: IDBTransactionMode,
|
|
operation: (store: IDBObjectStore) => IDBRequest<R>,
|
|
): Promise<R> {
|
|
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<HugePayload>('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<IDBDatabase> | 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<IDBDatabase> {
|
|
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<T>(key: string, value: T): Promise<void> {
|
|
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<T>(key: string): Promise<T | null> {
|
|
const db = await this.getDB();
|
|
|
|
const raw = await withTransaction<string | undefined>(
|
|
db,
|
|
this.storeName,
|
|
'readonly',
|
|
(store) => store.get(key) as IDBRequest<string | undefined>,
|
|
);
|
|
|
|
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<void> {
|
|
const db = await this.getDB();
|
|
await withTransaction(db, this.storeName, 'readwrite', (store) =>
|
|
store.delete(key),
|
|
);
|
|
}
|
|
|
|
async clear(): Promise<void> {
|
|
const db = await this.getDB();
|
|
await withTransaction(db, this.storeName, 'readwrite', (store) =>
|
|
store.clear(),
|
|
);
|
|
}
|
|
|
|
async hasItem(key: string): Promise<boolean> {
|
|
const value = await this.getItem(key);
|
|
return value !== null;
|
|
}
|
|
|
|
async keys(): Promise<string[]> {
|
|
const db = await this.getDB();
|
|
return withTransaction<string[]>(
|
|
db,
|
|
this.storeName,
|
|
'readonly',
|
|
(store) => store.getAllKeys() as IDBRequest<string[]>,
|
|
);
|
|
}
|
|
}
|