Spaces:
Running
Running
| import Dexie, { type Table } from "dexie"; | |
| 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 SearchCacheDatabase extends Dexie { | |
| textSearchHistory!: Table<TextSearchCache, string>; | |
| imageSearchHistory!: Table<ImageSearchCache, string>; | |
| constructor() { | |
| super("SearchCache"); | |
| this.version(1).stores({ | |
| textSearchHistory: "key, timestamp", | |
| imageSearchHistory: "key, timestamp", | |
| }); | |
| } | |
| async ensureIntegrity(): Promise<void> { | |
| 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<void> { | |
| 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<void> { | |
| 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<T extends TextSearchResults | ImageSearchResults>( | |
| 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<T extends TextSearchResults | ImageSearchResults>( | |
| storeName: "textSearchHistory" | "imageSearchHistory", | |
| key: string, | |
| results: T, | |
| ): Promise<void> { | |
| 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 SearchCacheDatabase(); | |
| 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<T>( | |
| endpoint: "text" | "images", | |
| query: string, | |
| limit?: number, | |
| ): Promise<T> { | |
| 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<TextSearchResults> { | |
| try { | |
| await db.cleanExpiredCache("textSearchHistory"); | |
| const key = this.hashQuery(query); | |
| const cachedData = await db.getCachedResult<TextSearchResults>( | |
| "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<TextSearchResults>( | |
| "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<ImageSearchResults> { | |
| try { | |
| await db.cleanExpiredCache("imageSearchHistory"); | |
| const key = this.hashQuery(query); | |
| const cachedData = await db.getCachedResult<ImageSearchResults>( | |
| "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<ImageSearchResults>( | |
| "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<void> { | |
| 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<typeof cacheConfig>) { | |
| 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); | |