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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user