chore: crush git history - reborn from consolidation on 2026-03-10

This commit is contained in:
John Dvorak
2026-03-10 00:00:00 -07:00
commit d278c4b105
313 changed files with 87549 additions and 0 deletions
+299
View File
@@ -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),
}
}