/** * Incremental Test Cache Manager * Loads/saves cache from disk, looks up cached test commands by route hash. * File-based persistence in .apophis-cache.json * * CI/CD Integration: * Set APOPHIS_CHANGED_ROUTES=/users,/items,GET /orders to invalidate * specific routes on the next test run. Comma-separated patterns support * exact paths, wildcards (*), and method prefixes (METHOD path). */ import { readFileSync, writeFileSync, existsSync } from 'node:fs' import type { RouteContract } from '../types.js' import { hashRoute } from './hash.js' import { log } from '../infrastructure/logger.js' export interface CachedCommand { readonly params: Record readonly headers: Record } export interface CacheEntry { readonly routeHash: string readonly schemaHash: string readonly path: string readonly method: string readonly commands: ReadonlyArray readonly timestamp: number } export interface TestCache { readonly version: number readonly entries: Record } const CACHE_VERSION = 1 const CACHE_FILE = '.apophis-cache.json' const HINTS_FILE = '.apophis-hints.json' const isCacheDisabled = (): boolean => { // Cache is enabled by default in all environments. // Set APOPHIS_DISABLE_CACHE=1 to opt-out (e.g., serverless cold starts). return process.env.APOPHIS_DISABLE_CACHE === '1' || process.env.APOPHIS_DISABLE_CACHE === 'true' } function loadCacheFromDisk(): TestCache { if (!existsSync(CACHE_FILE)) { return { version: CACHE_VERSION, entries: {} } } try { const raw = readFileSync(CACHE_FILE, 'utf-8') const parsed = JSON.parse(raw) as TestCache // Validate version if (parsed.version !== CACHE_VERSION) { return { version: CACHE_VERSION, entries: {} } } return parsed } catch { // Corrupted cache: start fresh return { version: CACHE_VERSION, entries: {} } } } function saveCacheToDisk(cache: TestCache): void { writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2)) } // Lazy-loaded in-memory cache — never loads in production or test let memoryCache: TestCache | undefined let dirty = false function getCache(): TestCache { if (!memoryCache) { memoryCache = isCacheDisabled() ? { version: CACHE_VERSION, entries: {} } : loadCacheFromDisk() if (!isCacheDisabled()) { const invalidated = applyHints() if (invalidated > 0) { log.info(`Invalidated ${invalidated} cached route(s) from CI/CD hints`) } } } return memoryCache } /** * Parse a hint pattern and return a matcher function. * Patterns support: * - Exact path: /users * - Method prefix: GET /users * - Wildcards: /users/*, /api/** */ const createMatcher = (pattern: string): ((path: string, method: string) => boolean) => { const trimmed = pattern.trim() // Method prefix: "GET /users" const methodMatch = trimmed.match(/^(\w+)\s+(.+)$/) if (methodMatch) { const [, expectedMethod, pathPattern] = methodMatch const methodUpper = expectedMethod!.toUpperCase() // Replace ** first with placeholder, then *, then restore ** const regexPattern = pathPattern! .replace(/\*\*/g, '\0__DW__\0') .replace(/\*/g, '[^/]*') .replace(/\0__DW__\0/g, '.*') const regex = new RegExp('^' + regexPattern + '$') return (path, method) => method === methodUpper && regex.test(path) } // Path only const regexPattern = trimmed .replace(/\*\*/g, '\0__DW__\0') .replace(/\*/g, '[^/]*') .replace(/\0__DW__\0/g, '.*') const regex = new RegExp('^' + regexPattern + '$') return (path) => regex.test(path) } /** * Load hints from environment variable or hints file. * Returns array of pattern strings. */ const loadHints = (): string[] => { const hints: string[] = [] // Check env var: APOPHIS_CHANGED_ROUTES=/users,/items,GET /orders const envHints = process.env.APOPHIS_CHANGED_ROUTES if (envHints) { hints.push(...envHints.split(',').map(h => h.trim()).filter(Boolean)) } // Check hints file: .apophis-hints.json if (existsSync(HINTS_FILE)) { try { const raw = readFileSync(HINTS_FILE, 'utf-8') const parsed = JSON.parse(raw) as { changed?: string[] } if (Array.isArray(parsed.changed)) { hints.push(...parsed.changed) } } catch { // Ignore malformed hints file } } return hints } /** * Apply hints by invalidating matching routes from the cache. * Called automatically on module load and via refreshCache(). */ const applyHints = (): number => { const hints = loadHints() if (hints.length === 0) return 0 const cache = getCache() const matchers = hints.map(createMatcher) let invalidated = 0 const newEntries: Record = {} for (const [hash, entry] of Object.entries(cache.entries)) { const shouldInvalidate = matchers.some(m => m(entry.path, entry.method)) if (shouldInvalidate) { invalidated++ } else { newEntries[hash] = entry } } if (invalidated > 0) { memoryCache = { ...cache, entries: newEntries } dirty = true } return invalidated } /** * Explicitly reload the in-memory cache from disk. * Resets the dirty flag since memory now matches disk. * Re-applies any active hints. */ export function refreshCache(): void { if (isCacheDisabled()) { memoryCache = { version: CACHE_VERSION, entries: {} } dirty = false return } memoryCache = loadCacheFromDisk() dirty = false const invalidated = applyHints() if (invalidated > 0) { log.info(`Invalidated ${invalidated} cached route(s) from CI/CD hints`) } } /** * Explicitly write the in-memory cache to disk if it has been modified. * Resets the dirty flag after a successful flush. */ export function flushCache(): void { if (dirty) { saveCacheToDisk(getCache()) dirty = false } } /** * Check if a route has valid cached test commands. * Reads from the in-memory cache only — no disk I/O. * Returns undefined if cache is disabled (production/test environments). */ export function lookupCache(route: RouteContract): CacheEntry | undefined { if (isCacheDisabled()) return undefined const cache = getCache() const routeHash = hashRoute(route.path, route.method, route.schema) const entry = cache.entries[routeHash] if (!entry) return undefined // Verify schema hasn't changed const currentSchemaHash = hashRoute(route.path, route.method, route.schema) if (entry.schemaHash !== currentSchemaHash) { return undefined } return entry } /** * Store generated test commands for a route in the cache. * Updates the in-memory cache only — no disk I/O. * Sets the dirty flag so flushCache() will persist changes. * No-op if cache is disabled (production/test environments). */ export function storeCache( route: RouteContract, commands: CacheEntry['commands'] ): TestCache { if (isCacheDisabled()) { return getCache() } const cache = getCache() const routeHash = hashRoute(route.path, route.method, route.schema) const schemaHash = hashRoute(route.path, route.method, route.schema) memoryCache = { ...cache, entries: { ...cache.entries, [routeHash]: { routeHash, schemaHash, path: route.path, method: route.method, commands, timestamp: Date.now(), }, }, } dirty = true return memoryCache } /** * Invalidate cache entries matching the given path/method patterns. * Patterns support exact paths, wildcards (*), and method prefixes (METHOD path). * * Example: * invalidateRoutes(['/users', 'GET /items/*']) */ export function invalidateRoutes(patterns: string[]): number { if (patterns.length === 0) return 0 const cache = getCache() const matchers = patterns.map(createMatcher) let invalidated = 0 const newEntries: Record = {} for (const [hash, entry] of Object.entries(cache.entries)) { const shouldInvalidate = matchers.some(m => m(entry.path, entry.method)) if (shouldInvalidate) { invalidated++ } else { newEntries[hash] = entry } } if (invalidated > 0) { memoryCache = { ...cache, entries: newEntries } dirty = true } return invalidated } /** * Invalidate the entire cache. * Clears the in-memory cache, marks dirty, and immediately persists the empty cache to disk. */ export function invalidateCache(): void { memoryCache = { version: CACHE_VERSION, entries: {} } dirty = true saveCacheToDisk(memoryCache) dirty = false } /** * Get cache statistics from the in-memory cache. * No disk I/O. */ export function getCacheStats(): { totalEntries: number totalCommands: number oldestEntry: number | null newestEntry: number | null } { const cache = getCache() const entries = Object.values(cache.entries) if (entries.length === 0) { return { totalEntries: 0, totalCommands: 0, oldestEntry: null, newestEntry: null } } const timestamps = entries.map(e => e.timestamp) const totalCommands = entries.reduce((sum, e) => sum + e.commands.length, 0) return { totalEntries: entries.length, totalCommands, oldestEntry: Math.min(...timestamps), newestEntry: Math.max(...timestamps), } }