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,5 @@
/**
* @mizan/base — The client state kernel.
*
* Zero framework dependencies. React, Vue, Svelte — all import from here.
*
* The kernel owns the data. Adapters subscribe and render.
* The client state kernel: context registry, merge, and invalidation.
*/
// === Error ===
@@ -24,7 +20,8 @@ export class MizanError extends Error {
this.code = source?.code ?? `HTTP_${status}`
this.details = source?.details
if (source?.message) this.message = source.message
} catch {
} catch (e) {
console.warn(`[mizan] Unparseable error envelope for HTTP ${status}:`, e)
this.code = `HTTP_${status}`
}
}
@@ -33,11 +30,8 @@ export class MizanError extends Error {
// === Transport ===
/**
* Wire surface the kernel uses to reach a Mizan backend. The default
* implementation is `httpTransport()` (POST /call/, GET /ctx/). Tauri
* apps swap in `tauriTransport()` from `@mizan/tauri-transport`. Any
* future transport — workers, edge runtimes, channels — implements this
* interface and replaces the default via `configure({ transport })`.
* Wire surface the kernel uses to reach a Mizan backend. `configure({
* transport })` swaps the default `httpTransport()` for another one.
*/
export interface MizanTransport {
/** RPC dispatch — invokes a Mizan-registered function. */
@@ -53,9 +47,8 @@ export interface MizanTransport {
}
/**
* Raw envelope a transport returns from `call()`. The kernel uses the
* `merge` and `invalidate` arrays to drive client-side cache updates;
* `result` is the function's typed return value.
* Raw envelope a transport returns from `call()`. `merge` and `invalidate`
* drive client-side cache updates; `result` is the function's return value.
*/
export interface MizanCallResponse {
result: any
@@ -72,18 +65,11 @@ interface MizanConfig {
csrfHeaderName: string
/**
* Whether the backend exposes `/session/` for CSRF/session bootstrap.
* `true` for Django (the default — preserves existing setups); set
* `false` for FastAPI or any backend that doesn't ship a session
* endpoint to avoid a 404 storm on startup. A future revision moves
* this onto the schema-advertised capability surface.
* Django does; a backend without that endpoint answers 404 on every
* startup unless this is `false`.
*/
session: boolean
/**
* Wire transport. Defaults to `httpTransport()` (fetch-based,
* compatible with FastAPI / Django backends). Swap with a custom
* transport (e.g. `tauriTransport()`) at app entry to route
* Mizan calls through a different channel.
*/
/** Wire transport used for every `call()` and `fetch()`. */
transport: MizanTransport
}
@@ -93,7 +79,7 @@ const config: MizanConfig = {
csrfCookieName: 'csrftoken',
csrfHeaderName: 'X-CSRFToken',
session: true,
// Initialized below once httpTransport is defined.
// Bound below, once httpTransport is in scope.
transport: null as unknown as MizanTransport,
}
@@ -167,10 +153,8 @@ function stableKey(params: Record<string, any>): string {
}
/**
* Register a context instance. The kernel owns the fetch lifecycle.
*
* Returns { getState, subscribe, refetch, unregister }.
* Adapters call subscribe() to get notified on state changes.
* Register a context instance keyed by (name, params). The returned handle
* exposes the entry's state, a subscription, a refetch, and teardown.
*/
export function registerContext(
name: string,
@@ -187,7 +171,8 @@ export function registerContext(
const key = stableKey(params)
const map = contexts.get(name)!
// Reuse existing entry if same key is re-registered (React Strict Mode)
// React Strict Mode re-registers the same key; reuse the entry so
// subscribers already attached to it are not orphaned.
let entry = map.get(key)
if (!entry) {
entry = {
@@ -202,7 +187,6 @@ export function registerContext(
}
map.set(key, entry)
} else {
// Update fetchFn in case closure changed
entry.fetchFn = fetchFn
}
@@ -241,13 +225,9 @@ export function registerContext(
// === Merge ===
//
// A mutation that declares `@client(merge=ctx)` returns `{merge: [{context,
// slot, params?, value}]}` alongside `result`/`invalidate`. The server has
// already resolved which bundle slot the value lands in (by matching the
// mutation's return type against each context function's return type), so
// the kernel does no inference — it writes directly to `bundle[slot]`,
// upserting by id when the slot is a list. The type information lives in
// the schema-aware backend layer; the kernel is type-erased on purpose.
// The server resolved which bundle slot the value lands in, so this writes
// directly to `bundle[slot]` with no inference — upserting by id when the
// slot holds a list.
function spliceSlot(slot: unknown, value: unknown): unknown {
if (Array.isArray(slot)) {
@@ -368,6 +348,7 @@ async function fetchWithRetry(
if (attempt >= retries) return res
} catch (e) {
if (attempt >= retries) throw e
console.warn(`[mizan] Fetch attempt ${attempt + 1} failed, retrying:`, e)
}
await new Promise(r => setTimeout(r, (attempt + 1) * 200))
}
@@ -387,11 +368,7 @@ async function resolveHeaders(): Promise<Record<string, string>> {
}
/**
* Default Mizan transport POST `${baseUrl}/call/` and GET
* `${baseUrl}/ctx/${name}/`. Compatible with `mizan-fastapi`,
* `mizan-django`, and `mizan-rust-axum`. Swap with a different
* transport via `configure({ transport })` when running in a
* non-HTTP host (e.g. Tauri).
* Transport over POST `${baseUrl}/call/` and GET `${baseUrl}/ctx/${name}/`.
*/
export function httpTransport(): MizanTransport {
return {
@@ -431,9 +408,6 @@ export function httpTransport(): MizanTransport {
}
}
// Install the default transport now that httpTransport is in scope. The
// config object was constructed earlier with a placeholder so the type
// stayed honest; this line is the actual binding.
config.transport = httpTransport()
export async function mizanFetch(
@@ -449,16 +423,14 @@ export async function mizanCall(
): Promise<any> {
const data = await config.transport.call(functionName, args)
// Server-driven merges run before invalidations so a context that is
// both merged-into and invalidated ends in the invalidation state — the
// server told us to refetch, that wins.
// Merges run before invalidations so a context that is both merged-into
// and invalidated ends in the invalidation state.
if (data.merge) {
for (const entry of data.merge) {
merge(entry.context, entry.params, entry.slot, entry.value)
}
}
// Server-driven invalidation
if (data.invalidate) {
for (const entry of data.invalidate) {
if (typeof entry === 'string') {
@@ -466,9 +438,8 @@ export async function mizanCall(
} else if ('context' in entry) {
invalidate(entry.context, entry.params)
}
// {function: name} entries route through the kernel's
// function-output cache layer, which lives in the framework
// adapter; mizan-base treats them as a no-op here.
// {function: name} entries are handled by the framework adapter's
// function-output cache; no kernel-side state corresponds to them.
}
}