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

69
packages/mizan-ts/src/cache/keys.ts vendored Normal file
View File

@@ -0,0 +1,69 @@
/**
* Cache key derivation — HMAC-SHA256 over JSON-canonical form.
*
* Protocol-critical: must produce identical output to Python's derive_cache_key
* for the same inputs. Cross-language conformance verified by pin tests.
*
* Wire format:
* HMAC-SHA256(secret, stableStringify({"c": ctx, "p": params, "r": rev}))
* - All keys sorted recursively
* - No whitespace (compact JSON)
* - "u" key included only for user-scoped content
* - All param values stringified
*/
import { createHmac } from 'crypto'
/**
* JSON.stringify with recursively sorted keys and no whitespace.
* Equivalent to Python's json.dumps(obj, sort_keys=True, separators=(",", ":"))
*/
function stableStringify(obj: any): string {
if (obj === null || obj === undefined) return 'null'
if (typeof obj === 'string') return JSON.stringify(obj)
if (typeof obj === 'number' || typeof obj === 'boolean') return String(obj)
if (Array.isArray(obj)) {
return '[' + obj.map(stableStringify).join(',') + ']'
}
const keys = Object.keys(obj).sort()
const pairs = keys.map(k => JSON.stringify(k) + ':' + stableStringify(obj[k]))
return '{' + pairs.join(',') + '}'
}
/**
* Derive a deterministic HMAC-SHA256 cache key.
*
* Must produce identical output to Python's derive_cache_key for the same inputs.
*/
export function deriveCacheKey(
secret: string,
context: string,
params: Record<string, any>,
userId?: string,
rev: number = 0,
): string {
// Stringify all param values for cross-language determinism
const sortedParams: Record<string, string> = {}
for (const [k, v] of Object.entries(params).sort(([a], [b]) => a.localeCompare(b))) {
sortedParams[k] = String(v)
}
const keyData: Record<string, any> = { c: context, p: sortedParams, r: rev }
if (userId !== undefined) {
keyData.u = String(userId)
}
const message = stableStringify(keyData)
return createHmac('sha256', secret).update(message).digest('hex')
}
/**
* Build reverse index keys for a cache entry.
*/
export function buildIndexKeys(context: string, params: Record<string, any>): string[] {
const keys = [`mizan:idx:${context}`]
for (const [k, v] of Object.entries(params).sort(([a], [b]) => a.localeCompare(b))) {
keys.push(`mizan:idx:${context}:${k}=${String(v)}`)
}
return keys
}