A channel's message slots are named from the client, on every backend

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>
This commit is contained in:
2026-07-27 14:03:19 -04:00
parent 398c90fc8b
commit 3aafec6dd4
345 changed files with 11054 additions and 17359 deletions

View File

@@ -1,9 +1,9 @@
/**
* Cache backends — MemoryCache for testing.
* A cache backend is a flat string-to-string store.
*
* Simple key-value store. No reverse indexes.
* There is no reverse index from a context to its keys, so `deleteByPrefix` is
* what a broad purge relies on and every backend owes it.
*/
export interface CacheBackend {
get(key: string): string | null
set(key: string, value: string): void
@@ -29,6 +29,7 @@ export class MemoryCache implements CacheBackend {
deleteByPrefix(prefix: string): number {
let count = 0
// Snapshot the keys — deleting while iterating the live view is UB.
for (const key of [...this._store.keys()]) {
if (key.startsWith(prefix)) {
this._store.delete(key)

View File

@@ -1,11 +1,3 @@
/**
* mizan cache — TypeScript adapter.
*
* Same protocol as Python's mizan.cache. Cross-language conformance
* verified by pin tests. No reverse indexes — scoped purge recomputes
* the key directly, broad purge uses prefix scan.
*/
export { MemoryCache } from './backend'
export type { CacheBackend } from './backend'
export { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
@@ -52,6 +44,13 @@ export function cachePut(
backend.set(key, value)
}
/**
* Delete cached entries for a context. Returns the number removed.
*
* With params and a secret the exact key is recomputed and dropped; without
* them every key carrying the context prefix is scanned and dropped. There is
* no reverse index from a context to its keys, so those are the only two forms.
*/
export function cachePurge(
backend: CacheBackend,
context: string,
@@ -61,11 +60,9 @@ export function cachePurge(
rev: number = 0,
): number {
if (params && secret) {
// Scoped purge — recompute key and delete directly
const key = deriveCacheKey(secret, context, params, userId, rev)
return backend.delete(key) ? 1 : 0
} else {
// Broad purge — prefix scan
const prefix = `${CONTEXT_KEY_PREFIX}${context}:`
return backend.deleteByPrefix(prefix)
}

View File

@@ -1,10 +1,8 @@
/**
* Cache key derivation — HMAC-SHA256 over JSON-canonical form.
* Cache key derivation — HMAC-SHA256 over a JSON-canonical form.
*
* Protocol-critical: must produce identical output to Python's derive_cache_key.
* Cross-language conformance verified by pin tests.
*
* Key format: "ctx:{context}:{hmac_hex}" — enables broad purge by prefix scan.
* Key format: "ctx:{context}:{hmac_hex}". The context prefix is what lets a
* broad purge run as a prefix scan over the backend's keyspace.
*/
import { createHmac } from 'crypto'
@@ -13,7 +11,11 @@ const CONTEXT_KEY_PREFIX = 'ctx:'
/**
* JSON.stringify with recursively sorted keys and no whitespace.
* Equivalent to Python's json.dumps(obj, sort_keys=True, separators=(",", ":"))
*
* Hand-rolled rather than JSON.stringify because the bytes must match
* Python's json.dumps(obj, sort_keys=True, separators=(",", ":")) exactly —
* a key derived here is looked up by the Python side under the same secret,
* so any serialization drift silently splits the keyspace in two.
*/
function stableStringify(obj: any): string {
if (obj === null || obj === undefined) return 'null'

View File

@@ -1,18 +1,3 @@
/**
* Mizan @client decorator and function wrapper.
*
* Two registration styles:
*
* 1. Function wrapper (standalone functions):
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
*
* 2. Class decorator (methods):
* class Handlers {
* @client({ context: UserCtx })
* async userProfile(userId: number) { ... }
* }
*/
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef } from './types'
import { register } from './registry'
@@ -35,8 +20,14 @@ function normalizeAffects(
})
}
/**
* Recover parameter names by parsing the function's own source text.
*
* Names are the wire contract — the dispatcher matches request params to
* positional arguments by name — and JS erases them at runtime, so the
* source string is the only place they survive.
*/
function extractParams(fn: Function): ParamDef[] {
// Extract parameter names from function.toString()
const source = fn.toString()
const match = source.match(/\(([^)]*)\)/)
if (!match || !match[1].trim()) return []
@@ -46,33 +37,22 @@ function extractParams(fn: Function): ParamDef[] {
.map(p => p.trim())
.filter(p => p && !p.startsWith('...'))
.map(p => {
// Handle destructured defaults: name = default, name: type
// Strips a default value or a type annotation off the name
const name = p.split(/[=:]/)[0].trim()
return { name, type: 'any', required: !p.includes('=') }
})
}
/**
* Function wrapper — registers a standalone function.
*
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
*/
/** Wrap and register a standalone function. */
export function client<T extends (...args: any[]) => Promise<any>>(
options: ClientOptions,
fn: T,
): T
/**
* Class method decorator.
*
* class Handlers {
* @client({ context: UserCtx })
* async userProfile(userId: number) { ... }
* }
*/
/** Register a class method. */
export function client(options: ClientOptions): MethodDecorator
export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function): any {
export function client(optionsOrFn: ClientOptions, fn?: Function): any {
// Function wrapper form: client(options, fn)
if (fn && typeof fn === 'function') {
const options = optionsOrFn as ClientOptions
@@ -85,16 +65,17 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
const name = fn.name || 'anonymous'
const params = extractParams(fn)
const isView = false // Determined at call time for function wrappers
const entry: RegistryEntry = {
name,
fn: fn as any,
fn: fn as RegistryEntry['fn'],
context,
affects,
params,
private: options.private ?? false,
viewPath: isView,
// A wrapped function's return is only known once called, so the
// view-vs-RPC split is decided by the dispatcher, not here.
viewPath: false,
route: options.route,
methods: options.methods,
auth: options.auth,

View File

@@ -1,8 +1,8 @@
/**
* Request dispatch — context GET and mutation POST handlers.
* Context GET and mutation POST handlers.
*
* Framework-agnostic. Returns plain objects. The router adapter
* (Express, Hono, etc.) converts to framework-specific responses.
* Handlers return plain MizanResponse objects; turning one into a
* framework's own response type is the router adapter's job.
*/
import { getFunction, getContextGroups } from './registry'
@@ -62,7 +62,10 @@ export async function handleContextFetch(
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Mizan-Cache': 'HIT' },
}
}
} catch { /* cache miss on error */ }
} catch (e: any) {
// A failed lookup degrades to a miss, so recompute below rather than fail the request.
console.error(`mizan: cache lookup failed for context '${contextName}'`, e)
}
}
const results: Record<string, any> = {}
@@ -86,6 +89,7 @@ export async function handleContextFetch(
results[fnName] = result
} catch (e: any) {
console.error(`mizan: context function '${fnName}' raised`, e)
return {
status: 500,
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
@@ -111,7 +115,10 @@ export async function handleContextFetch(
if (cacheBackend && cacheSecret && effectiveCache !== false) {
try {
cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev)
} catch { /* cache store failure is non-fatal */ }
} catch (e: any) {
// The results are already computed, so a store failure costs a future hit, not this response.
console.error(`mizan: cache store failed for context '${contextName}'`, e)
}
}
return {
@@ -186,20 +193,25 @@ export async function handleMutationCall(
// Purge origin-side cache
const cb = getCache()
if (cb) {
try {
for (const entry of invalidate) {
if (typeof entry === 'string') {
cachePurge(cb, entry)
for (const target of invalidate) {
try {
if (typeof target === 'string') {
cachePurge(cb, target)
} else {
cachePurge(cb, entry.context, entry.params, _cacheSecret)
cachePurge(cb, target.context, target.params, _cacheSecret)
}
} catch (e: any) {
// The client still gets X-Mizan-Invalidate, so a stale origin entry
// is recoverable; one bad target must not skip the remaining ones.
console.error(`mizan: cache purge failed for`, target, e)
}
} catch { /* purge failure is non-fatal */ }
}
}
}
return { status: 200, body: responseData, headers }
} catch (e: any) {
console.error(`mizan: mutation '${fnName}' raised`, e)
return {
status: 500,
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },

View File

@@ -1,20 +1,15 @@
/**
* Invalidation protocol — header formatting, auto-scoping.
*
* Matches Django's implementation exactly. Same format. Same rules.
*/
import type { RegistryEntry } from './types'
import { getContextGroups, getContextParamNames, getFunction } from './registry'
import { getContextGroups, getContextParamNames } from './registry'
type InvalidateEntry = string | { context: string; params: Record<string, any> }
/**
* Resolve invalidation targets with three-tier auto-scoping.
* Resolve what a mutation's `affects` targets invalidate.
*
* Tier 1: Argument name matching
* Tier 2: Auth inference (Edge-side, not handled here)
* Tier 3: Broad fallback
* Each target narrows to the call arguments whose names the target's context
* also declares as params — that intersection is the scoped purge. A target
* with no such overlap emits as a bare context name, meaning purge every
* entry under that context.
*/
export function resolveInvalidation(
entry: RegistryEntry,
@@ -34,7 +29,6 @@ export function resolveInvalidation(
const resolved = resolveAffectsTarget(targetName)
const ctxForParams = resolved.type === 'function' ? resolved.context : resolved.name
// Tier 1: argument name matching
if (callArgs && ctxForParams) {
const contextParams = getContextParamNames(ctxForParams)
const matched: Record<string, any> = {}
@@ -47,7 +41,6 @@ export function resolveInvalidation(
}
}
// Tier 3: broad fallback
result.push(targetName)
}
@@ -55,7 +48,10 @@ export function resolveInvalidation(
}
/**
* Determine whether an affects target is a context name or function name.
* Determine whether an affects target names a context or a function.
*
* An unrecognized name resolves as a context, so a target registered later
* still purges by name rather than being dropped here.
*/
function resolveAffectsTarget(name: string): { type: 'context' | 'function'; name: string; context?: string } {
const groups = getContextGroups()
@@ -74,9 +70,10 @@ function resolveAffectsTarget(name: string): { type: 'context' | 'function'; nam
}
/**
* Format invalidation targets as X-Mizan-Invalidate header value.
* Format invalidation targets as an X-Mizan-Invalidate header value.
*
* Format: comma-separated contexts. Semicolon-separated URL-encoded params.
* Comma-separated targets; within a target, semicolon-separated URL-encoded
* params follow the context name.
*/
export function formatInvalidateHeader(invalidate: InvalidateEntry[]): string {
const parts: string[] = []

View File

@@ -1,15 +1,8 @@
/**
* 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 type { EdgeManifest, ManifestFunction } from './types'
import { getAllFunctions, getContextGroups, getContextParamNames } from './registry'
// Both camelCase and snake_case forms included for cross-language matching.
// Wire format is snake_case (protocol rule); camelCase is the TS-local convention.
// Wire format is snake_case; camelCase is the TS-local convention. Both forms
// are listed because a param name arrives here as whichever the author wrote.
const USER_SCOPED_PARAMS = new Set(['userId', 'user', 'ownerId', 'accountId', 'user_id', 'owner_id', 'account_id'])
export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
@@ -20,7 +13,7 @@ export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
// 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 functions: ManifestFunction[] = []
const pageRoutes: string[] = []
for (const fnName of fnNames) {
@@ -29,14 +22,14 @@ export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
for (const p of entry.params) paramNames.add(p.name)
const fnEntry: any = { name: fnName, path: entry.viewPath ? 'view' : 'rpc' }
const fnEntry: ManifestFunction = { name: fnName, path: entry.viewPath ? 'view' : 'rpc' }
if (entry.route) {
fnEntry.route = entry.route
fnEntry.methods = entry.methods || ['GET']
pageRoutes.push(entry.route)
}
if (entry.rev !== undefined && entry.rev !== 0) fnEntry.rev = entry.rev
if (entry.cache !== undefined && entry.cache !== true) fnEntry.cache = entry.cache
if (entry.cache !== undefined) fnEntry.cache = entry.cache
functions.push(fnEntry)
}

View File

@@ -1,12 +1,10 @@
/**
* Mizan Registry — Central registration for server functions.
*/
import type { RegistryEntry } from './types'
const _functions: Map<string, RegistryEntry> = new Map()
export function register(entry: RegistryEntry): void {
// Re-registering the same function object is a module re-evaluation, not a
// name collision, so only a different fn under a taken name is an error.
if (_functions.has(entry.name) && _functions.get(entry.name)!.fn !== entry.fn) {
throw new Error(`Function '${entry.name}' already registered`)
}

View File

@@ -1,7 +1,3 @@
/**
* Mizan TypeScript Adapter — Shared Types
*/
export class ReactContext {
constructor(public readonly name: string) {
if (!name) throw new Error('ReactContext name must be non-empty')
@@ -42,8 +38,17 @@ export interface RegistryEntry {
cache?: number | false
}
export interface ManifestFunction {
name: string
path: 'rpc' | 'view'
route?: string
methods?: string[]
rev?: number
cache?: number | false
}
export interface ManifestContext {
functions: Array<{ name: string; path: 'rpc' | 'view' }>
functions: ManifestFunction[]
endpoints: string[]
params: string[]
user_scoped: boolean