refactor: simplify form draft key generation
This commit is contained in:
@@ -37,7 +37,7 @@ function openDatabase(dbName: string, storeName: string, version: number): Promi
|
||||
|
||||
/**
|
||||
* Execute a single IndexedDB transaction and return the result.
|
||||
* Handles open → transaction → request → close lifecycle cleanly.
|
||||
* Resolves only when the entire transaction is completed to prevent silent failures.
|
||||
*/
|
||||
function withTransaction<R>(
|
||||
db: IDBDatabase,
|
||||
@@ -48,10 +48,20 @@ function withTransaction<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 = () => resolve(request.result);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,6 +77,8 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
|
||||
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) {
|
||||
@@ -76,6 +88,8 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
|
||||
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). */
|
||||
@@ -96,37 +110,46 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
|
||||
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, key as string));
|
||||
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(key as string) as IDBRequest<string | undefined>,
|
||||
(store) => store.get(resolvedKey) 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;
|
||||
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 "${key}". Removing corrupt entry.`);
|
||||
console.warn(`[core-storage] Failed to parse IndexedDB key "${String(key)}". Removing corrupt entry.`);
|
||||
await this.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
@@ -135,12 +158,16 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
|
||||
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));
|
||||
const resolvedKey = this.resolveKey(key);
|
||||
|
||||
await withTransaction(db, this.storeName, 'readwrite', (store) => store.delete(resolvedKey));
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
const db = await this.getDB();
|
||||
await withTransaction(db, this.storeName, 'readwrite', (store) => store.clear());
|
||||
const allValidKeys = await this.keys();
|
||||
for (const key of allValidKeys) {
|
||||
await this.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
async hasItem(key: TKey): Promise<boolean> {
|
||||
@@ -150,13 +177,34 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
|
||||
|
||||
async keys(): Promise<TKey[]> {
|
||||
const db = await this.getDB();
|
||||
const allKeys = await withTransaction<string[]>(
|
||||
const rawKeys = await withTransaction<string[]>(
|
||||
db,
|
||||
this.storeName,
|
||||
'readonly',
|
||||
(store) => store.getAllKeys() as IDBRequest<string[]>,
|
||||
);
|
||||
return allKeys as TKey[];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { IStorageService } from '../types/storage.interface';
|
||||
export interface StorageOptions<TKey extends string> {
|
||||
encryptedKeys?: Set<TKey>;
|
||||
plainTextKeys?: Set<TKey>;
|
||||
personalizedKeys?: Set<TKey>;
|
||||
getUserId?: () => string | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -13,11 +15,15 @@ export class LocalStorageService<TKey extends string> implements IStorageService
|
||||
private readonly encryption: EncryptionUtils;
|
||||
private readonly encryptedKeys: Set<TKey>;
|
||||
private readonly plainTextKeys: Set<TKey>;
|
||||
private readonly personalizedKeys?: Set<TKey>;
|
||||
private readonly getUserId?: () => string | null | undefined;
|
||||
|
||||
constructor(options?: StorageOptions<TKey>, encryptionUtils?: EncryptionUtils) {
|
||||
this.encryption = encryptionUtils ?? EncryptionUtils.getInstance();
|
||||
this.encryptedKeys = options?.encryptedKeys ?? new Set();
|
||||
this.plainTextKeys = options?.plainTextKeys ?? new Set();
|
||||
this.personalizedKeys = options?.personalizedKeys;
|
||||
this.getUserId = options?.getUserId;
|
||||
}
|
||||
|
||||
private validateKey(key: TKey): void {
|
||||
@@ -26,25 +32,35 @@ export class LocalStorageService<TKey extends string> implements IStorageService
|
||||
}
|
||||
}
|
||||
|
||||
private resolveKey(key: TKey): string {
|
||||
if (this.personalizedKeys?.has(key)) {
|
||||
const userId = this.getUserId?.() || 'guest';
|
||||
return `${String(key)}_${userId}`;
|
||||
}
|
||||
return String(key);
|
||||
}
|
||||
|
||||
private shouldEncrypt(key: TKey): boolean {
|
||||
return this.encryptedKeys.has(key);
|
||||
}
|
||||
|
||||
async setItem<T>(key: TKey, value: T): Promise<void> {
|
||||
this.validateKey(key);
|
||||
const resolvedKey = this.resolveKey(key);
|
||||
const serialized = JSON.stringify(value);
|
||||
|
||||
if (this.shouldEncrypt(key)) {
|
||||
const encrypted = this.encryption.encrypt(serialized);
|
||||
localStorage.setItem(key as string, encrypted);
|
||||
localStorage.setItem(resolvedKey, encrypted);
|
||||
} else {
|
||||
localStorage.setItem(key as string, serialized);
|
||||
localStorage.setItem(resolvedKey, serialized);
|
||||
}
|
||||
}
|
||||
|
||||
async getItem<T>(key: TKey): Promise<T | null> {
|
||||
this.validateKey(key);
|
||||
const raw = localStorage.getItem(key as string);
|
||||
const resolvedKey = this.resolveKey(key);
|
||||
const raw = localStorage.getItem(resolvedKey);
|
||||
if (raw === null) return null;
|
||||
|
||||
try {
|
||||
@@ -55,15 +71,16 @@ export class LocalStorageService<TKey extends string> implements IStorageService
|
||||
}
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
console.warn(`[core-storage] Failed to parse key "${key}". Removing corrupt entry.`);
|
||||
localStorage.removeItem(key as string);
|
||||
console.warn(`[core-storage] Failed to parse key "${String(key)}". Removing corrupt entry.`);
|
||||
localStorage.removeItem(resolvedKey);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async removeItem(key: TKey): Promise<void> {
|
||||
this.validateKey(key);
|
||||
localStorage.removeItem(key as string);
|
||||
const resolvedKey = this.resolveKey(key);
|
||||
localStorage.removeItem(resolvedKey);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
@@ -71,7 +88,8 @@ export class LocalStorageService<TKey extends string> implements IStorageService
|
||||
}
|
||||
|
||||
async hasItem(key: TKey): Promise<boolean> {
|
||||
return localStorage.getItem(key as string) !== null;
|
||||
const resolvedKey = this.resolveKey(key);
|
||||
return localStorage.getItem(resolvedKey) !== null;
|
||||
}
|
||||
|
||||
async keys(): Promise<TKey[]> {
|
||||
|
||||
Reference in New Issue
Block a user