From 4744ff052e8d1a5fc7a597e9f39725dae0c607aa Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Tue, 7 Apr 2026 01:03:24 -0400 Subject: [PATCH] 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) --- .../mizan-django/src/mizan/tests/test_core.py | 20 +++ packages/mizan-ts/src/cache/backend.ts | 70 ++++++++ packages/mizan-ts/src/cache/index.ts | 100 ++++++++++++ packages/mizan-ts/src/cache/keys.ts | 69 ++++++++ packages/mizan-ts/src/dispatch.ts | 61 +++++++ packages/mizan-ts/src/index.ts | 4 + packages/mizan-ts/tests/edge-compat.test.ts | 151 +++++++++++++++++- 7 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 packages/mizan-ts/src/cache/backend.ts create mode 100644 packages/mizan-ts/src/cache/index.ts create mode 100644 packages/mizan-ts/src/cache/keys.ts diff --git a/packages/mizan-django/src/mizan/tests/test_core.py b/packages/mizan-django/src/mizan/tests/test_core.py index 412093f..4191d0f 100644 --- a/packages/mizan-django/src/mizan/tests/test_core.py +++ b/packages/mizan-django/src/mizan/tests/test_core.py @@ -2841,6 +2841,26 @@ class CacheKeyDerivationTests(TestCase): scoped = derive_cache_key(self.SECRET, "products", {"id": "1"}, user_id="5") 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): """Tests for MemoryCache backend operations.""" diff --git a/packages/mizan-ts/src/cache/backend.ts b/packages/mizan-ts/src/cache/backend.ts new file mode 100644 index 0000000..6d1fd9a --- /dev/null +++ b/packages/mizan-ts/src/cache/backend.ts @@ -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 + removeFromIndex(indexKey: string, members: Set): void + deleteIndex(indexKey: string): void + deleteIndexesByPrefix(prefix: string): void + clear(): void +} + +export class MemoryCache implements CacheBackend { + private _store = new Map() + private _indexes = new Map>() + + 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 { + return new Set(this._indexes.get(indexKey) ?? []) + } + + removeFromIndex(indexKey: string, members: Set): 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() + } +} diff --git a/packages/mizan-ts/src/cache/index.ts b/packages/mizan-ts/src/cache/index.ts new file mode 100644 index 0000000..cbf7635 --- /dev/null +++ b/packages/mizan-ts/src/cache/index.ts @@ -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, + 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, + 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 | null, +): number { + if (params) { + // Scoped purge — AND semantics (intersection) + const setsPerParam: Set[] = [] + 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 + 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 + } +} diff --git a/packages/mizan-ts/src/cache/keys.ts b/packages/mizan-ts/src/cache/keys.ts new file mode 100644 index 0000000..1a64dc2 --- /dev/null +++ b/packages/mizan-ts/src/cache/keys.ts @@ -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, + userId?: string, + rev: number = 0, +): string { + // Stringify all param values for cross-language determinism + const sortedParams: Record = {} + for (const [k, v] of Object.entries(params).sort(([a], [b]) => a.localeCompare(b))) { + sortedParams[k] = String(v) + } + + const keyData: Record = { 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[] { + 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 +} diff --git a/packages/mizan-ts/src/dispatch.ts b/packages/mizan-ts/src/dispatch.ts index 5095679..80239ad 100644 --- a/packages/mizan-ts/src/dispatch.ts +++ b/packages/mizan-ts/src/dispatch.ts @@ -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 = {} 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 } diff --git a/packages/mizan-ts/src/index.ts b/packages/mizan-ts/src/index.ts index 5772e88..bdd1eee 100644 --- a/packages/mizan-ts/src/index.ts +++ b/packages/mizan-ts/src/index.ts @@ -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' diff --git a/packages/mizan-ts/tests/edge-compat.test.ts b/packages/mizan-ts/tests/edge-compat.test.ts index cdd9e46..e917238 100644 --- a/packages/mizan-ts/tests/edge-compat.test.ts +++ b/packages/mizan-ts/tests/edge-compat.test.ts @@ -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) + }) +})