300 lines
9.0 KiB
TypeScript
300 lines
9.0 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<string, unknown>
|
||
|
|
readonly headers: Record<string, string>
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface CacheEntry {
|
||
|
|
readonly routeHash: string
|
||
|
|
readonly schemaHash: string
|
||
|
|
readonly path: string
|
||
|
|
readonly method: string
|
||
|
|
readonly commands: ReadonlyArray<CachedCommand>
|
||
|
|
readonly timestamp: number
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface TestCache {
|
||
|
|
readonly version: number
|
||
|
|
readonly entries: Record<string, CacheEntry>
|
||
|
|
}
|
||
|
|
|
||
|
|
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<string, CacheEntry> = {}
|
||
|
|
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<string, CacheEntry> = {}
|
||
|
|
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),
|
||
|
|
}
|
||
|
|
}
|