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:
@@ -2841,6 +2841,26 @@ class CacheKeyDerivationTests(TestCase):
|
|||||||
scoped = derive_cache_key(self.SECRET, "products", {"id": "1"}, user_id="5")
|
scoped = derive_cache_key(self.SECRET, "products", {"id": "1"}, user_id="5")
|
||||||
self.assertNotEqual(public, scoped)
|
self.assertNotEqual(public, scoped)
|
||||||
|
|
||||||
|
def test_cross_language_pin(self):
|
||||||
|
"""Pinned HMAC values — must match TypeScript adapter exactly."""
|
||||||
|
from mizan.cache.keys import derive_cache_key
|
||||||
|
|
||||||
|
pin_secret = "test-pin-secret-that-is-32bytes!"
|
||||||
|
|
||||||
|
public_key = derive_cache_key(pin_secret, "user", {"user_id": "5"}, rev=0)
|
||||||
|
self.assertEqual(
|
||||||
|
public_key,
|
||||||
|
"605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_scoped_key = derive_cache_key(
|
||||||
|
pin_secret, "user", {"user_id": "5"}, user_id="5", rev=0,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
user_scoped_key,
|
||||||
|
"30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CacheBackendTests(TestCase):
|
class CacheBackendTests(TestCase):
|
||||||
"""Tests for MemoryCache backend operations."""
|
"""Tests for MemoryCache backend operations."""
|
||||||
|
|||||||
70
packages/mizan-ts/src/cache/backend.ts
vendored
Normal file
70
packages/mizan-ts/src/cache/backend.ts
vendored
Normal 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
100
packages/mizan-ts/src/cache/index.ts
vendored
Normal 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
69
packages/mizan-ts/src/cache/keys.ts
vendored
Normal 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
|
||||||
|
}
|
||||||
@@ -7,6 +7,14 @@
|
|||||||
|
|
||||||
import { getFunction, getContextGroups } from './registry'
|
import { getFunction, getContextGroups } from './registry'
|
||||||
import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
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 {
|
export interface MizanResponse {
|
||||||
status: number
|
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> = {}
|
const results: Record<string, any> = {}
|
||||||
|
|
||||||
for (const fnName of fnNames) {
|
for (const fnName of fnNames) {
|
||||||
@@ -89,12 +128,20 @@ export async function handleContextFetch(
|
|||||||
cacheControl = 'public, max-age=0, s-maxage=31536000'
|
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 {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: results,
|
body: results,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Cache-Control': cacheControl,
|
'Cache-Control': cacheControl,
|
||||||
|
...(cacheBackend && cacheSecret ? { 'X-Mizan-Cache': 'MISS' } : {}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,6 +203,20 @@ export async function handleMutationCall(
|
|||||||
if (invalidate) {
|
if (invalidate) {
|
||||||
responseData.invalidate = invalidate
|
responseData.invalidate = invalidate
|
||||||
headers['X-Mizan-Invalidate'] = formatInvalidateHeader(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 }
|
return { status: 200, body: responseData, headers }
|
||||||
|
|||||||
@@ -11,3 +11,7 @@ export type { MizanResponse } from './dispatch'
|
|||||||
export { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
export { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
||||||
|
|
||||||
export { generateManifest } from './manifest'
|
export { generateManifest } from './manifest'
|
||||||
|
|
||||||
|
export { MemoryCache, getCache, setCache, resetCache, cacheGet, cachePut, cachePurge, deriveCacheKey } from './cache'
|
||||||
|
export type { CacheBackend } from './cache'
|
||||||
|
export { setCacheSecret } from './dispatch'
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
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')
|
const UserCtx = new ReactContext('user')
|
||||||
|
|
||||||
@@ -275,3 +275,152 @@ describe('Manifest', () => {
|
|||||||
expect(r.headers['Cache-Control']).toBe('no-store')
|
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