managarten/packages/shared-utils/src/cache.ts
2025-12-03 23:42:37 +01:00

205 lines
5.3 KiB
TypeScript

/**
* IndexedDB Cache Utility
* Provides persistent storage for cached data like signed URLs
* Survives page reloads and provides better performance than localStorage
*/
interface CacheEntry<T = string> {
id: string;
data: T;
expires: number;
createdAt: number;
}
interface CacheConfig {
/** Database name */
dbName: string;
/** Store name */
storeName: string;
/** Database version */
version?: number;
}
const dbPromises: Map<string, Promise<IDBDatabase>> = new Map();
/**
* Initialize IndexedDB connection
*/
function getDB(config: CacheConfig): Promise<IDBDatabase> {
const key = `${config.dbName}:${config.storeName}`;
if (dbPromises.has(key)) {
return dbPromises.get(key)!;
}
const promise = new Promise<IDBDatabase>((resolve, reject) => {
if (typeof indexedDB === 'undefined') {
reject(new Error('IndexedDB not available'));
return;
}
const request = indexedDB.open(config.dbName, config.version || 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create store if it doesn't exist
if (!db.objectStoreNames.contains(config.storeName)) {
const store = db.createObjectStore(config.storeName, { keyPath: 'id' });
store.createIndex('expires', 'expires', { unique: false });
}
};
});
dbPromises.set(key, promise);
return promise;
}
/**
* Create a cache instance for a specific database/store
*/
export function createCache<T = string>(config: CacheConfig) {
return {
/**
* Get cached data by ID
* Returns null if not found or expired
*/
async get(id: string): Promise<T | null> {
try {
const db = await getDB(config);
const transaction = db.transaction(config.storeName, 'readonly');
const store = transaction.objectStore(config.storeName);
return new Promise((resolve) => {
const request = store.get(id);
request.onsuccess = () => {
const result = request.result as CacheEntry<T> | undefined;
if (result && Date.now() < result.expires) {
resolve(result.data);
} else {
resolve(null);
}
};
request.onerror = () => {
console.error('Error getting cached data:', request.error);
resolve(null);
};
});
} catch {
return null;
}
},
/**
* Store data in cache
* @param id - Unique identifier for the cached data
* @param data - Data to cache
* @param expiresInMs - Time until expiration in milliseconds
*/
async set(id: string, data: T, expiresInMs: number): Promise<void> {
try {
const db = await getDB(config);
const transaction = db.transaction(config.storeName, 'readwrite');
const store = transaction.objectStore(config.storeName);
const entry: CacheEntry<T> = {
id,
data,
expires: Date.now() + expiresInMs,
createdAt: Date.now(),
};
store.put(entry);
} catch (error) {
console.error('Error caching data:', error);
}
},
/**
* Delete cached data by ID
*/
async delete(id: string): Promise<void> {
try {
const db = await getDB(config);
const transaction = db.transaction(config.storeName, 'readwrite');
const store = transaction.objectStore(config.storeName);
store.delete(id);
} catch (error) {
console.error('Error deleting cached data:', error);
}
},
/**
* Clean up expired entries
* Should be called periodically
*/
async cleanupExpired(): Promise<void> {
try {
const db = await getDB(config);
const transaction = db.transaction(config.storeName, 'readwrite');
const store = transaction.objectStore(config.storeName);
const index = store.index('expires');
const now = Date.now();
const request = index.openCursor(IDBKeyRange.upperBound(now));
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result;
if (cursor) {
cursor.delete();
cursor.continue();
}
};
} catch (error) {
console.error('Error cleaning up expired entries:', error);
}
},
/**
* Clear all cached data
*/
async clear(): Promise<void> {
try {
const db = await getDB(config);
const transaction = db.transaction(config.storeName, 'readwrite');
const store = transaction.objectStore(config.storeName);
store.clear();
} catch (error) {
console.error('Error clearing cache:', error);
}
},
/**
* Check if an item exists and is not expired
*/
async has(id: string): Promise<boolean> {
const data = await this.get(id);
return data !== null;
},
};
}
// Default URL cache for signed URLs (like Supabase storage URLs)
const DEFAULT_URL_CACHE_CONFIG: CacheConfig = {
dbName: 'manacore-cache',
storeName: 'urls',
version: 1,
};
/**
* Pre-configured cache for signed URLs
* Useful for caching Supabase storage signed URLs
*/
export const urlCache = createCache<string>(DEFAULT_URL_CACHE_CONFIG);
// Convenience exports for URL caching (backward compatibility)
export const getCachedUrl = urlCache.get.bind(urlCache);
export const setCachedUrl = urlCache.set.bind(urlCache);
export const deleteCachedUrl = urlCache.delete.bind(urlCache);
export const cleanupExpiredUrls = urlCache.cleanupExpired.bind(urlCache);
export const clearAllCachedUrls = urlCache.clear.bind(urlCache);