/**
* TTL-based caching layer using SQLite
*
* Provides get/set/delete operations with automatic expiration.
* Thread-safe via SQLite transactions.
*/
import { getDatabase, saveDatabase } from './database.js';
import { getConfig } from '../config/index.js';
import { createLogger } from '../shared/logger.js';
const logger = createLogger('cache');
/**
* Get a value from cache
*/
export function cacheGet<T>(key: string): T | null {
const config = getConfig();
if (!config.cacheEnabled) {
return null;
}
const db = getDatabase();
const now = Date.now();
try {
const stmt = db.prepare('SELECT value, expires_at FROM cache WHERE key = ?');
stmt.bind([key]);
if (stmt.step()) {
const row = stmt.getAsObject() as { value: string; expires_at: number };
stmt.free();
// Check expiration
if (row.expires_at < now) {
// Expired, delete and return null
db.run('DELETE FROM cache WHERE key = ?', [key]);
logger.debug('Cache miss (expired)', { key });
return null;
}
// Update hit count
db.run('UPDATE cache SET hit_count = hit_count + 1 WHERE key = ?', [key]);
logger.debug('Cache hit', { key });
return JSON.parse(row.value) as T;
}
stmt.free();
logger.debug('Cache miss', { key });
return null;
} catch (error) {
logger.warning('Cache get error', { key, error: String(error) });
return null;
}
}
/**
* Set a value in cache with TTL
*/
export function cacheSet<T>(key: string, value: T, ttlSeconds?: number): void {
const config = getConfig();
if (!config.cacheEnabled) {
return;
}
const db = getDatabase();
const now = Date.now();
const ttl = ttlSeconds ?? config.cacheTtlSeconds;
const expiresAt = now + ttl * 1000;
try {
db.run(
`INSERT OR REPLACE INTO cache (key, value, expires_at, created_at, hit_count)
VALUES (?, ?, ?, ?, 0)`,
[key, JSON.stringify(value), expiresAt, now]
);
logger.debug('Cache set', { key, ttlSeconds: ttl });
// Periodically save to disk (every 10 writes)
const countResult = db.exec('SELECT COUNT(*) as count FROM cache');
if (countResult[0]?.values[0]?.[0]) {
const count = countResult[0].values[0][0] as number;
if (count % 10 === 0) {
saveDatabase();
}
}
} catch (error) {
logger.warning('Cache set error', { key, error: String(error) });
}
}
/**
* Delete a value from cache
*/
export function cacheDelete(key: string): boolean {
const config = getConfig();
if (!config.cacheEnabled) {
return false;
}
const db = getDatabase();
try {
db.run('DELETE FROM cache WHERE key = ?', [key]);
const changes = db.getRowsModified();
logger.debug('Cache delete', { key, deleted: changes > 0 });
return changes > 0;
} catch (error) {
logger.warning('Cache delete error', { key, error: String(error) });
return false;
}
}
/**
* Clear all cache entries
*/
export function cacheClear(): void {
const db = getDatabase();
try {
db.run('DELETE FROM cache');
logger.info('Cache cleared');
} catch (error) {
logger.warning('Cache clear error', { error: String(error) });
}
}
/**
* Get cache statistics
*/
export function cacheStats(): {
totalEntries: number;
expiredEntries: number;
totalHits: number;
} {
const db = getDatabase();
const now = Date.now();
try {
const total = db.exec('SELECT COUNT(*) FROM cache');
const expired = db.exec('SELECT COUNT(*) FROM cache WHERE expires_at < ?', [now]);
const hits = db.exec('SELECT SUM(hit_count) FROM cache');
return {
totalEntries: (total[0]?.values[0]?.[0] as number) ?? 0,
expiredEntries: (expired[0]?.values[0]?.[0] as number) ?? 0,
totalHits: (hits[0]?.values[0]?.[0] as number) ?? 0,
};
} catch {
return { totalEntries: 0, expiredEntries: 0, totalHits: 0 };
}
}
/**
* Cleanup expired entries
*/
export function cacheCleanup(): number {
const db = getDatabase();
const now = Date.now();
try {
db.run('DELETE FROM cache WHERE expires_at < ?', [now]);
const deleted = db.getRowsModified();
if (deleted > 0) {
logger.info('Cache cleanup', { deleted });
saveDatabase();
}
return deleted;
} catch (error) {
logger.warning('Cache cleanup error', { error: String(error) });
return 0;
}
}
/**
* Create a cache key with namespace
*/
export function makeCacheKey(namespace: string, ...parts: string[]): string {
return `${namespace}:${parts.join(':')}`;
}