Files
mizan/backends/mizan-ts/src/decorator.ts
Ryth Azhur 6c5f6f1fba AFI parity: close all 35 gaps — every adapter wires every AFI-common capability
The conformance board (tests/afi/test_capability_parity.py) is now fully green:
90 capability cells + 4 meta-locks + 3 codegen byte-parity = 97 passed. The
gaps the prose table used to launder as "Django-only" / "out of scope" are
wired, against the pinned-spec model (single-authored spec, byte-identical
conformance across languages) — never per-language reimplementation.

FastAPI — edge_manifest + PSR (logic single-sourced in mizan_core.manifest),
WebSocket RPC (/ws/ through the shared dispatch), SSR (the framework-agnostic
SSRBridge relocated to mizan_core.ssr; Django rides it from there), Shapes
(SQLAlchemy projection, same declaration surface as django-readers), Forms
(Pydantic schema/validate/submit).

Rust (Axum + Tauri + cores/mizan-rust) — X-Mizan-Invalidate header, auth=
enforcement, origin HMAC cache, edge manifest + PSR, WebSocket handler / IPC
subscription channel, multipart upload, SSR bridge, Shapes, Forms; JWT/MWT
mint+verify and cache-key derivation byte-pinned to the Python reference
(cache_keys_pin, token_pin, invalidate_header_pin).

TypeScript — a KDL IR emitter byte-identical to the Python build_ir (so a TS
backend can feed the codegen — the largest gap), multipart upload, session-init,
WebSocket transport, SSR bridge, JWT/MWT mint (pinned to Python), Shapes, Forms.

Verified in the merged tree: core 25, fastapi 74, django 353/21-skip,
mizan-rust (incl. cross-language pins) green, axum 10, tauri 8, mizan-ts 103/2-skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:44:35 -04:00

140 lines
4.4 KiB
TypeScript

/**
* 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, type AuthRequirement, type AffectsTarget } from './types'
import { register } from './registry'
function resolveContext(ctx: ReactContext | string | undefined): string | undefined {
if (ctx instanceof ReactContext) return ctx.name
return ctx
}
function normalizeMerge(merge: ClientOptions['merge']): string[] | undefined {
if (!merge) return undefined
const items = Array.isArray(merge) ? merge : [merge]
return items.map((m: AffectsTarget) => (m instanceof ReactContext ? m.name : m))
}
/**
* Normalize the public auth option into the stored requirement.
* Mirrors Python: undefined→undefined, true→'required', callable→callable,
* 'staff'/'superuser' pass through, anything else throws at decoration time.
*/
function normalizeAuth(auth: ClientOptions['auth']): AuthRequirement | undefined {
if (auth === undefined) return undefined
if (auth === true) return 'required'
if (typeof auth === 'function') return auth
if (auth === 'staff' || auth === 'superuser') return auth
throw new Error(`Invalid auth value ${JSON.stringify(auth)}`)
}
function normalizeAffects(
affects: ClientOptions['affects'],
): RegistryEntry['affects'] | undefined {
if (!affects) return undefined
const items = Array.isArray(affects) ? affects : [affects]
return items.map(item => {
if (item instanceof ReactContext) {
return { type: 'context' as const, name: item.name }
}
return { type: 'context' as const, name: item }
})
}
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 []
return match[1]
.split(',')
.map(p => p.trim())
.filter(p => p && !p.startsWith('...'))
.map(p => {
// Handle destructured defaults: name = default, name: type
const name = p.split(/[=:]/)[0].trim()
return { name, type: 'any', required: !p.includes('=') }
})
}
function buildEntry(options: ClientOptions, name: string, fn: Function): RegistryEntry {
const context = resolveContext(options.context)
const affects = normalizeAffects(options.affects)
if (context && affects) {
throw new Error('context and affects are mutually exclusive')
}
return {
name,
fn: fn as any,
context,
affects,
merge: normalizeMerge(options.merge),
params: extractParams(fn),
private: options.private ?? false,
viewPath: false,
route: options.route,
methods: options.methods,
auth: normalizeAuth(options.auth),
websocket: options.websocket,
rev: options.rev,
cache: options.cache,
ir: options.ir,
form: options.form,
formName: options.formName,
formRole: options.formRole,
}
}
/**
* Function wrapper — registers a standalone function.
*
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
*/
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) { ... }
* }
*/
export function client(options: ClientOptions): MethodDecorator
export function client(optionsOrFn: ClientOptions, fn?: Function): any {
// Function wrapper form: client(options, fn)
if (fn && typeof fn === 'function') {
const options = optionsOrFn as ClientOptions
const name = fn.name || 'anonymous'
register(buildEntry(options, name, fn))
return fn
}
// Decorator form: @client(options)
const options = optionsOrFn as ClientOptions
return function (_target: any, propertyKey: string, descriptor: PropertyDescriptor) {
register(buildEntry(options, propertyKey, descriptor.value))
return descriptor
}
}