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,89 @@
/**
* Edge Manifest Generator
*
* Produces the same JSON format as mizan-django. One Edge Worker.
* Two backend languages. Same manifest.
*/
import type { EdgeManifest } from './types'
import { getAllFunctions, getContextGroups, getContextParamNames } from './registry'
const USER_SCOPED_PARAMS = new Set(['userId', 'user', 'ownerId', 'accountId', 'user_id', 'owner_id', 'account_id'])
export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
const groups = getContextGroups()
const allFunctions = getAllFunctions()
const manifest: EdgeManifest = { contexts: {}, mutations: {} }
// Contexts
for (const [ctxName, fnNames] of Object.entries(groups)) {
const paramNames = new Set<string>()
const functions: Array<{ name: string; path: 'rpc' | 'view'; route?: string; methods?: string[] }> = []
const pageRoutes: string[] = []
for (const fnName of fnNames) {
const entry = allFunctions.get(fnName)
if (!entry) continue
for (const p of entry.params) paramNames.add(p.name)
const fnEntry: any = { name: fnName, path: entry.viewPath ? 'view' : 'rpc' }
if (entry.route) {
fnEntry.route = entry.route
fnEntry.methods = entry.methods || ['GET']
pageRoutes.push(entry.route)
}
functions.push(fnEntry)
}
const sortedParams = [...paramNames].sort()
const userScoped = [...paramNames].some(p => USER_SCOPED_PARAMS.has(p))
const ctxEntry: EdgeManifest['contexts'][string] = {
functions,
endpoints: [`${baseUrl}/ctx/${ctxName}/`],
params: sortedParams,
user_scoped: userScoped,
render_strategy: userScoped ? 'dynamic_cached' : 'psr',
}
if (pageRoutes.length > 0) {
ctxEntry.page_routes = pageRoutes
}
manifest.contexts[ctxName] = ctxEntry
}
// Mutations
for (const [fnName, entry] of allFunctions) {
if (!entry.affects) continue
const affectedContexts = [...new Set(entry.affects.map(a => a.name))]
// Auto-scoped params
const fnParamNames = new Set(entry.params.map(p => p.name))
const autoScoped: string[] = []
for (const ctxName of affectedContexts) {
const ctxParams = getContextParamNames(ctxName)
for (const p of fnParamNames) {
if (ctxParams.has(p) && !autoScoped.includes(p)) {
autoScoped.push(p)
}
}
}
const mutation: EdgeManifest['mutations'][string] = {
affects: affectedContexts,
}
if (autoScoped.length > 0) mutation.auto_scoped_params = autoScoped.sort()
if (entry.private) mutation.private = true
if (entry.route) {
mutation.route = entry.route
mutation.methods = entry.methods || ['POST']
}
manifest.mutations[fnName] = mutation
}
return manifest
}