/**
* Smart in-memory cache with LRU eviction and adaptive TTL
*/
interface CacheEntry<T> {
data: T;
timestamp: number;
size: number;
hits: number;
}
export interface CacheOptions {
defaultTTL?: number;
maxSize?: number;
cleanupInterval?: number;
}
export class CacheManager {
private cache = new Map<string, CacheEntry<any>>();
private sizeUsed = 0;
private readonly maxSize: number;
private readonly defaultTTL: number;
private cleanupTimer: NodeJS.Timeout | null = null;
private readonly ttlByPattern: Map<RegExp, number>;
constructor(options: CacheOptions = {}) {
this.maxSize = options.maxSize ?? 50 * 1024 * 1024; // 50MB default
this.defaultTTL = options.defaultTTL ?? 5 * 60 * 1000; // 5 minutes default
// Adaptive TTL based on HN content type
this.ttlByPattern = new Map([
[/^stories:top/, 5 * 60 * 1000], // Top stories: 5 minutes
[/^stories:new/, 2 * 60 * 1000], // New stories: 2 minutes
[/^stories:best/, 30 * 60 * 1000], // Best stories: 30 minutes
[/^story:/, 10 * 60 * 1000], // Individual stories: 10 minutes
[/^user:/, 15 * 60 * 1000], // User data: 15 minutes
[/^search:/, 10 * 60 * 1000], // Search results: 10 minutes
[/^explain:/, 60 * 60 * 1000], // HN explanations: 1 hour (static)
]);
// Start cleanup interval
if (options.cleanupInterval !== 0) {
this.startCleanup(options.cleanupInterval ?? 60000); // Every minute
}
}
static createKey(...parts: any[]): string {
return parts
.filter((p) => p !== undefined && p !== null)
.map((p) => String(p))
.join(':')
.toLowerCase();
}
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
// Check if expired
const ttl = this.getTTLForKey(key);
if (Date.now() - entry.timestamp > ttl) {
this.delete(key);
return null;
}
// Update hit count for LRU tracking
entry.hits++;
return entry.data as T;
}
set<T>(key: string, data: T, _customTTL?: number): void {
const size = this.estimateSize(data);
// Evict entries if needed to make room
while (this.sizeUsed + size > this.maxSize && this.cache.size > 0) {
this.evictLRU();
}
// Remove old entry if exists
if (this.cache.has(key)) {
this.delete(key);
}
// Add new entry
const entry: CacheEntry<T> = {
data,
timestamp: Date.now(),
size,
hits: 0
};
this.cache.set(key, entry);
this.sizeUsed += size;
}
has(key: string): boolean {
const entry = this.cache.get(key);
if (!entry) return false;
const ttl = this.getTTLForKey(key);
if (Date.now() - entry.timestamp > ttl) {
this.delete(key);
return false;
}
return true;
}
delete(key: string): boolean {
const entry = this.cache.get(key);
if (!entry) return false;
this.sizeUsed -= entry.size;
return this.cache.delete(key);
}
clear(): void {
this.cache.clear();
this.sizeUsed = 0;
}
/**
* Get cache statistics
*/
getStats() {
const totalHits = Array.from(this.cache.values())
.reduce((sum, entry) => sum + entry.hits, 0);
return {
entries: this.cache.size,
sizeUsed: this.sizeUsed,
maxSize: this.maxSize,
sizeUsedMB: (this.sizeUsed / 1024 / 1024).toFixed(2),
maxSizeMB: (this.maxSize / 1024 / 1024).toFixed(2),
hitRate: this.cache.size > 0 ? (totalHits / this.cache.size).toFixed(2) : 0,
oldestEntry: this.getOldestEntry(),
mostUsed: this.getMostUsedKeys(5)
};
}
/**
* Private: Get TTL for a specific key based on patterns
*/
private getTTLForKey(key: string): number {
for (const [pattern, ttl] of this.ttlByPattern) {
if (pattern.test(key)) {
return ttl;
}
}
return this.defaultTTL;
}
/**
* Private: Estimate size of data in bytes
*/
private estimateSize(data: any): number {
try {
return JSON.stringify(data).length * 2; // Rough estimate (UTF-16)
} catch {
return 1024; // Default 1KB for non-serializable
}
}
/**
* Private: Evict least recently used entry
*/
private evictLRU(): void {
let lruKey: string | null = null;
let minScore = Infinity;
for (const [key, entry] of this.cache.entries()) {
// Score based on hits and age
const age = Date.now() - entry.timestamp;
const score = entry.hits / (age / 1000); // Hits per second
if (score < minScore) {
minScore = score;
lruKey = key;
}
}
if (lruKey) {
this.delete(lruKey);
}
}
/**
* Private: Cleanup expired entries
*/
private cleanup(): void {
const now = Date.now();
const keysToDelete: string[] = [];
for (const [key, entry] of this.cache.entries()) {
const ttl = this.getTTLForKey(key);
if (now - entry.timestamp > ttl) {
keysToDelete.push(key);
}
}
keysToDelete.forEach(key => this.delete(key));
}
/**
* Private: Start cleanup timer
*/
private startCleanup(interval: number): void {
this.cleanupTimer = setInterval(() => this.cleanup(), interval);
}
/**
* Private: Get oldest cache entry
*/
private getOldestEntry(): string | null {
let oldestKey: string | null = null;
let oldestTime = Infinity;
for (const [key, entry] of this.cache.entries()) {
if (entry.timestamp < oldestTime) {
oldestTime = entry.timestamp;
oldestKey = key;
}
}
return oldestKey;
}
/**
* Private: Get most used keys
*/
private getMostUsedKeys(count: number): string[] {
return Array.from(this.cache.entries())
.sort((a, b) => b[1].hits - a[1].hits)
.slice(0, count)
.map(([key]) => key);
}
destroy(): void {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
this.clear();
}
}