//
// File: cache-manager.ts
// Brief: Manages caching of CodeWiki documentation with TTL and persistence
//
// Copyright (c) 2025 Chris Bunting <cbuntingde@gmail.com>
//
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
//
import fs from 'fs/promises';
import path from 'path';
export interface CachedDocumentation {
owner: string;
repo: string;
content: string;
lastUpdated: Date;
metadata: {
size: number;
sections: string[];
lastCommit?: string | undefined;
};
}
export interface CacheEntry {
docs: CachedDocumentation;
timestamp: number;
ttl: number; // Time to live in milliseconds
}
export class CacheManager {
private cacheDir: string;
private memoryCache: Map<string, CacheEntry> = new Map();
private defaultTtl: number = 24 * 60 * 60 * 1000; // 24 hours
constructor(cacheDir?: string) {
this.cacheDir = cacheDir || path.join(process.cwd(), '.codewiki-cache');
this.initializeCacheDir();
}
private async initializeCacheDir(): Promise<void> {
try {
await fs.access(this.cacheDir);
} catch {
await fs.mkdir(this.cacheDir, { recursive: true });
}
}
private getCacheKey(owner: string, repo: string): string {
return `${owner}/${repo}`;
}
private getCacheFilePath(owner: string, repo: string): string {
const key = this.getCacheKey(owner, repo);
return path.join(this.cacheDir, `${key.replace('/', '_')}.json`);
}
async get(owner: string, repo: string): Promise<CachedDocumentation | null> {
const key = this.getCacheKey(owner, repo);
// Check memory cache first
const memoryEntry = this.memoryCache.get(key);
if (memoryEntry && Date.now() - memoryEntry.timestamp < memoryEntry.ttl) {
return memoryEntry.docs;
}
// Check disk cache
try {
const filePath = this.getCacheFilePath(owner, repo);
const data = await fs.readFile(filePath, 'utf-8');
const entry: CacheEntry = JSON.parse(data);
if (Date.now() - entry.timestamp < entry.ttl) {
// Add to memory cache
this.memoryCache.set(key, entry);
return entry.docs;
} else {
// Expired, remove from disk
await fs.unlink(filePath).catch(() => {});
}
} catch {
// File doesn't exist or can't be read
}
return null;
}
async set(owner: string, repo: string, docs: CachedDocumentation, ttl?: number): Promise<void> {
const key = this.getCacheKey(owner, repo);
const ttlMs = ttl || this.defaultTtl;
const entry: CacheEntry = {
docs,
timestamp: Date.now(),
ttl: ttlMs,
};
// Update memory cache
this.memoryCache.set(key, entry);
// Update disk cache
try {
const filePath = this.getCacheFilePath(owner, repo);
await fs.writeFile(filePath, JSON.stringify(entry, null, 2), 'utf-8');
} catch (error) {
console.warn(`Failed to write cache file for ${key}:`, error);
}
}
async clearRepository(owner: string, repo: string): Promise<void> {
const key = this.getCacheKey(owner, repo);
// Remove from memory cache
this.memoryCache.delete(key);
// Remove from disk cache
try {
const filePath = this.getCacheFilePath(owner, repo);
await fs.unlink(filePath);
} catch {
// File doesn't exist or can't be deleted
}
}
async clearAll(): Promise<void> {
// Clear memory cache
this.memoryCache.clear();
// Clear disk cache
try {
const files = await fs.readdir(this.cacheDir);
await Promise.all(
files.map(file =>
fs.unlink(path.join(this.cacheDir, file)).catch(() => {})
)
);
} catch {
// Cache directory doesn't exist or can't be read
}
}
async listRepositories(): Promise<Array<{ owner: string; repo: string; lastUpdated: Date; size: number }>> {
const repositories: Array<{ owner: string; repo: string; lastUpdated: Date; size: number }> = [];
// Check memory cache
for (const [key, entry] of this.memoryCache.entries()) {
if (Date.now() - entry.timestamp < entry.ttl) {
const [owner, repo] = key.split('/');
if (owner && repo) {
repositories.push({
owner,
repo,
lastUpdated: entry.docs.lastUpdated,
size: entry.docs.metadata.size,
});
}
}
}
// Check disk cache
try {
const files = await fs.readdir(this.cacheDir);
for (const file of files) {
if (file.endsWith('.json')) {
try {
const filePath = path.join(this.cacheDir, file);
const data = await fs.readFile(filePath, 'utf-8');
const entry: CacheEntry = JSON.parse(data);
if (Date.now() - entry.timestamp < entry.ttl) {
const [owner, repo] = file.replace('.json', '').split('_');
if (owner && repo) {
repositories.push({
owner,
repo,
lastUpdated: entry.docs.lastUpdated,
size: entry.docs.metadata.size,
});
}
} else {
// Expired, remove it
await fs.unlink(filePath).catch(() => {});
}
} catch {
// Skip corrupted cache files
}
}
}
} catch {
// Cache directory doesn't exist or can't be read
}
return repositories.sort((a, b) => b.lastUpdated.getTime() - a.lastUpdated.getTime());
}
async getCacheStats(): Promise<{ totalSize: number; entryCount: number; memoryEntries: number; diskEntries: number }> {
const repos = await this.listRepositories();
const totalSize = repos.reduce((sum, repo) => sum + repo.size, 0);
let memoryEntries = 0;
let diskEntries = 0;
for (const [, entry] of this.memoryCache.entries()) {
if (Date.now() - entry.timestamp < entry.ttl) {
memoryEntries++;
}
}
try {
const files = await fs.readdir(this.cacheDir);
diskEntries = files.filter(file => file.endsWith('.json')).length;
} catch {
// Cache directory doesn't exist
}
return {
totalSize,
entryCount: repos.length,
memoryEntries,
diskEntries,
};
}
}