All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m23s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Add allKeys() to IndexedDBStorage to eliminate Auth0 CacheKeyManifest fallback, revert set()/remove() to non-blocking persist, add auth error display on callback route, remove leaky force-auth-check interceptor, and migrate debug console calls to centralized logger. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
253 lines
7.2 KiB
TypeScript
253 lines
7.2 KiB
TypeScript
/**
|
|
* @ai-summary IndexedDB storage adapter for Auth0 and Zustand persistence
|
|
* @ai-context Replaces localStorage with IndexedDB for mobile browser compatibility
|
|
*/
|
|
|
|
import logger from '../../utils/logger';
|
|
|
|
interface StorageAdapter {
|
|
getItem(key: string): string | null;
|
|
setItem(key: string, value: string): void;
|
|
removeItem(key: string): void;
|
|
clear(): void;
|
|
key(index: number): string | null;
|
|
readonly length: number;
|
|
}
|
|
|
|
interface Auth0Cache {
|
|
get(key: string): Promise<any>;
|
|
set(key: string, value: any): Promise<void>;
|
|
remove(key: string): Promise<void>;
|
|
}
|
|
|
|
class IndexedDBStorage implements StorageAdapter, Auth0Cache {
|
|
private dbName = 'motovaultpro-storage';
|
|
private dbVersion = 1;
|
|
private storeName = 'keyvalue';
|
|
private db: IDBDatabase | null = null;
|
|
private memoryCache = new Map<string, string>();
|
|
private initPromise: Promise<void>;
|
|
private isReady = false;
|
|
|
|
constructor() {
|
|
this.initPromise = this.initialize();
|
|
}
|
|
|
|
private async initialize(): Promise<void> {
|
|
try {
|
|
this.db = await this.openDatabase();
|
|
await this.loadCacheFromDB();
|
|
this.isReady = true;
|
|
logger.debug('IndexedDB storage initialized successfully');
|
|
} catch (error) {
|
|
logger.error('IndexedDB initialization failed, using memory only', { error: String(error) });
|
|
this.isReady = false;
|
|
}
|
|
}
|
|
|
|
private openDatabase(): Promise<IDBDatabase> {
|
|
return new Promise((resolve) => {
|
|
const request = indexedDB.open(this.dbName, this.dbVersion);
|
|
|
|
request.onerror = () => {
|
|
logger.error(`IndexedDB open failed: ${request.error?.message}`);
|
|
resolve(null as any); // Fallback to memory-only mode
|
|
};
|
|
|
|
request.onsuccess = () => {
|
|
resolve(request.result);
|
|
};
|
|
|
|
request.onupgradeneeded = (event) => {
|
|
const db = (event.target as IDBOpenDBRequest).result;
|
|
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
db.createObjectStore(this.storeName);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
private async loadCacheFromDB(): Promise<void> {
|
|
if (!this.db) return;
|
|
|
|
return new Promise((resolve) => {
|
|
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
|
const store = transaction.objectStore(this.storeName);
|
|
const request = store.openCursor();
|
|
this.memoryCache.clear();
|
|
|
|
request.onsuccess = () => {
|
|
const cursor = request.result;
|
|
if (cursor) {
|
|
const key = cursor.key as string;
|
|
const value = cursor.value;
|
|
if (typeof key === 'string' && typeof value === 'string') {
|
|
this.memoryCache.set(key, value);
|
|
}
|
|
cursor.continue();
|
|
} else {
|
|
logger.debug(`IndexedDB loaded ${this.memoryCache.size} items into cache`);
|
|
resolve();
|
|
}
|
|
};
|
|
|
|
request.onerror = () => {
|
|
logger.warn('IndexedDB failed to load cache from DB', { error: String(request.error) });
|
|
resolve();
|
|
};
|
|
});
|
|
}
|
|
|
|
private async persistToDB(key: string, value: string | null): Promise<void> {
|
|
if (!this.db) return;
|
|
|
|
return new Promise((resolve) => {
|
|
const transaction = this.db!.transaction([this.storeName], 'readwrite');
|
|
const store = transaction.objectStore(this.storeName);
|
|
|
|
if (value === null) {
|
|
const request = store.delete(key);
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => {
|
|
logger.warn(`IndexedDB failed to delete ${key}`, { error: String(request.error) });
|
|
resolve();
|
|
};
|
|
} else {
|
|
const request = store.put(value, key);
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => {
|
|
logger.warn(`IndexedDB failed to persist ${key}`, { error: String(request.error) });
|
|
resolve();
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
// Synchronous Storage interface (uses memory cache)
|
|
getItem(key: string): string | null {
|
|
return this.memoryCache.get(key) || null;
|
|
}
|
|
|
|
setItem(key: string, value: string): void {
|
|
this.memoryCache.set(key, value);
|
|
|
|
// Async persist to IndexedDB (non-blocking)
|
|
if (this.isReady) {
|
|
this.persistToDB(key, value).catch(error => {
|
|
logger.warn(`IndexedDB background persist failed for ${key}`, { error: String(error) });
|
|
});
|
|
}
|
|
}
|
|
|
|
removeItem(key: string): void {
|
|
this.memoryCache.delete(key);
|
|
|
|
// Async remove from IndexedDB (non-blocking)
|
|
if (this.isReady) {
|
|
this.persistToDB(key, null).catch(error => {
|
|
logger.warn(`IndexedDB background removal failed for ${key}`, { error: String(error) });
|
|
});
|
|
}
|
|
}
|
|
|
|
clear(): void {
|
|
this.memoryCache.clear();
|
|
|
|
// Async clear IndexedDB (non-blocking)
|
|
if (this.db) {
|
|
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
|
const store = transaction.objectStore(this.storeName);
|
|
store.clear();
|
|
}
|
|
}
|
|
|
|
async clearAll(): Promise<void> {
|
|
await this.initPromise;
|
|
if (!this.db) {
|
|
this.memoryCache.clear();
|
|
return;
|
|
}
|
|
const tx = this.db.transaction(this.storeName, 'readwrite');
|
|
tx.objectStore(this.storeName).clear();
|
|
await new Promise<void>((resolve, reject) => {
|
|
tx.oncomplete = () => {
|
|
this.memoryCache.clear();
|
|
resolve();
|
|
};
|
|
tx.onerror = () => {
|
|
this.memoryCache.clear();
|
|
reject(tx.error);
|
|
};
|
|
tx.onabort = () => {
|
|
this.memoryCache.clear();
|
|
reject(new Error('Transaction aborted'));
|
|
};
|
|
});
|
|
}
|
|
|
|
key(index: number): string | null {
|
|
const keys = Array.from(this.memoryCache.keys());
|
|
return keys[index] || null;
|
|
}
|
|
|
|
get length(): number {
|
|
return this.memoryCache.size;
|
|
}
|
|
|
|
// Auth0 Cache interface implementation
|
|
// allKeys() eliminates Auth0 SDK's CacheKeyManifest fallback (auth0-spa-js line 2319)
|
|
allKeys(): string[] {
|
|
return Array.from(this.memoryCache.keys());
|
|
}
|
|
|
|
async get(key: string): Promise<any> {
|
|
await this.initPromise;
|
|
const value = this.getItem(key);
|
|
return value ? JSON.parse(value) : undefined;
|
|
}
|
|
|
|
async set(key: string, value: any): Promise<void> {
|
|
await this.initPromise;
|
|
const stringValue = JSON.stringify(value);
|
|
this.memoryCache.set(key, stringValue);
|
|
// Fire-and-forget: persist to IndexedDB for page reload survival
|
|
this.persistToDB(key, stringValue).catch(error => {
|
|
logger.warn(`IndexedDB background persist failed for ${key}`, { error: String(error) });
|
|
});
|
|
}
|
|
|
|
async remove(key: string): Promise<void> {
|
|
await this.initPromise;
|
|
this.memoryCache.delete(key);
|
|
// Fire-and-forget: remove from IndexedDB
|
|
this.persistToDB(key, null).catch(error => {
|
|
logger.warn(`IndexedDB background removal failed for ${key}`, { error: String(error) });
|
|
});
|
|
}
|
|
|
|
// Additional methods for enhanced functionality
|
|
async waitForReady(): Promise<void> {
|
|
return this.initPromise;
|
|
}
|
|
|
|
get isInitialized(): boolean {
|
|
return this.isReady;
|
|
}
|
|
|
|
// For debugging
|
|
getStats() {
|
|
return {
|
|
cacheSize: this.memoryCache.size,
|
|
isReady: this.isReady,
|
|
hasDB: !!this.db
|
|
};
|
|
}
|
|
}
|
|
|
|
// Create singleton instance
|
|
export const indexedDBStorage = new IndexedDBStorage();
|
|
|
|
// For Auth0 compatibility - ensure storage is ready before use
|
|
export const createIndexedDBAdapter = () => {
|
|
return indexedDBStorage;
|
|
}; |