Add mizan-ts: TypeScript backend adapter proving AFI is language-agnostic

The TypeScript adapter produces the same manifest, the same
X-Mizan-Invalidate headers, the same JSON invalidation protocol,
and the same CDN-ready response headers as mizan-django.

One Edge Worker. Two backend languages. Same protocol.

Features:
- @client decorator (function wrapper + class method decorator)
- ReactContext class (same API as Django adapter)
- Registry with context groups and param tracking
- Context bundled GET: /api/mizan/ctx/<name>/
- Mutation POST: /api/mizan/call/ with server-driven invalidation
- Three-tier auto-scoping (argument name matching → broad fallback)
- Function-level affects targeting
- private=True (rejected from RPC, in manifest for Edge)
- X-Mizan-Invalidate header with URL-encoded params
- Edge manifest generation (identical format to Django's)
- render_strategy + user_scoped derivation

22 edge compatibility tests pass (Bun, 21ms):
- Deterministic JSON, sorted keys
- Cache-Control: public on GETs, no-store on mutations/errors
- Vary: Authorization, Cookie
- Header round-trip with special characters
- Auto-scoped invalidation matches body and header
- Function-level invalidation
- Private function rejection
- Manifest structure with PSR/dynamic_cached strategies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 00:19:48 -04:00
parent d228c7ab1b
commit 97237ed1a4
11 changed files with 889 additions and 0 deletions

View File

@@ -0,0 +1,238 @@
/**
* Edge Compatibility Tests — mirrors Django's EdgeCompatibilityTests exactly.
*
* These prove that a Cloudflare Worker (Edge) can sit in front of a
* TypeScript backend and behave identically to sitting in front of Django.
*/
import { describe, test, expect, beforeEach } from 'bun:test'
import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest } from '../src'
const UserCtx = new ReactContext('user')
function setupUserContext() {
const userProfile = client({ context: UserCtx }, async function userProfile(userId: number) {
return { name: `user_${userId}`, email: `user${userId}@test.com` }
})
const userOrders = client({ context: UserCtx }, async function userOrders(userId: number) {
return { count: userId * 10 }
})
const updateProfile = 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 is cacheable', async () => {
const r = await handleContextFetch('user', { userId: '5' })
expect(r.headers['Cache-Control']).toContain('public')
expect(r.headers['Cache-Control']).toContain('stale-while-revalidate')
expect(r.headers['Cache-Control']).not.toContain('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')
})
// ── Vary header ────────────────────────────────────────────────────
test('Vary header present on context GET', async () => {
const r = await handleContextFetch('user', { userId: '5' })
expect(r.headers['Vary']).toContain('Authorization')
expect(r.headers['Vary']).toContain('Cookie')
})
// ── 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 (what Edge 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 () => {
client({ context: new ReactContext('plain') }, async function plainFn() {
return { ok: true }
})
// A context function called via mutation dispatch (shouldn't have invalidation)
// Actually test a function without affects
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.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'])
})
})