feat(core-storage): add PouchEnvelope tests and LocalStorageService implementation
- Introduced tests for PouchEnvelope to validate envelope-aware CRUD operations using an in-memory PouchDB. - Implemented LocalStorageService with encryption support for sensitive keys, including tests for setItem, getItem, removeItem, and clear methods. - Defined a generic storage interface (IStorageService) to enforce type-safe serialization/deserialization across storage implementations.
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import { EncryptionUtils } from '@repo/utils';
|
||||
import type { IStorageService } from '../types/storage.interface';
|
||||
import type { StorageOptions } from '../local-storage/local-storage.service';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface IndexedDBConfig<TKey extends string> extends StorageOptions<TKey> {
|
||||
/** 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.
|
||||
*/
|
||||
export class IndexedDBService<TKey extends string> implements IStorageService<TKey> {
|
||||
private readonly encryption: EncryptionUtils;
|
||||
private readonly dbName: string;
|
||||
private readonly storeName: string;
|
||||
private readonly version: number;
|
||||
private readonly encryptedKeys: Set<TKey>;
|
||||
private readonly plainTextKeys: Set<TKey>;
|
||||
private dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
constructor(config?: IndexedDBConfig<TKey>, encryptionUtils?: EncryptionUtils) {
|
||||
this.encryption = encryptionUtils ?? EncryptionUtils.getInstance();
|
||||
this.dbName = config?.dbName ?? 'app_db';
|
||||
this.storeName = config?.storeName ?? 'kv_store';
|
||||
this.version = config?.version ?? 1;
|
||||
this.encryptedKeys = config?.encryptedKeys ?? new Set();
|
||||
this.plainTextKeys = config?.plainTextKeys ?? new Set();
|
||||
}
|
||||
|
||||
/** 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 validateKey(key: TKey): void {
|
||||
if (!this.encryptedKeys.has(key) && !this.plainTextKeys.has(key)) {
|
||||
throw new Error(`[Storage Engine] Security Exception: Key '${key}' is not registered and cannot be accessed.`);
|
||||
}
|
||||
}
|
||||
|
||||
private shouldEncrypt(key: TKey): boolean {
|
||||
return this.encryptedKeys.has(key);
|
||||
}
|
||||
|
||||
async setItem<T>(key: TKey, value: T): Promise<void> {
|
||||
this.validateKey(key);
|
||||
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 as string));
|
||||
}
|
||||
|
||||
async getItem<T>(key: TKey): Promise<T | null> {
|
||||
this.validateKey(key);
|
||||
const db = await this.getDB();
|
||||
|
||||
const raw = await withTransaction<string | undefined>(
|
||||
db,
|
||||
this.storeName,
|
||||
'readonly',
|
||||
(store) => store.get(key as string) 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: TKey): Promise<void> {
|
||||
this.validateKey(key);
|
||||
const db = await this.getDB();
|
||||
await withTransaction(db, this.storeName, 'readwrite', (store) => store.delete(key as string));
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
const db = await this.getDB();
|
||||
await withTransaction(db, this.storeName, 'readwrite', (store) => store.clear());
|
||||
}
|
||||
|
||||
async hasItem(key: TKey): Promise<boolean> {
|
||||
const value = await this.getItem(key);
|
||||
return value !== null;
|
||||
}
|
||||
|
||||
async keys(): Promise<TKey[]> {
|
||||
const db = await this.getDB();
|
||||
const allKeys = await withTransaction<string[]>(
|
||||
db,
|
||||
this.storeName,
|
||||
'readonly',
|
||||
(store) => store.getAllKeys() as IDBRequest<string[]>,
|
||||
);
|
||||
return allKeys as TKey[];
|
||||
}
|
||||
}
|
||||
|
||||
export function createIndexedDB<TKey extends string>(config: IndexedDBConfig<TKey>): IStorageService<TKey> {
|
||||
return new IndexedDBService<TKey>(config);
|
||||
}
|
||||
Reference in New Issue
Block a user