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:
7
backends/mizan-ts/src/cache/backend.ts
vendored
7
backends/mizan-ts/src/cache/backend.ts
vendored
@@ -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)
|
||||
|
||||
17
backends/mizan-ts/src/cache/index.ts
vendored
17
backends/mizan-ts/src/cache/index.ts
vendored
@@ -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)
|
||||
}
|
||||
|
||||
14
backends/mizan-ts/src/cache/keys.ts
vendored
14
backends/mizan-ts/src/cache/keys.ts
vendored
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user