Files
trackgo-fe/packages/core-storage/src/indexed-db/indexed-db.service.ts
T
shancheas 67ae5b6c11 refactor: improve code formatting and consistency across multiple files
- Standardized import statements and removed unnecessary line breaks for better readability in various components.
- Enhanced error handling and logging in the useElectronPrinter hook.
- Updated sample data formatting in AgGridShowcase for improved clarity.
- Refactored JSX elements for consistent indentation and structure in LandingSample, AuthPage, and EventsPage components.
- Consolidated and simplified conditional rendering logic in several components.

These changes aim to enhance code maintainability and readability throughout the project.
2026-08-25 17:50:48 +07:00

214 lines
7.0 KiB
TypeScript

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.
* Resolves only when the entire transaction is completed to prevent silent failures.
*/
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);
let requestResult: R;
const request = operation(store);
request.onsuccess = () => {
requestResult = request.result;
};
request.onerror = () => reject(request.error);
// Resolve promise ONLY after transaction is successfully committed
tx.oncomplete = () => resolve(requestResult);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.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 readonly personalizedKeys?: Set<TKey>;
private readonly getUserId?: () => string | null | undefined;
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();
this.personalizedKeys = config?.personalizedKeys;
this.getUserId = config?.getUserId;
}
/** 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);
}
private resolveKey(key: TKey): string {
if (this.personalizedKeys?.has(key)) {
const userId = this.getUserId?.() || 'guest';
return `${String(key)}_${userId}`;
}
return String(key);
}
async setItem<T>(key: TKey, value: T): Promise<void> {
this.validateKey(key);
const db = await this.getDB();
const resolvedKey = this.resolveKey(key);
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, resolvedKey));
}
async getItem<T>(key: TKey): Promise<T | null> {
this.validateKey(key);
const db = await this.getDB();
const resolvedKey = this.resolveKey(key);
const raw = await withTransaction<string | undefined>(
db,
this.storeName,
'readonly',
(store) => store.get(resolvedKey) as IDBRequest<string | undefined>,
);
if (raw === undefined || raw === null) return null;
try {
const decrypted = this.shouldEncrypt(key) ? this.encryption.decrypt(raw) : raw;
if (!decrypted) return null;
return JSON.parse(decrypted) as T;
} catch {
console.warn(`[core-storage] Failed to parse IndexedDB key "${String(key)}". Removing corrupt entry.`);
await this.removeItem(key);
return null;
}
}
async removeItem(key: TKey): Promise<void> {
this.validateKey(key);
const db = await this.getDB();
const resolvedKey = this.resolveKey(key);
await withTransaction(db, this.storeName, 'readwrite', (store) => store.delete(resolvedKey));
}
async clear(): Promise<void> {
const allValidKeys = await this.keys();
for (const key of allValidKeys) {
await this.removeItem(key);
}
}
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 rawKeys = await withTransaction<string[]>(
db,
this.storeName,
'readonly',
(store) => store.getAllKeys() as IDBRequest<string[]>,
);
const resultSet = new Set<TKey>();
const currentUserId = this.getUserId?.() || 'guest';
for (const rawKey of rawKeys) {
const isGlobalKey = this.encryptedKeys.has(rawKey as TKey) || this.plainTextKeys.has(rawKey as TKey);
if (isGlobalKey && !this.personalizedKeys?.has(rawKey as TKey)) {
resultSet.add(rawKey as TKey);
}
if (this.personalizedKeys) {
for (const pKey of this.personalizedKeys) {
if (rawKey === `${String(pKey)}_${currentUserId}`) {
resultSet.add(pKey);
break;
}
}
}
}
return Array.from(resultSet);
}
}
export function createIndexedDB<TKey extends string>(config: IndexedDBConfig<TKey>): IStorageService<TKey> {
return new IndexedDBService<TKey>(config);
}