The IR called them react-message and django-message, so a FastAPI channel had to declare a DjangoMessage. They are client-message and server-message now, and the direction words hold wherever a channel is declared: Params / ClientMessage / ServerMessage, with mizan-core deriving <Pascal>Params and friends so no backend names a type itself. Django's ReactChannel and FastAPI's ReactChannel are both Channel. mizan-fastapi never registered a channels extension, so build_ir() emitted no channel at all and every payload type was invisible to codegen. It registers one now. RegistryExtension is an ABC requiring all(), which is what the IR reads — an extension that cannot enumerate its registrations no longer exists. The gate that should have caught the rename could not: tests/afi registered no channel because mizan-rust had no channel registry to register one in, so a five-package rename of the wire contract passed byte-parity without a channel byte crossing it. mizan-rust grows ChannelSlotKind, a CHANNELS slice, a #[mizan::channel] macro, and KDL emission whose wire_to_pascal matches Python's split; the AFI fixture now carries a channel with every slot and one with a single slot, so all three backends prove the contract byte for byte. MizanChannel held three Option<String> beside three has_*() predicates and unwrapped them with defaults; it holds an ordered slot vector, so an absent slot is absent rather than defaulted. The channels target emitted a React hooks file that a stage1-only consumer could not compile — react emits that now. The codegen's parity tests byte-compared emitted source against baselines without ever compiling it: they compile the generated crate and run its tests, import the generated Python package and call every method, and typecheck each TypeScript target against a consumer. Also fixed at source: app_visitor printed its import diagnostic to stdout, the stream export_mizan_ir writes KDL to, so a failed import silently corrupted the IR; the apps root was hardcoded to "apps"; _default_literal crashed build_ir on any non-JSON-serializable field default; Django and mizan-core derived Pascal names two different ways, disagreeing on every dotted channel name. ir.py builds a document and renders templates/ir/document.kdl.j2 rather than appending KDL strings with hand-tracked indentation, and named types resolve to a fixed point — a model reachable only through a union branch was referenced by a ref that no type block ever defined. The rest is the write-gate's own classifiers run over the standing tree: relative imports, silent swallows, Protocol contracts that should be ABCs, emitters hand-rendering target source, catch-all arms over closed enums, and comments narrating the project rather than the code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
447 lines
17 KiB
TypeScript
447 lines
17 KiB
TypeScript
import { describe, test, expect, beforeEach } from 'bun:test'
|
|
import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest, MemoryCache, setCache, resetCache, setCacheSecret, deriveCacheKey, cacheGet, cachePut, cachePurge } from '../src'
|
|
|
|
const UserCtx = new ReactContext('user')
|
|
|
|
function setupUserContext() {
|
|
client({ context: UserCtx }, async function userProfile(userId: number) {
|
|
return { name: `user_${userId}`, email: `user${userId}@test.com` }
|
|
})
|
|
|
|
client({ context: UserCtx }, async function userOrders(userId: number) {
|
|
return { count: userId * 10 }
|
|
})
|
|
|
|
client({ affects: UserCtx }, async function updateProfile(userId: number, name: string) {
|
|
return { name, email: `user${userId}@test.com` }
|
|
})
|
|
|
|
client({ affects: 'userProfile' }, async function updateName(userId: number, name: string) {
|
|
return { name, email: `user${userId}@test.com` }
|
|
})
|
|
}
|
|
|
|
describe('Edge Compatibility', () => {
|
|
beforeEach(() => {
|
|
clearRegistry()
|
|
setupUserContext()
|
|
})
|
|
|
|
// ── Deterministic JSON ──────────────────────────────────────────────
|
|
|
|
test('deterministic JSON output', async () => {
|
|
const r1 = await handleContextFetch('user', { userId: '5' })
|
|
const r2 = await handleContextFetch('user', { userId: '5' })
|
|
expect(JSON.stringify(r1.body)).toBe(JSON.stringify(r2.body))
|
|
})
|
|
|
|
test('different params produce different responses', async () => {
|
|
const r1 = await handleContextFetch('user', { userId: '5' })
|
|
const r2 = await handleContextFetch('user', { userId: '6' })
|
|
expect(JSON.stringify(r1.body)).not.toBe(JSON.stringify(r2.body))
|
|
expect(r1.body.userProfile.name).toBe('user_5')
|
|
expect(r2.body.userProfile.name).toBe('user_6')
|
|
})
|
|
|
|
// ── Cache-Control correctness ───────────────────────────────────────
|
|
|
|
test('context GET emits no-store', async () => {
|
|
const r = await handleContextFetch('user', { userId: '5' })
|
|
expect(r.headers['Cache-Control']).toBe('no-store')
|
|
})
|
|
|
|
test('mutation POST not cacheable', async () => {
|
|
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
|
|
expect(r.headers['Cache-Control']).toBe('no-store')
|
|
})
|
|
|
|
test('error response not cacheable', async () => {
|
|
const r = await handleContextFetch('nonexistent', {})
|
|
expect(r.status).toBe(404)
|
|
expect(r.headers['Cache-Control']).toBe('no-store')
|
|
})
|
|
|
|
// ── X-Mizan-Invalidate header ──────────────────────────────────────
|
|
|
|
test('mutation response includes invalidation header', async () => {
|
|
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
|
|
expect(r.headers['X-Mizan-Invalidate']).toBeDefined()
|
|
expect(r.headers['X-Mizan-Invalidate']).toContain('user')
|
|
})
|
|
|
|
test('auto-scoped invalidation in header', async () => {
|
|
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
|
|
expect(r.headers['X-Mizan-Invalidate']).toBe('user;userId=5')
|
|
})
|
|
|
|
test('invalidation header matches JSON body', async () => {
|
|
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
|
|
const body = r.body
|
|
expect(body.invalidate[0].context).toBe('user')
|
|
expect(body.invalidate[0].params.userId).toBe(5)
|
|
expect(r.headers['X-Mizan-Invalidate']).toContain('user;userId=5')
|
|
})
|
|
|
|
test('function-level invalidation in header', async () => {
|
|
const r = await handleMutationCall('updateName', { userId: 7, name: 'X' })
|
|
expect(r.headers['X-Mizan-Invalidate']).toContain('userProfile')
|
|
})
|
|
|
|
test('no invalidation header on context GET', async () => {
|
|
const r = await handleContextFetch('user', { userId: '5' })
|
|
expect(r.headers['X-Mizan-Invalidate']).toBeUndefined()
|
|
})
|
|
|
|
// ── Header format edge cases ───────────────────────────────────────
|
|
|
|
test('special characters in param values are URL-encoded', () => {
|
|
const header = formatInvalidateHeader([
|
|
{ context: 'search', params: { q: 'a;b' } },
|
|
])
|
|
expect(header).not.toContain(';b')
|
|
expect(header).toContain('a%3Bb')
|
|
})
|
|
|
|
test('spaces in param values are URL-encoded', () => {
|
|
const header = formatInvalidateHeader([
|
|
{ context: 'search', params: { q: 'hello world' } },
|
|
])
|
|
expect(header).not.toContain(' ')
|
|
expect(header).toContain('hello%20world')
|
|
})
|
|
|
|
test('header round-trip with special chars', () => {
|
|
const header = formatInvalidateHeader([
|
|
{ context: 'data', params: { name: "O'Brien", tag: 'a;b;c' } },
|
|
])
|
|
|
|
// Parse the header the way the Edge worker does
|
|
const segments = header.split(';')
|
|
const ctx = segments[0]
|
|
const params: Record<string, string> = {}
|
|
for (const seg of segments.slice(1)) {
|
|
const [k, v] = seg.split('=', 2)
|
|
params[decodeURIComponent(k)] = decodeURIComponent(v)
|
|
}
|
|
|
|
expect(ctx).toBe('data')
|
|
expect(params.name).toBe("O'Brien")
|
|
expect(params.tag).toBe('a;b;c')
|
|
})
|
|
|
|
// ── Empty invalidation ─────────────────────────────────────────────
|
|
|
|
test('no affects = no header, no body key', async () => {
|
|
clearRegistry()
|
|
client({}, async function noAffects() { return { ok: true } })
|
|
const r = await handleMutationCall('noAffects', {})
|
|
expect(r.body.invalidate).toBeUndefined()
|
|
expect(r.headers['X-Mizan-Invalidate']).toBeUndefined()
|
|
})
|
|
|
|
// ── Private functions ──────────────────────────────────────────────
|
|
|
|
test('private functions rejected from RPC', async () => {
|
|
clearRegistry()
|
|
client({ affects: 'subscription', private: true }, async function webhook() {
|
|
return { ok: true }
|
|
})
|
|
const r = await handleMutationCall('webhook', {})
|
|
expect(r.status).toBe(403)
|
|
})
|
|
|
|
// ── Unknown function ───────────────────────────────────────────────
|
|
|
|
test('unknown function returns 404', async () => {
|
|
const r = await handleMutationCall('doesNotExist', {})
|
|
expect(r.status).toBe(404)
|
|
})
|
|
|
|
test('unknown context returns 404', async () => {
|
|
const r = await handleContextFetch('doesNotExist', {})
|
|
expect(r.status).toBe(404)
|
|
})
|
|
})
|
|
|
|
describe('Manifest', () => {
|
|
beforeEach(() => {
|
|
clearRegistry()
|
|
setupUserContext()
|
|
})
|
|
|
|
test('manifest matches expected structure', () => {
|
|
const m = generateManifest()
|
|
|
|
expect(m.version).toBe(1)
|
|
expect(m.contexts.user).toBeDefined()
|
|
expect(m.contexts.user.endpoints).toEqual(['/api/mizan/ctx/user/'])
|
|
expect(m.contexts.user.params).toContain('userId')
|
|
expect(m.contexts.user.user_scoped).toBe(true)
|
|
expect(m.contexts.user.render_strategy).toBe('dynamic_cached')
|
|
})
|
|
|
|
test('mutations section includes auto-scoped params', () => {
|
|
const m = generateManifest()
|
|
|
|
expect(m.mutations.updateProfile).toBeDefined()
|
|
expect(m.mutations.updateProfile.affects).toEqual(['user'])
|
|
expect(m.mutations.updateProfile.auto_scoped_params).toContain('userId')
|
|
})
|
|
|
|
test('PSR strategy for non-user-scoped context', () => {
|
|
clearRegistry()
|
|
const ProductCtx = new ReactContext('products')
|
|
client({ context: ProductCtx }, async function productDetail(productId: number) {
|
|
return { id: productId }
|
|
})
|
|
|
|
const m = generateManifest()
|
|
expect(m.contexts.products.user_scoped).toBe(false)
|
|
expect(m.contexts.products.render_strategy).toBe('psr')
|
|
})
|
|
|
|
test('private mutation in manifest', () => {
|
|
clearRegistry()
|
|
client(
|
|
{ affects: 'subscription', private: true, route: '/webhooks/stripe/', methods: ['POST'] },
|
|
async function stripeWebhook() { return new Response('ok') },
|
|
)
|
|
|
|
const m = generateManifest()
|
|
expect(m.mutations.stripeWebhook).toBeDefined()
|
|
expect(m.mutations.stripeWebhook.private).toBe(true)
|
|
expect(m.mutations.stripeWebhook.route).toBe('/webhooks/stripe/')
|
|
expect(m.mutations.stripeWebhook.methods).toEqual(['POST'])
|
|
})
|
|
|
|
test('rev appears in manifest', () => {
|
|
clearRegistry()
|
|
const Ctx = new ReactContext('data')
|
|
client({ context: Ctx, rev: 3 }, async function versionedFn(itemId: number) {
|
|
return { value: itemId }
|
|
})
|
|
|
|
const m = generateManifest()
|
|
const fn = m.contexts.data.functions[0]
|
|
expect(fn.rev).toBe(3)
|
|
})
|
|
|
|
test('cache TTL appears in manifest', () => {
|
|
clearRegistry()
|
|
const Ctx = new ReactContext('trending')
|
|
client({ context: Ctx, cache: 60 }, async function trendingFn() {
|
|
return { items: [] }
|
|
})
|
|
|
|
const m = generateManifest()
|
|
const fn = m.contexts.trending.functions[0]
|
|
expect(fn.cache).toBe(60)
|
|
})
|
|
|
|
test('cache=false appears in manifest', () => {
|
|
clearRegistry()
|
|
const Ctx = new ReactContext('nocache')
|
|
client({ context: Ctx, cache: false }, async function uncachedFn() {
|
|
return { value: 1 }
|
|
})
|
|
|
|
const m = generateManifest()
|
|
const fn = m.contexts.nocache.functions[0]
|
|
expect(fn.cache).toBe(false)
|
|
})
|
|
|
|
test('cache=60 still emits no-store on HTTP', async () => {
|
|
clearRegistry()
|
|
const Ctx = new ReactContext('live')
|
|
client({ context: Ctx, cache: 60 }, async function liveFn() {
|
|
return { score: 42 }
|
|
})
|
|
|
|
const r = await handleContextFetch('live', {})
|
|
expect(r.headers['Cache-Control']).toBe('no-store')
|
|
})
|
|
|
|
test('cache=false sets no-store', async () => {
|
|
clearRegistry()
|
|
const Ctx = new ReactContext('random')
|
|
client({ context: Ctx, cache: false }, async function randomFn() {
|
|
return { value: Math.random() }
|
|
})
|
|
|
|
const r = await handleContextFetch('random', {})
|
|
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).toStartWith('ctx:user:')
|
|
expect(k1).toHaveLength('ctx:user:'.length + 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)', () => {
|
|
// Literals captured from Python's derive_cache_key under the same
|
|
// secret. A key derived here is looked up by the Python side, so a
|
|
// mismatch means the two runtimes address different keyspaces.
|
|
const publicKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, undefined, 0)
|
|
expect(publicKey).toBe('ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6')
|
|
|
|
const userScopedKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, '5', 0)
|
|
expect(userScopedKey).toBe('ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2')
|
|
})
|
|
|
|
test('MemoryCache get/set/clear', () => {
|
|
const cache = new MemoryCache()
|
|
expect(cache.get('k1')).toBeNull()
|
|
|
|
cache.set('k1', '{"data":true}')
|
|
expect(cache.get('k1')).toBe('{"data":true}')
|
|
|
|
cache.clear()
|
|
expect(cache.get('k1')).toBeNull()
|
|
})
|
|
|
|
test('scoped purge recomputes key directly', () => {
|
|
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' }, SECRET)
|
|
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('cache=false context is never stored', async () => {
|
|
clearRegistry()
|
|
const Ctx = new ReactContext('volatile')
|
|
client({ context: Ctx, cache: false }, async function volatileFn(itemId: number) {
|
|
return { value: itemId }
|
|
})
|
|
|
|
const cache = new MemoryCache()
|
|
setCache(cache)
|
|
setCacheSecret(SECRET)
|
|
|
|
const r1 = await handleContextFetch('volatile', { itemId: '1' })
|
|
expect(r1.headers['X-Mizan-Cache']).toBe('MISS')
|
|
|
|
const r2 = await handleContextFetch('volatile', { itemId: '1' })
|
|
expect(r2.headers['X-Mizan-Cache']).toBe('MISS')
|
|
|
|
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)
|
|
})
|
|
})
|