Mizan codegen substrate: Rust kernel + Rust codegen binary, JS generator deleted

The Mizan codegen substrate moves off JavaScript template-literal emission
onto a compiled Rust binary that consumes the same OpenAPI + x-mizan-* IR
the JS substrate consumed. Three structural wins fall out of one move:

1. Moat closes. The codegen logic (how `affects` becomes auto-invalidation,
   how named contexts collapse onto bundled fetches, how the registry-to-
   Provider mapping is shaped) ships compiled instead of as source bytes
   in every consumer's node_modules.

2. Pattern F (lines.push append-walls) becomes structurally unauthorable.
   The emit substrate is askama templates in templates/<target>/*.j2 —
   actual target-language files with {{ ... }} substitution markers,
   syntax-highlighted natively, type-checked against the render context
   structs at compile time. The Rust emit modules build typed render
   contexts and call .render(); no string-builder surface exists.

3. OpenAPI `default`-bearing fields now emit as non-optional in TS / Python
   / Rust — the server always populates them, so consumer code reads them
   without nullable checks. Surfaced by Blazr's typecheck on regeneration.

Layout:
  frontends/mizan-rust/        — Rust port of @mizan/base; #[cfg(feature="pyo3")]
                                 exposes PyMizanClient for the Python target.
  protocol/mizan-codegen/      — codegen binary source + askama templates.
  protocol/mizan-generate/     — npm-package shim. bin/launcher.mjs dispatches
                                 to the platform-appropriate prebuilt binary.
                                 Old generator/ JS tree deleted.
  tests/rust/                  — wire-parity drivers. drive_kernel exercises
                                 raw client.call() / fetch_context(); drive_emitted
                                 exercises the typed crate the codegen emits.
  tests/afi/afi_codegen_app.py — codegen entrypoint module (imports + registers).
  backends/mizan-fastapi/.../schema.py — adds outputNullable so the Rust
                                 codegen can wrap T | None responses in Option<T>.

Verification:
  - 20 mizan-codegen tests green (IR deserialization, byte-equivalent
    parity vs JS baseline for stage1/rust/python/react/vue/svelte,
    structural test for channels).
  - tests/rust/run_wire_parity.py — 12/12 probes green via the Rust binary
    driving the FastAPI fixture end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 18:26:32 -04:00
parent c15c6f3e14
commit 43bcf3f26f
114 changed files with 11090 additions and 2342 deletions

View File

@@ -0,0 +1,195 @@
//! `MizanClient` — the kernel entry point.
//!
//! Mirrors the `configure(opts)` + module-level state in
//! `frontends/mizan-base/src/index.ts`, but as an owned struct because
//! Rust lacks module-level mutable state. Consumers hold an
//! `Arc<MizanClient>` and pass it everywhere the TS code would have
//! used the module-level `config`.
//!
//! Public surface:
//! - `MizanClient::new(config)` — build with reqwest cookie jar.
//! - `client.fetch_context(name, params)` — async, returns parsed JSON bundle.
//! - `client.call(fn_name, args)` — async, applies merge + invalidation
//! from the response then returns `result`.
//! - `client.register_context(name, params, fetch_fn)` — register an
//! instance; returns a `ContextHandle`.
//! - `client.invalidate(name)` / `client.invalidate_scoped(name, params)`
//! — schedule invalidation via the kernel queue.
//! - `client.merge(context, params, slot, value)` — splice a value into
//! a context bundle slot.
use std::sync::Arc;
use std::time::Duration;
use reqwest::cookie::CookieStore;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT};
use reqwest::Url;
use serde_json::Value;
use tokio::sync::OnceCell;
use crate::context::{ContextHandle, ContextRegistry, FetchFn};
use crate::error::MizanError;
use crate::invalidation::InvalidationQueue;
use crate::transport;
pub struct MizanConfig {
pub base_url: String,
pub session: bool,
pub csrf_cookie_name: String,
pub csrf_header_name: String,
pub extra_headers: Vec<(String, String)>,
}
impl Default for MizanConfig {
fn default() -> Self {
Self {
base_url: "/api/mizan".to_string(),
session: true,
csrf_cookie_name: "csrftoken".to_string(),
csrf_header_name: "X-CSRFToken".to_string(),
extra_headers: Vec::new(),
}
}
}
pub struct MizanClient {
config: Arc<MizanConfig>,
http: reqwest::Client,
cookie_jar: Arc<reqwest::cookie::Jar>,
registry: Arc<ContextRegistry>,
queue: Arc<InvalidationQueue>,
session_ready: OnceCell<()>,
}
impl MizanClient {
pub fn new(config: MizanConfig) -> Arc<Self> {
let cookie_jar = Arc::new(reqwest::cookie::Jar::default());
let http = reqwest::Client::builder()
.cookie_provider(Arc::clone(&cookie_jar))
.build()
.expect("reqwest client construction");
let registry = Arc::new(ContextRegistry::new());
let queue = InvalidationQueue::new(Arc::clone(&registry));
Arc::new(Self {
config: Arc::new(config),
http,
cookie_jar,
registry,
queue,
session_ready: OnceCell::new(),
})
}
pub fn config(&self) -> &MizanConfig {
&self.config
}
pub fn http(&self) -> &reqwest::Client {
&self.http
}
pub fn context_registry(&self) -> &Arc<ContextRegistry> {
&self.registry
}
pub fn invalidation_queue(&self) -> &Arc<InvalidationQueue> {
&self.queue
}
/// Hit `/session/` once on first call to bootstrap the CSRF cookie.
/// No-op when `config.session == false`. Three attempts with 100ms
/// × attempt backoff.
pub async fn ensure_session_ready(&self) -> Result<(), MizanError> {
if !self.config.session {
return Ok(());
}
self.session_ready
.get_or_try_init(|| async {
if self.read_csrf_cookie().is_some() {
return Ok(());
}
let url = Url::parse(&format!("{}/session/", self.config.base_url.trim_end_matches('/')))
.map_err(|e| MizanError::transport(format!("invalid base_url: {e}")))?;
for attempt in 0..3 {
let res = self.http.get(url.clone()).send().await;
if res.is_ok() && self.read_csrf_cookie().is_some() {
return Ok(());
}
if attempt < 2 {
tokio::time::sleep(Duration::from_millis(100 * (attempt as u64 + 1))).await;
}
}
// Mirror TS: failing to bootstrap is non-fatal — subsequent
// calls proceed without CSRF and may still succeed (e.g.,
// FastAPI configs that don't require it).
Ok(())
})
.await
.copied()
}
pub(crate) async fn resolve_headers(&self) -> HeaderMap {
let mut headers = HeaderMap::new();
for (name, value) in &self.config.extra_headers {
if let (Ok(n), Ok(v)) = (HeaderName::try_from(name.as_str()), HeaderValue::try_from(value.as_str())) {
headers.insert(n, v);
}
}
if let Some(token) = self.read_csrf_cookie() {
if let (Ok(n), Ok(v)) = (
HeaderName::try_from(self.config.csrf_header_name.as_str()),
HeaderValue::try_from(token.as_str()),
) {
headers.insert(n, v);
}
}
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
headers
}
fn read_csrf_cookie(&self) -> Option<String> {
let url = Url::parse(&self.config.base_url).ok()?;
let header = self.cookie_jar.cookies(&url)?;
let raw = header.to_str().ok()?;
let needle = format!("{}=", self.config.csrf_cookie_name);
raw.split(';')
.map(|p| p.trim())
.find_map(|p| p.strip_prefix(&needle))
.map(|v| v.trim_matches('"').to_string())
}
// ── High-level API ─────────────────────────────────────────────────
pub async fn fetch_context(&self, context: &str, params: &Value) -> Result<Value, MizanError> {
transport::mizan_fetch(self, context, params).await
}
pub async fn call(&self, fn_name: &str, args: Value) -> Result<Value, MizanError> {
transport::mizan_call(self, fn_name, args).await
}
pub async fn register_context(
self: &Arc<Self>,
name: impl Into<String>,
params: Value,
fetch_fn: FetchFn,
) -> ContextHandle {
self.registry.register(name, params, fetch_fn, None).await
}
pub async fn invalidate(self: &Arc<Self>, name: impl Into<String>) {
self.queue.invalidate(name).await;
}
pub async fn invalidate_scoped(self: &Arc<Self>, name: impl Into<String>, params: Value) {
self.queue.invalidate_scoped(name, params).await;
}
pub async fn merge(&self, context: &str, params: Option<&Value>, slot: &str, value: &Value) {
self.registry.merge(context, params, slot, value).await;
}
}