Add TypeScript cache adapter with cross-language conformance tests

Port of Python's origin-side cache to TypeScript:
- cache/keys.ts: deriveCacheKey with stableStringify for JSON-canonical HMAC
- cache/backend.ts: MemoryCache (same API as Python)
- cache/index.ts: cacheGet, cachePut, cachePurge with AND semantics

Integrated into dispatch.ts:
- handleContextFetch: cache lookup before execution, store after
- handleMutationCall: purge on invalidation

Cross-language pin test proves Python and TypeScript produce identical
HMAC-SHA256 output for the same inputs:
  Public:      605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6
  User-scoped: 30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2

34 TypeScript tests (9 new), 165 Python tests (1 new pin test).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 01:03:24 -04:00
parent 54581d184f
commit 4744ff052e
7 changed files with 474 additions and 1 deletions

100
packages/mizan-ts/src/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,100 @@
/**
* mizan cache — TypeScript adapter.
*
* Same protocol as Python's mizan.cache. Cross-language conformance
* verified by pin tests.
*/
export { MemoryCache } from './backend'
export type { CacheBackend } from './backend'
export { deriveCacheKey, buildIndexKeys } from './keys'
import type { CacheBackend } from './backend'
import { deriveCacheKey, buildIndexKeys } from './keys'
let _cacheInstance: CacheBackend | null = null
export function getCache(): CacheBackend | null {
return _cacheInstance
}
export function setCache(backend: CacheBackend | null): void {
_cacheInstance = backend
}
export function resetCache(): void {
_cacheInstance = null
}
export function cacheGet(
secret: string,
backend: CacheBackend,
context: string,
params: Record<string, any>,
userId?: string,
rev: number = 0,
): string | null {
const key = deriveCacheKey(secret, context, params, userId, rev)
return backend.get(key)
}
export function cachePut(
secret: string,
backend: CacheBackend,
context: string,
params: Record<string, any>,
value: string,
userId?: string,
rev: number = 0,
): void {
const key = deriveCacheKey(secret, context, params, userId, rev)
const indexes = buildIndexKeys(context, params)
backend.put(key, value, indexes)
}
export function cachePurge(
backend: CacheBackend,
context: string,
params?: Record<string, any> | null,
): number {
if (params) {
// Scoped purge — AND semantics (intersection)
const setsPerParam: Set<string>[] = []
const paramIndexKeys: string[] = []
for (const [k, v] of Object.entries(params).sort(([a], [b]) => a.localeCompare(b))) {
const indexKey = `mizan:idx:${context}:${k}=${String(v)}`
paramIndexKeys.push(indexKey)
setsPerParam.push(backend.getIndex(indexKey))
}
let keysToDelete: Set<string>
if (setsPerParam.length > 0) {
keysToDelete = setsPerParam[0]
for (let i = 1; i < setsPerParam.length; i++) {
keysToDelete = new Set([...keysToDelete].filter(k => setsPerParam[i].has(k)))
}
} else {
keysToDelete = new Set()
}
if (keysToDelete.size > 0) {
for (const idxKey of paramIndexKeys) {
backend.removeFromIndex(idxKey, keysToDelete)
}
backend.removeFromIndex(`mizan:idx:${context}`, keysToDelete)
return backend.deleteMany([...keysToDelete])
}
return 0
} else {
// Broad purge
const indexKey = `mizan:idx:${context}`
const keysToDelete = backend.getIndex(indexKey)
backend.deleteIndex(indexKey)
backend.deleteIndexesByPrefix(`mizan:idx:${context}:`)
if (keysToDelete.size > 0) {
return backend.deleteMany([...keysToDelete])
}
return 0
}
}