UNPKG

kist

Version:

Lightweight Package Pipeline Processor with Plugin Architecture

562 lines (493 loc) 16.7 kB
// ============================================================================ // Import // ============================================================================ import crypto from "crypto"; import fs from "fs"; import path from "path"; import { AbstractProcess } from "../abstract/AbstractProcess.js"; import { FileCache } from "./FileCache.js"; // ============================================================================ // Types // ============================================================================ /** * Represents a cached build result. */ interface BuildCacheEntry { /** Hash of input files combined */ inputHash: string; /** Paths to output files */ outputFiles: string[]; /** Build configuration hash */ configHash: string; /** Timestamp when the entry was created */ createdAt: number; /** Build duration in milliseconds */ buildDuration?: number; } /** * Options for the BuildCache constructor. */ interface BuildCacheOptions { /** Directory to store cached build artifacts */ cacheDir?: string; /** Maximum cache size in bytes (default: 1GB) */ maxCacheSize?: number; /** Cache time-to-live in milliseconds (default: 7 days) */ ttl?: number; } /** * Result from a cache lookup. */ interface CacheLookupResult { /** Whether a valid cache entry was found */ found: boolean; /** Cached output files if found */ outputFiles?: string[]; /** Whether cache was restored from artifacts */ restored?: boolean; } // ============================================================================ // Class // ============================================================================ /** * BuildCache provides a mechanism for caching build outputs based on input * file hashes. This enables incremental builds by skipping steps whose inputs * haven't changed. * * @example * ```typescript * const buildCache = BuildCache.getInstance(); * * // Check for cached output * const cacheResult = await buildCache.lookup('typescript', inputFiles, config); * if (cacheResult.found) { * console.log('Using cached build output'); * return cacheResult.outputFiles; * } * * // Build and cache the result * const outputFiles = await compile(inputFiles); * await buildCache.store('typescript', inputFiles, outputFiles, config); * ``` */ export class BuildCache extends AbstractProcess { // Parameters // ======================================================================== /** Singleton instance */ private static instance: BuildCache; /** In-memory cache index */ private cacheIndex: Map<string, BuildCacheEntry> = new Map(); /** Directory for storing cached artifacts */ private cacheDir: string; /** Maximum cache size in bytes */ private maxCacheSize: number; /** Time-to-live in milliseconds */ private ttl: number; /** Path to the cache index file */ private indexPath: string; /** Flag indicating initialization status */ private initialized: boolean = false; /** Reference to FileCache for input hashing */ private fileCache: FileCache; /** Statistics */ private stats = { hits: 0, misses: 0, stored: 0, restored: 0, evicted: 0, }; // Constructor // ======================================================================== /** * Private constructor for singleton pattern. */ private constructor(options: BuildCacheOptions = {}) { super(); this.cacheDir = options.cacheDir || path.join(process.cwd(), ".kist-cache", "build"); this.maxCacheSize = options.maxCacheSize || 1024 * 1024 * 1024; // 1GB this.ttl = options.ttl || 7 * 24 * 60 * 60 * 1000; // 7 days this.indexPath = path.join(this.cacheDir, "build-index.json"); this.fileCache = FileCache.getInstance(); } // Static Methods // ======================================================================== /** * Gets the singleton instance of BuildCache. */ public static getInstance(options?: BuildCacheOptions): BuildCache { if (!BuildCache.instance) { BuildCache.instance = new BuildCache(options); } return BuildCache.instance; } /** * Resets the singleton instance (useful for testing). */ public static resetInstance(): void { BuildCache.instance = undefined as any; } // Public Methods // ======================================================================== /** * Initializes the build cache. */ public async initialize(): Promise<void> { if (this.initialized) return; try { await fs.promises.mkdir(this.cacheDir, { recursive: true }); await this.loadIndex(); await this.fileCache.initialize(); this.initialized = true; this.logDebug( `BuildCache initialized with ${this.cacheIndex.size} entries.`, ); } catch (error) { this.logWarn(`Failed to initialize build cache: ${error}`); this.cacheIndex.clear(); this.initialized = true; } } /** * Looks up a cached build result. * * @param actionName - Name of the action (e.g., 'typescript', 'sass') * @param inputFiles - Array of input file paths * @param config - Build configuration object * @returns Cache lookup result */ public async lookup( actionName: string, inputFiles: string[], config: Record<string, any> = {}, ): Promise<CacheLookupResult> { await this.initialize(); const cacheKey = await this.computeCacheKey( actionName, inputFiles, config, ); const entry = this.cacheIndex.get(cacheKey); if (!entry) { this.stats.misses++; return { found: false }; } // Check TTL if (Date.now() - entry.createdAt > this.ttl) { this.cacheIndex.delete(cacheKey); this.stats.misses++; return { found: false }; } // Verify input hash still matches (files haven't changed) const currentInputHash = await this.computeInputHash(inputFiles); if (currentInputHash !== entry.inputHash) { this.cacheIndex.delete(cacheKey); this.stats.misses++; return { found: false }; } // Verify cached output files still exist const outputsExist = await this.verifyOutputFiles(entry.outputFiles); if (!outputsExist) { // Try to restore from cache artifacts const restored = await this.restoreFromArtifacts(cacheKey, entry); if (restored) { this.stats.restored++; this.stats.hits++; return { found: true, outputFiles: entry.outputFiles, restored: true, }; } this.cacheIndex.delete(cacheKey); this.stats.misses++; return { found: false }; } this.stats.hits++; this.logDebug(`Cache hit for ${actionName}`); return { found: true, outputFiles: entry.outputFiles }; } /** * Stores a build result in the cache. * * @param actionName - Name of the action * @param inputFiles - Array of input file paths * @param outputFiles - Array of output file paths * @param config - Build configuration object * @param buildDuration - Optional build duration in milliseconds */ public async store( actionName: string, inputFiles: string[], outputFiles: string[], config: Record<string, any> = {}, buildDuration?: number, ): Promise<void> { await this.initialize(); const cacheKey = await this.computeCacheKey( actionName, inputFiles, config, ); const inputHash = await this.computeInputHash(inputFiles); const configHash = this.computeConfigHash(config); const entry: BuildCacheEntry = { inputHash, outputFiles, configHash, createdAt: Date.now(), buildDuration, }; // Store artifacts for future restoration await this.storeArtifacts(cacheKey, outputFiles); this.cacheIndex.set(cacheKey, entry); this.stats.stored++; // Update file cache for inputs await this.fileCache.updateFileEntries(inputFiles); // Cleanup if needed await this.cleanupIfNeeded(); this.logDebug(`Stored cache entry for ${actionName}`); } /** * Invalidates cache entries for a specific action. */ public invalidateAction(actionName: string): void { for (const [key] of this.cacheIndex) { if (key.startsWith(`${actionName}:`)) { this.cacheIndex.delete(key); } } } /** * Clears the entire build cache. */ public async clear(): Promise<void> { this.cacheIndex.clear(); this.stats = { hits: 0, misses: 0, stored: 0, restored: 0, evicted: 0, }; try { await fs.promises.rm(this.cacheDir, { recursive: true, force: true, }); await fs.promises.mkdir(this.cacheDir, { recursive: true }); } catch { // Ignore cleanup errors } this.logInfo("BuildCache cleared."); } /** * Saves the cache index to disk. */ public async save(): Promise<void> { try { const data = JSON.stringify( Object.fromEntries(this.cacheIndex), null, 2, ); await fs.promises.writeFile(this.indexPath, data, "utf-8"); this.logDebug("BuildCache index saved."); } catch (error) { this.logWarn(`Failed to save build cache index: ${error}`); } } /** * Gets cache statistics. */ public getStats(): typeof this.stats & { size: number; hitRate: string } { const total = this.stats.hits + this.stats.misses; const hitRate = total > 0 ? ((this.stats.hits / total) * 100).toFixed(2) + "%" : "N/A"; return { ...this.stats, size: this.cacheIndex.size, hitRate, }; } // Private Methods // ======================================================================== /** * Computes a unique cache key for a build. */ private async computeCacheKey( actionName: string, inputFiles: string[], config: Record<string, any>, ): Promise<string> { const sortedFiles = [...inputFiles].sort().join("|"); const configStr = JSON.stringify(config); const data = `${actionName}:${sortedFiles}:${configStr}`; return `${actionName}:${crypto.createHash("md5").update(data).digest("hex")}`; } /** * Computes a combined hash of all input files. */ private async computeInputHash(inputFiles: string[]): Promise<string> { const hashes: string[] = []; for (const file of inputFiles.sort()) { try { const content = await fs.promises.readFile(file); hashes.push( crypto.createHash("sha256").update(content).digest("hex"), ); } catch { hashes.push("missing"); } } return crypto .createHash("sha256") .update(hashes.join(":")) .digest("hex"); } /** * Computes a hash of the configuration object. */ private computeConfigHash(config: Record<string, any>): string { return crypto .createHash("md5") .update(JSON.stringify(config)) .digest("hex"); } /** * Verifies that all output files exist. */ private async verifyOutputFiles(outputFiles: string[]): Promise<boolean> { for (const file of outputFiles) { try { await fs.promises.access(file, fs.constants.R_OK); } catch { return false; } } return true; } /** * Stores output files as cached artifacts. */ private async storeArtifacts( cacheKey: string, outputFiles: string[], ): Promise<void> { const artifactDir = path.join(this.cacheDir, "artifacts", cacheKey); try { await fs.promises.mkdir(artifactDir, { recursive: true }); for (const file of outputFiles) { const artifactPath = path.join( artifactDir, path.basename(file), ); await fs.promises.copyFile(file, artifactPath); } } catch (error) { this.logWarn( `Failed to store artifacts for ${cacheKey}: ${error}`, ); } } /** * Restores output files from cached artifacts. */ private async restoreFromArtifacts( cacheKey: string, entry: BuildCacheEntry, ): Promise<boolean> { const artifactDir = path.join(this.cacheDir, "artifacts", cacheKey); try { for (const outputFile of entry.outputFiles) { const artifactPath = path.join( artifactDir, path.basename(outputFile), ); const outputDir = path.dirname(outputFile); await fs.promises.mkdir(outputDir, { recursive: true }); await fs.promises.copyFile(artifactPath, outputFile); } return true; } catch { return false; } } /** * Loads the cache index from disk. */ private async loadIndex(): Promise<void> { try { const data = await fs.promises.readFile(this.indexPath, "utf-8"); const parsed = JSON.parse(data) as Record<string, BuildCacheEntry>; this.cacheIndex = new Map(Object.entries(parsed)); } catch { this.cacheIndex = new Map(); } } /** * Cleans up old cache entries if cache exceeds size limits. */ private async cleanupIfNeeded(): Promise<void> { // Simple size-based cleanup - evict oldest 20% when over limit const artifactsPath = path.join(this.cacheDir, "artifacts"); try { const size = await this.getDirectorySize(artifactsPath); if (size > this.maxCacheSize) { const entries = Array.from(this.cacheIndex.entries()).sort( ([, a], [, b]) => a.createdAt - b.createdAt, ); const toEvict = Math.ceil(entries.length * 0.2); for (let i = 0; i < toEvict; i++) { const [key] = entries[i]; await this.evictEntry(key); this.stats.evicted++; } } } catch { // Ignore cleanup errors } } /** * Evicts a single cache entry. */ private async evictEntry(cacheKey: string): Promise<void> { this.cacheIndex.delete(cacheKey); const artifactDir = path.join(this.cacheDir, "artifacts", cacheKey); try { await fs.promises.rm(artifactDir, { recursive: true, force: true, }); } catch { // Ignore deletion errors } } /** * Gets the total size of a directory in bytes. */ private async getDirectorySize(dirPath: string): Promise<number> { let totalSize = 0; try { const entries = await fs.promises.readdir(dirPath, { withFileTypes: true, }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { totalSize += await this.getDirectorySize(fullPath); } else { const stat = await fs.promises.stat(fullPath); totalSize += stat.size; } } } catch { // Directory doesn't exist } return totalSize; } }