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

70
packages/mizan-ts/src/cache/backend.ts vendored Normal file
View File

@@ -0,0 +1,70 @@
/**
* Cache backends — MemoryCache for testing.
*
* Same API as Python's MemoryCache. RedisCache is implemented
* per-adapter for production (not included here).
*/
export interface CacheBackend {
get(key: string): string | null
put(key: string, value: string, indexes: string[]): void
deleteMany(keys: string[]): number
getIndex(indexKey: string): Set<string>
removeFromIndex(indexKey: string, members: Set<string>): void
deleteIndex(indexKey: string): void
deleteIndexesByPrefix(prefix: string): void
clear(): void
}
export class MemoryCache implements CacheBackend {
private _store = new Map<string, string>()
private _indexes = new Map<string, Set<string>>()
get(key: string): string | null {
return this._store.get(key) ?? null
}
put(key: string, value: string, indexes: string[]): void {
this._store.set(key, value)
for (const idx of indexes) {
if (!this._indexes.has(idx)) {
this._indexes.set(idx, new Set())
}
this._indexes.get(idx)!.add(key)
}
}
deleteMany(keys: string[]): number {
let count = 0
for (const key of keys) {
if (this._store.delete(key)) count++
}
return count
}
getIndex(indexKey: string): Set<string> {
return new Set(this._indexes.get(indexKey) ?? [])
}
removeFromIndex(indexKey: string, members: Set<string>): void {
const idx = this._indexes.get(indexKey)
if (!idx) return
for (const m of members) idx.delete(m)
if (idx.size === 0) this._indexes.delete(indexKey)
}
deleteIndex(indexKey: string): void {
this._indexes.delete(indexKey)
}
deleteIndexesByPrefix(prefix: string): void {
for (const key of [...this._indexes.keys()]) {
if (key.startsWith(prefix)) this._indexes.delete(key)
}
}
clear(): void {
this._store.clear()
this._indexes.clear()
}
}

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
}
}

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
}

View File

@@ -7,6 +7,14 @@
import { getFunction, getContextGroups } from './registry'
import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
let _cacheSecret: string | null = null
/** Set the cache secret for origin-side caching. */
export function setCacheSecret(secret: string | null): void {
_cacheSecret = secret
}
export interface MizanResponse {
status: number
@@ -38,6 +46,37 @@ export async function handleContextFetch(
}
}
// Resolve effective rev (max across functions) and cache policy (min TTL)
let effectiveRev = 0
for (const fnName of fnNames) {
const entry = getFunction(fnName)
if (entry?.rev) effectiveRev = Math.max(effectiveRev, entry.rev)
}
// Origin-side cache lookup
const cacheBackend = getCache()
const cacheSecret = _cacheSecret
if (cacheBackend && cacheSecret) {
try {
const cached = cacheGet(cacheSecret, cacheBackend, contextName, params, undefined, effectiveRev)
if (cached !== null) {
// Resolve cache policy for headers
let cc: number | boolean = true
for (const fn of fnNames) {
const e = getFunction(fn)
if (e?.cache === false) { cc = false; break }
if (typeof e?.cache === 'number') cc = cc === true ? e.cache : Math.min(cc as number, e.cache)
}
const cacheControl = cc === false ? 'no-store' : typeof cc === 'number' ? `public, max-age=0, s-maxage=${cc}` : 'public, max-age=0, s-maxage=31536000'
return {
status: 200,
body: JSON.parse(cached),
headers: { 'Content-Type': 'application/json', 'Cache-Control': cacheControl, 'X-Mizan-Cache': 'HIT' },
}
}
} catch { /* cache miss on error */ }
}
const results: Record<string, any> = {}
for (const fnName of fnNames) {
@@ -89,12 +128,20 @@ export async function handleContextFetch(
cacheControl = 'public, max-age=0, s-maxage=31536000'
}
// Store in origin-side cache
if (cacheBackend && cacheSecret && effectiveCache !== false) {
try {
cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev)
} catch { /* cache store failure is non-fatal */ }
}
return {
status: 200,
body: results,
headers: {
'Content-Type': 'application/json',
'Cache-Control': cacheControl,
...(cacheBackend && cacheSecret ? { 'X-Mizan-Cache': 'MISS' } : {}),
},
}
}
@@ -156,6 +203,20 @@ export async function handleMutationCall(
if (invalidate) {
responseData.invalidate = invalidate
headers['X-Mizan-Invalidate'] = formatInvalidateHeader(invalidate)
// Purge origin-side cache
const cb = getCache()
if (cb) {
try {
for (const entry of invalidate) {
if (typeof entry === 'string') {
cachePurge(cb, entry)
} else {
cachePurge(cb, entry.context, entry.params)
}
}
} catch { /* purge failure is non-fatal */ }
}
}
return { status: 200, body: responseData, headers }

View File

@@ -11,3 +11,7 @@ export type { MizanResponse } from './dispatch'
export { resolveInvalidation, formatInvalidateHeader } from './invalidation'
export { generateManifest } from './manifest'
export { MemoryCache, getCache, setCache, resetCache, cacheGet, cachePut, cachePurge, deriveCacheKey } from './cache'
export type { CacheBackend } from './cache'
export { setCacheSecret } from './dispatch'