import Dexie, { type Table } from "dexie"; import { name } from "../../package.json"; import { addLogEntry } from "./logEntries"; import { getSearchTokenHash } from "./searchTokenHash"; import type { ImageSearchResults, TextSearchResults } from "./types"; const cacheConfig = { ttl: 15 * 60 * 1000, maxEntries: 100, enabled: true, }; const cacheMetrics = { textHits: 0, textMisses: 0, imageHits: 0, imageMisses: 0, getTextHitRate(): number { const total = this.textHits + this.textMisses; return total > 0 ? this.textHits / total : 0; }, getImageHitRate(): number { const total = this.imageHits + this.imageMisses; return total > 0 ? this.imageHits / total : 0; }, logPerformance(): void { addLogEntry( `Cache performance - Text: ${(this.getTextHitRate() * 100).toFixed(1)}% hits, ` + `Image: ${(this.getImageHitRate() * 100).toFixed(1)}% hits`, ); }, }; interface SearchCacheEntry { key: string; timestamp: number; } interface TextSearchCache extends SearchCacheEntry { results: TextSearchResults; } interface ImageSearchCache extends SearchCacheEntry { results: ImageSearchResults; } class SearchDb extends Dexie { textSearchHistory!: Table; imageSearchHistory!: Table; constructor() { super(name); this.version(1).stores({ textSearchHistory: "key, timestamp", imageSearchHistory: "key, timestamp", }); } async ensureIntegrity(): Promise { try { await this.textSearchHistory.count(); } catch (error) { addLogEntry( `Database integrity check failed, rebuilding: ${error instanceof Error ? error.message : String(error)}`, ); try { await this.delete(); await this.open(); } catch (recoveryError) { addLogEntry( `Failed to recover database: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`, ); cacheConfig.enabled = false; } } } async cleanExpiredCache( storeName: "textSearchHistory" | "imageSearchHistory", timeToLive: number = cacheConfig.ttl, ): Promise { const currentTime = Date.now(); const store = this[storeName]; try { const expiredItems = await store .where("timestamp") .below(currentTime - timeToLive) .toArray(); if (expiredItems.length > 0) { await store.bulkDelete(expiredItems.map((item) => item.key)); addLogEntry( `Removed ${expiredItems.length} expired items from ${storeName}`, ); } } catch (error) { addLogEntry( `Error cleaning expired cache: ${error instanceof Error ? error.message : String(error)}`, ); } } async pruneCache( storeName: "textSearchHistory" | "imageSearchHistory", maxEntries: number = cacheConfig.maxEntries, ): Promise { try { const store = this[storeName]; const count = await store.count(); if (count > maxEntries) { const excess = count - maxEntries; const oldestEntries = await store .orderBy("timestamp") .limit(excess) .primaryKeys(); if (oldestEntries.length > 0) { await store.bulkDelete(oldestEntries); addLogEntry( `Pruned ${oldestEntries.length} oldest entries from ${storeName}`, ); } } } catch (error) { addLogEntry( `Error pruning cache: ${error instanceof Error ? error.message : String(error)}`, ); } } async getCachedResult( storeName: "textSearchHistory" | "imageSearchHistory", key: string, ): Promise<{ results: T; fresh: boolean } | null> { if (!cacheConfig.enabled) return null; try { const store = this[storeName] as Table< { key: string; results: T; timestamp: number }, string >; const cachedItem = await store.get(key); if (!cachedItem) return null; const fresh = Date.now() - cachedItem.timestamp < cacheConfig.ttl; return { results: cachedItem.results, fresh }; } catch (error) { addLogEntry( `Error retrieving from cache: ${error instanceof Error ? error.message : String(error)}`, ); return null; } } async cacheResult( storeName: "textSearchHistory" | "imageSearchHistory", key: string, results: T, ): Promise { if (!cacheConfig.enabled) return; try { const store = this[storeName] as Table< { key: string; results: T; timestamp: number }, string >; await store.put({ key, results, timestamp: Date.now(), }); this.pruneCache(storeName).catch((error) => { addLogEntry( `Error during cache pruning: ${error instanceof Error ? error.message : String(error)}`, ); }); } catch (error) { addLogEntry( `Error caching results: ${error instanceof Error ? error.message : String(error)}`, ); } } } const db = new SearchDb(); db.ensureIntegrity().catch((error) => { addLogEntry( `Database initialization error: ${error instanceof Error ? error.message : String(error)}`, ); }); const searchService = { hashQuery(query: string): string { return query .split("") .reduce((acc, char) => ((acc << 5) - acc + char.charCodeAt(0)) | 0, 0) .toString(36); }, async performSearch( endpoint: "text" | "images", query: string, limit?: number, ): Promise { const searchUrl = new URL(`/search/${endpoint}`, self.location.origin); searchUrl.searchParams.set("q", query); searchUrl.searchParams.set("token", await getSearchTokenHash()); if (limit) searchUrl.searchParams.set("limit", limit.toString()); const response = await fetch(searchUrl.toString()); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); }, async searchText(query: string, limit?: number): Promise { try { await db.cleanExpiredCache("textSearchHistory"); const key = this.hashQuery(query); const cachedData = await db.getCachedResult( "textSearchHistory", key, ); if (cachedData?.fresh) { cacheMetrics.textHits++; addLogEntry( `Text search: Reused ${cachedData.results.length} results from the cache`, ); return cachedData.results; } cacheMetrics.textMisses++; const results = await this.performSearch( "text", query, limit, ); await db.cacheResult("textSearchHistory", key, results); if ((cacheMetrics.textHits + cacheMetrics.textMisses) % 10 === 0) { cacheMetrics.logPerformance(); } addLogEntry( `Text search: Fetched ${results.length} results from the API`, ); return results; } catch (error) { addLogEntry( `Text search failed: ${error instanceof Error ? error.message : String(error)}`, ); return []; } }, async searchImages( query: string, limit?: number, ): Promise { try { await db.cleanExpiredCache("imageSearchHistory"); const key = this.hashQuery(query); const cachedData = await db.getCachedResult( "imageSearchHistory", key, ); if (cachedData?.fresh) { cacheMetrics.imageHits++; addLogEntry( `Image search: Reused ${cachedData.results.length} results from the cache`, ); return cachedData.results; } cacheMetrics.imageMisses++; const results = await this.performSearch( "images", query, limit, ); await db.cacheResult("imageSearchHistory", key, results); if ((cacheMetrics.imageHits + cacheMetrics.imageMisses) % 10 === 0) { cacheMetrics.logPerformance(); } addLogEntry( `Image search: Fetched ${results.length} results from the API`, ); return results; } catch (error) { addLogEntry( `Image search failed: ${error instanceof Error ? error.message : String(error)}`, ); return []; } }, async clearSearchCache(): Promise { try { await db.delete(); db.version(1).stores({ textSearchHistory: "key, timestamp", imageSearchHistory: "key, timestamp", }); await db.open(); cacheMetrics.textHits = 0; cacheMetrics.textMisses = 0; cacheMetrics.imageHits = 0; cacheMetrics.imageMisses = 0; addLogEntry("Search cache cleared successfully"); } catch (error) { addLogEntry( `Failed to clear search cache: ${error instanceof Error ? error.message : String(error)}`, ); } }, getCacheStats() { return { textHitRate: cacheMetrics.getTextHitRate(), imageHitRate: cacheMetrics.getImageHitRate(), textHits: cacheMetrics.textHits, textMisses: cacheMetrics.textMisses, imageHits: cacheMetrics.imageHits, imageMisses: cacheMetrics.imageMisses, config: { ...cacheConfig }, }; }, updateCacheConfig(newConfig: Partial) { Object.assign(cacheConfig, newConfig); addLogEntry( `Cache configuration updated: TTL=${cacheConfig.ttl}ms, maxEntries=${cacheConfig.maxEntries}, enabled=${cacheConfig.enabled}`, ); }, }; export const searchText = searchService.searchText.bind(searchService); export const searchImages = searchService.searchImages.bind(searchService);