import { recordCacheEvent } from '../../cache/context' import type { CacheMode, CachePolicy, CacheResult, CacheService, CacheTier, DistributedCacheBackend, } from '../../usecases/ports/cache' interface CacheEnvelope { freshUntil: number value: unknown } interface MemoryEntry { freshUntil: number value: unknown } const KV_CACHE_TTL_SECONDS = 60 const KV_EXPIRATION_TTL_SECONDS = 600 export interface RuntimeCacheOptions { mode: CacheMode distributed?: DistributedCacheBackend now?: () => number } export function resolveCacheMode(value: string | undefined, distributedAvailable: boolean): CacheMode { const mode = value?.trim() || (distributedAvailable ? 'distributed' : 'memory') if (mode !== 'off' && mode !== 'memory' && mode !== 'distributed') { throw new Error(`Invalid ZPAN_CACHE_MODE: ${mode}`) } if (mode === 'distributed' && !distributedAvailable) { throw new Error('ZPAN_CACHE_MODE=distributed requires a distributed cache binding') } return mode } export function createRuntimeCache(options: RuntimeCacheOptions): CacheService { if (options.mode === 'distributed' && !options.distributed) { throw new Error('Distributed cache mode requires a backend') } const stores = new Map>() const loads = new Map>>() const now = options.now ?? Date.now function memoryStore(namespace: string): Map { let store = stores.get(namespace) if (!store) { store = new Map() stores.set(namespace, store) } return store } function memoryGet(policy: CachePolicy, key: string): T | undefined { const store = memoryStore(policy.namespace) const entry = store.get(key) if (!entry) return undefined if (entry.freshUntil <= now() || !policy.validate(entry.value)) { store.delete(key) return undefined } store.delete(key) store.set(key, entry) return entry.value } function ttlFor(policy: CachePolicy, value: T): number { return value === null ? (policy.negativeTtlMs ?? policy.ttlMs) : policy.ttlMs } function memoryPut(policy: CachePolicy, key: string, value: T, freshUntil = now() + ttlFor(policy, value)) { const store = memoryStore(policy.namespace) store.delete(key) store.set(key, { freshUntil, value }) while (store.size > policy.maxEntries) { const oldest = store.keys().next().value if (oldest === undefined) break store.delete(oldest) } } function cacheKey(policy: CachePolicy, key: string): string { return `zpan:v${policy.version}:${policy.namespace}:${key}` } function usesDistributed(policy: CachePolicy): boolean { return options.mode === 'distributed' && policy.distributed !== false } async function distributedGet(policy: CachePolicy, key: string): Promise { if (!options.distributed) return undefined try { const raw = await options.distributed.get(cacheKey(policy, key), KV_CACHE_TTL_SECONDS) if (!raw) return undefined const envelope = JSON.parse(raw) as CacheEnvelope if ( typeof envelope !== 'object' || envelope === null || typeof envelope.freshUntil !== 'number' || envelope.freshUntil <= now() || !policy.validate(envelope.value) ) { return undefined } memoryPut(policy, key, envelope.value, envelope.freshUntil) return envelope.value } catch (error) { console.error( JSON.stringify({ message: 'cache.distributed.get.error', namespace: policy.namespace, error: error instanceof Error ? error.message : String(error), }), ) return undefined } } async function distributedPut(policy: CachePolicy, key: string, value: T, freshUntil: number): Promise { if (!options.distributed) return const envelope: CacheEnvelope = { freshUntil, value } try { await options.distributed.put(cacheKey(policy, key), JSON.stringify(envelope), KV_EXPIRATION_TTL_SECONDS) } catch (error) { console.error( JSON.stringify({ message: 'cache.distributed.put.error', namespace: policy.namespace, error: error instanceof Error ? error.message : String(error), }), ) } } function observed(namespace: string, tier: CacheTier, startedAt: number, value: T): CacheResult { recordCacheEvent({ namespace, tier, durationMs: performance.now() - startedAt }) return { value, tier } } return { mode: options.mode, async get(policy: CachePolicy, key: string): Promise | undefined> { if (options.mode === 'off') return undefined const startedAt = performance.now() const inMemory = memoryGet(policy, key) if (inMemory !== undefined) return observed(policy.namespace, 'memory', startedAt, inMemory) if (!usesDistributed(policy)) return undefined const distributed = await distributedGet(policy, key) return distributed === undefined ? undefined : observed(policy.namespace, 'distributed', startedAt, distributed) }, async getOrLoad(policy: CachePolicy, key: string, loader: () => Promise): Promise> { const startedAt = performance.now() if (options.mode === 'off') return observed(policy.namespace, 'bypass', startedAt, await loader()) const inMemory = memoryGet(policy, key) if (inMemory !== undefined) return observed(policy.namespace, 'memory', startedAt, inMemory) const loadKey = cacheKey(policy, key) const existing = loads.get(loadKey) as Promise> | undefined if (existing) { const result = await existing return observed(policy.namespace, 'coalesced', startedAt, result.value) } const load = (async (): Promise> => { if (usesDistributed(policy)) { const distributed = await distributedGet(policy, key) if (distributed !== undefined) return { value: distributed, tier: 'distributed' } } const value = await loader() const freshUntil = now() + ttlFor(policy, value) memoryPut(policy, key, value, freshUntil) if (usesDistributed(policy)) await distributedPut(policy, key, value, freshUntil) return { value, tier: 'source' } })() loads.set(loadKey, load as Promise>) try { const result = await load return observed(policy.namespace, result.tier, startedAt, result.value) } finally { if (loads.get(loadKey) === load) loads.delete(loadKey) } }, async replace(policy: CachePolicy, key: string, value: T): Promise { if (options.mode === 'off') return const freshUntil = now() + ttlFor(policy, value) memoryPut(policy, key, value, freshUntil) if (usesDistributed(policy)) await distributedPut(policy, key, value, freshUntil) }, async invalidate(policy: CachePolicy, key: string): Promise { memoryStore(policy.namespace).delete(key) if (!usesDistributed(policy) || !options.distributed) return try { await options.distributed.delete(cacheKey(policy, key)) } catch (error) { console.error( JSON.stringify({ message: 'cache.distributed.delete.error', namespace: policy.namespace, error: error instanceof Error ? error.message : String(error), }), ) } }, } }