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:
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user