/**
* Cache Operations
*
* Cache with TTL support using SQLite.
*/
import { getDatabase, persistDatabase } from "./database.js";
import { createLogger } from "../shared/logger.js";
import { withSpan, SpanAttributes, SpanOperations } from "../shared/index.js";
import { DEFAULT_CACHE_TTL_HOURS, MS_PER_HOUR } from "../shared/constants.js";
const logger = createLogger("Cache");
export interface CacheEntry<T> {
data: T;
source: "disney" | "themeparks-wiki";
cachedAt: string;
expiresAt: string;
}
export interface CacheSetOptions {
/** TTL in hours */
ttlHours?: number;
/** Data source identifier */
source?: "disney" | "themeparks-wiki";
}
/**
* Get a cached value if not expired.
*/
export async function cacheGet<T>(key: string): Promise<CacheEntry<T> | null> {
return withSpan(`cache.get ${key}`, SpanOperations.CACHE_GET, async (span) => {
span?.setAttribute(SpanAttributes.CACHE_KEY, key);
const db = await getDatabase();
const now = new Date().toISOString();
const result = db.exec(
`SELECT data, source, cached_at, expires_at
FROM cache
WHERE key = ? AND expires_at > ?`,
[key, now]
);
const firstResult = result[0];
if (!firstResult || firstResult.values.length === 0) {
span?.setAttribute(SpanAttributes.CACHE_HIT, false);
return null;
}
const row = firstResult.values[0];
if (!row) {
span?.setAttribute(SpanAttributes.CACHE_HIT, false);
return null;
}
try {
logger.debug("Cache hit", { key });
span?.setAttribute(SpanAttributes.CACHE_HIT, true);
return {
data: JSON.parse(String(row[0])) as T,
source: String(row[1]) as "disney" | "themeparks-wiki",
cachedAt: String(row[2]),
expiresAt: String(row[3]),
};
} catch (error) {
logger.warn("Failed to parse cached data", { key, error });
span?.setAttribute(SpanAttributes.CACHE_HIT, false);
await cacheDelete(key);
return null;
}
});
}
/**
* Set a cached value with TTL.
*/
export async function cacheSet(
key: string,
data: unknown,
options: CacheSetOptions = {}
): Promise<void> {
return withSpan(`cache.set ${key}`, SpanOperations.CACHE_SET, async (span) => {
span?.setAttribute(SpanAttributes.CACHE_KEY, key);
const db = await getDatabase();
const ttlHours = options.ttlHours ?? DEFAULT_CACHE_TTL_HOURS;
const source = options.source ?? "themeparks-wiki";
span?.setAttribute("cache.ttl_hours", ttlHours);
const now = new Date();
const cachedAt = now.toISOString();
const expiresAt = new Date(now.getTime() + ttlHours * MS_PER_HOUR).toISOString();
db.run(
`INSERT OR REPLACE INTO cache (key, data, source, cached_at, expires_at)
VALUES (?, ?, ?, ?, ?)`,
[key, JSON.stringify(data), source, cachedAt, expiresAt]
);
persistDatabase();
logger.debug("Cache set", { key, ttlHours, expiresAt });
});
}
/**
* Delete a cached value.
*/
export async function cacheDelete(key: string): Promise<boolean> {
return withSpan(`cache.delete ${key}`, SpanOperations.CACHE_DELETE, async (span) => {
span?.setAttribute(SpanAttributes.CACHE_KEY, key);
const db = await getDatabase();
// Check if exists first
const check = db.exec("SELECT 1 FROM cache WHERE key = ?", [key]);
const checkResult = check[0];
if (!checkResult || checkResult.values.length === 0) {
span?.setAttribute("cache.deleted", false);
return false;
}
db.run("DELETE FROM cache WHERE key = ?", [key]);
persistDatabase();
span?.setAttribute("cache.deleted", true);
return true;
});
}
/**
* Clear all expired cache entries.
*/
export async function cachePurgeExpired(): Promise<number> {
return withSpan("cache.purge-expired", SpanOperations.CACHE_DELETE, async (span) => {
const db = await getDatabase();
const now = new Date().toISOString();
// Count before delete
const countResult = db.exec("SELECT COUNT(*) FROM cache WHERE expires_at <= ?", [now]);
const count = (countResult[0]?.values[0]?.[0] as number) ?? 0;
span?.setAttribute("cache.purged_count", count);
if (count > 0) {
db.run("DELETE FROM cache WHERE expires_at <= ?", [now]);
persistDatabase();
logger.info("Purged expired cache entries", { count });
}
return count;
});
}
/**
* Clear all cache entries.
*/
export async function cacheClear(): Promise<number> {
const db = await getDatabase();
const countResult = db.exec("SELECT COUNT(*) FROM cache");
const count = (countResult[0]?.values[0]?.[0] as number) ?? 0;
db.run("DELETE FROM cache");
persistDatabase();
logger.info("Cleared all cache entries", { count });
return count;
}
/**
* Get cache statistics.
*/
export interface CacheStats {
totalEntries: number;
expiredEntries: number;
sources: Record<string, number>;
}
export async function getCacheStats(): Promise<CacheStats> {
const db = await getDatabase();
const now = new Date().toISOString();
const totalResult = db.exec("SELECT COUNT(*) FROM cache");
const total = (totalResult[0]?.values[0]?.[0] as number) ?? 0;
const expiredResult = db.exec("SELECT COUNT(*) FROM cache WHERE expires_at <= ?", [now]);
const expired = (expiredResult[0]?.values[0]?.[0] as number) ?? 0;
const sourcesResult = db.exec("SELECT source, COUNT(*) as count FROM cache GROUP BY source");
const sources: Record<string, number> = {};
const sourcesData = sourcesResult[0];
if (sourcesData) {
for (const row of sourcesData.values) {
if (row) {
sources[String(row[0])] = Number(row[1]);
}
}
}
return {
totalEntries: total,
expiredEntries: expired,
sources,
};
}