chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user