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

View File

@@ -6,7 +6,7 @@
*/
import { describe, test, expect, beforeEach } from 'bun:test'
import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest } from '../src'
import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest, MemoryCache, setCache, resetCache, setCacheSecret, deriveCacheKey, cacheGet, cachePut, cachePurge } from '../src'
const UserCtx = new ReactContext('user')
@@ -275,3 +275,152 @@ describe('Manifest', () => {
expect(r.headers['Cache-Control']).toBe('no-store')
})
})
// ── Cache Conformance Tests ────────────────────────────────────────────
describe('Cache Conformance', () => {
const SECRET = 'test-pin-secret-that-is-32bytes!'
test('deriveCacheKey determinism', () => {
const k1 = deriveCacheKey(SECRET, 'user', { user_id: '5' })
const k2 = deriveCacheKey(SECRET, 'user', { user_id: '5' })
expect(k1).toBe(k2)
expect(k1).toHaveLength(64)
})
test('deriveCacheKey param order irrelevant', () => {
const k1 = deriveCacheKey(SECRET, 'ctx', { a: '1', b: '2' })
const k2 = deriveCacheKey(SECRET, 'ctx', { b: '2', a: '1' })
expect(k1).toBe(k2)
})
test('deriveCacheKey cross-language pin (matches Python)', () => {
// These exact values are pinned from Python's derive_cache_key output.
// If this test fails, cross-language cache key compatibility is broken.
const publicKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, undefined, 0)
expect(publicKey).toBe('605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6')
const userScopedKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, '5', 0)
expect(userScopedKey).toBe('30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2')
})
test('MemoryCache get/put/clear', () => {
const cache = new MemoryCache()
expect(cache.get('k1')).toBeNull()
cache.put('k1', '{"data":true}', ['mizan:idx:ctx'])
expect(cache.get('k1')).toBe('{"data":true}')
cache.clear()
expect(cache.get('k1')).toBeNull()
})
test('scoped purge AND semantics', () => {
const cache = new MemoryCache()
cachePut(SECRET, cache, 'user', { user_id: '5' }, '{"u5":true}')
cachePut(SECRET, cache, 'user', { user_id: '6' }, '{"u6":true}')
const count = cachePurge(cache, 'user', { user_id: '5' })
expect(count).toBe(1)
expect(cacheGet(SECRET, cache, 'user', { user_id: '5' })).toBeNull()
expect(cacheGet(SECRET, cache, 'user', { user_id: '6' })).not.toBeNull()
})
test('broad purge removes all entries', () => {
const cache = new MemoryCache()
cachePut(SECRET, cache, 'user', { user_id: '5' }, '{"u5":true}')
cachePut(SECRET, cache, 'user', { user_id: '6' }, '{"u6":true}')
const count = cachePurge(cache, 'user')
expect(count).toBe(2)
expect(cacheGet(SECRET, cache, 'user', { user_id: '5' })).toBeNull()
expect(cacheGet(SECRET, cache, 'user', { user_id: '6' })).toBeNull()
})
test('handleContextFetch caches response', async () => {
clearRegistry()
const Ctx = new ReactContext('cached')
client({ context: Ctx }, async function cachedFn(itemId: number) {
return { value: itemId }
})
const cache = new MemoryCache()
setCache(cache)
setCacheSecret(SECRET)
const r1 = await handleContextFetch('cached', { itemId: '1' })
expect(r1.status).toBe(200)
expect(r1.headers['X-Mizan-Cache']).toBe('MISS')
const r2 = await handleContextFetch('cached', { itemId: '1' })
expect(r2.status).toBe(200)
expect(r2.headers['X-Mizan-Cache']).toBe('HIT')
expect(r2.body).toEqual(r1.body)
resetCache()
setCacheSecret(null)
})
test('handleMutationCall purges cache', async () => {
clearRegistry()
const Ctx = new ReactContext('product')
client({ context: Ctx }, async function getProduct(productId: number) {
return { id: productId }
})
client({ affects: Ctx }, async function updateProduct(productId: number, name: string) {
return { ok: true }
})
const cache = new MemoryCache()
setCache(cache)
setCacheSecret(SECRET)
// Prime cache
await handleContextFetch('product', { productId: '1' })
// Mutate
await handleMutationCall('updateProduct', { productId: 1, name: 'New' })
// Cache should be purged — next fetch is MISS
const r = await handleContextFetch('product', { productId: '1' })
expect(r.headers['X-Mizan-Cache']).toBe('MISS')
resetCache()
setCacheSecret(null)
})
test('scoped invalidation preserves other entries', async () => {
clearRegistry()
const Ctx = new ReactContext('user')
client({ context: Ctx }, async function userProfile(userId: number) {
return { name: `user_${userId}` }
})
client({ affects: Ctx }, async function editUser(userId: number, name: string) {
return { ok: true }
})
const cache = new MemoryCache()
setCache(cache)
setCacheSecret(SECRET)
// Prime both users
await handleContextFetch('user', { userId: '5' })
await handleContextFetch('user', { userId: '6' })
// Mutate only user 5
await handleMutationCall('editUser', { userId: 5, name: 'New' })
// User 6 should still be cached
const r6 = await handleContextFetch('user', { userId: '6' })
expect(r6.headers['X-Mizan-Cache']).toBe('HIT')
// User 5 should be a miss
const r5 = await handleContextFetch('user', { userId: '5' })
expect(r5.headers['X-Mizan-Cache']).toBe('MISS')
resetCache()
setCacheSecret(null)
})
})