github-actions[bot]
Sync from https://github.com/felladrin/MiniSearch
6e29063
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<TextSearchCache, string>;
imageSearchHistory!: Table<ImageSearchCache, string>;
constructor() {
super(name);
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 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<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);