mizan-tauri + Pydantic-aware codegen: Tauri-as-Mizan-backend substrate
Tauri now joins FastAPI/Django/axum as a first-class Mizan backend. The
React frontend calls Mizan-registered functions through Tauri's IPC
with the same {result, invalidate, merge} envelope the HTTP path uses;
the schema flows Pydantic → decoru → Rust → KDL → TS in one
mizan-generate invocation.
New packages:
* backends/mizan-tauri — Tauri plugin exposing a single `mizan_invoke`
command that routes through mizan-core's FUNCTIONS / CONTEXTS
registries. No per-function tauri::command; the linkme slice IS the
dispatch table.
* frontends/mizan-tauri-transport — TS package exporting
tauriTransport() that wraps invoke('plugin:mizan|mizan_invoke', ...)
and re-shapes errors into MizanError. Pairs with mizan-tauri.
@mizan/base — pluggable transport:
* Adds MizanTransport interface + transport config field.
* Existing fetch-based body factored into httpTransport() (default).
* mizanCall/mizanFetch delegate to config.transport; merge/invalidate
side-effects stay in the kernel (transport-agnostic).
* Consumers swap via configure({ transport: tauriTransport() }).
mizan-codegen — Rust source + Pydantic pre-step:
* [source.rust] runs a Cargo bin (cargo run --bin <name>) and parses
KDL from stdout. The bin uses mizan_core::build_ir() after
force-linking the consumer's #[derive(Mizan)] / #[mizan::client]
registrations.
* [source.rust.pydantic] is an optional pre-step that pipes an
embedded Python bridge (scripts/run_decoru.py) to python and writes
decoru-emitted Rust types into the consumer crate. The bridge
auto-discovers BaseModel subclasses AND Enum subclasses
(last-variant-is-default convention so decoru's impl Default keeps
compiling against enum-typed fields without explicit Pydantic
defaults).
* Pure-Rust usage stays intact — omit pydantic block and write Rust
types by hand.
mizan-macros:
* #[mizan::client] now supports Result<T, MizanError> returns. The
dispatch wrapper `?`-unwraps the user fn so server-side errors
surface as the protocol's standard {code, message, details?}
envelope; T-returning functions stay unchanged.
* #[derive(Mizan)] strips the r# raw-identifier prefix and honors
field-level #[serde(rename = "...")] when emitting IR field names.
Matches serde's wire shape — fixes IR-vs-JSON drift for Rust-keyword
fields (e.g. `r#type` → `type`).
react.tsx template:
* Conditionally emits context-related imports / useContextSubscription
helper based on has_global || !named_contexts.is_empty(). Consumers
without contexts (mutation/RPC-only apps like claude-manage) no
longer get dead imports that trip noUnusedLocals.
Verified end-to-end: cargo build clean across mizan-tauri,
mizan-codegen, AFI rust_app; AFI three-way KDL parity tests pass;
claude-manage migration drives the full stack (Pydantic schema →
generated TS api → Tauri-IPC transport → mizan-core dispatch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
4621
backends/mizan-tauri/Cargo.lock
generated
Normal file
4621
backends/mizan-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
backends/mizan-tauri/Cargo.toml
Normal file
12
backends/mizan-tauri/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "mizan-tauri"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Tauri backend adapter for Mizan — typed RPC dispatch over Tauri's IPC. Single `mizan_invoke` command routes through mizan-core's compile-time function registry."
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
mizan-core = { path = "../../cores/mizan-rust" }
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
220
backends/mizan-tauri/src/lib.rs
Normal file
220
backends/mizan-tauri/src/lib.rs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
|
||||||
|
//!
|
||||||
|
//! Ships as a Tauri plugin. The consumer installs it with one line:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! tauri::Builder::default()
|
||||||
|
//! .plugin(mizan_tauri::init())
|
||||||
|
//! .run(tauri::generate_context!())
|
||||||
|
//! .expect("error while running tauri application");
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The plugin exposes a single command `mizan_invoke` (full Tauri name
|
||||||
|
//! `plugin:mizan|mizan_invoke`). The JS-side `@mizan/tauri-transport`
|
||||||
|
//! sends call/fetch envelopes to it; the dispatch routes through
|
||||||
|
//! `mizan-core`'s FUNCTIONS / CONTEXTS registries — the same
|
||||||
|
//! linkme-backed distributed slices the HTTP adapter (mizan-rust-axum)
|
||||||
|
//! consumes. There is no per-function tauri::command; the registry IS
|
||||||
|
//! the dispatch table.
|
||||||
|
//!
|
||||||
|
//! Wire envelope:
|
||||||
|
//!
|
||||||
|
//! ```json
|
||||||
|
//! { "op": "call", "fn": "list_sessions", "args": {} }
|
||||||
|
//! { "op": "fetch", "context": "session", "params": {} }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Response shapes mirror POST /call/ and GET /ctx/.../ from
|
||||||
|
//! mizan-rust-axum:
|
||||||
|
//!
|
||||||
|
//! * `call` → `{ result, invalidate, merge? }`
|
||||||
|
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
|
||||||
|
//!
|
||||||
|
//! Error responses come back as the `Err` variant of the Tauri command's
|
||||||
|
//! `Result`, which Tauri serializes into the JS-side `Promise.reject`.
|
||||||
|
//! The TS-side transport re-wraps it into a `MizanError` so consumers
|
||||||
|
//! see one error surface regardless of transport.
|
||||||
|
|
||||||
|
use mizan_core::{
|
||||||
|
compute_invalidation, compute_merges, lookup_context, lookup_function,
|
||||||
|
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use tauri::{
|
||||||
|
plugin::{Builder, TauriPlugin},
|
||||||
|
Runtime,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())`
|
||||||
|
/// on the `tauri::Builder`. The plugin name is `mizan`; the dispatch
|
||||||
|
/// command is reachable from JS as `plugin:mizan|mizan_invoke`.
|
||||||
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
|
Builder::<R>::new("mizan")
|
||||||
|
.invoke_handler(tauri::generate_handler![mizan_invoke])
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Wire envelope ===
|
||||||
|
|
||||||
|
/// One Mizan request. The JS-side transport sends `{ envelope: ... }`;
|
||||||
|
/// Tauri's serde deserializer pulls this struct out of the `envelope`
|
||||||
|
/// field of the invoke payload.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(tag = "op")]
|
||||||
|
pub enum Envelope {
|
||||||
|
#[serde(rename = "call")]
|
||||||
|
Call {
|
||||||
|
/// Wire-level function name — registered name on the Rust side.
|
||||||
|
#[serde(rename = "fn")]
|
||||||
|
function_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
args: Map<String, Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "fetch")]
|
||||||
|
Fetch {
|
||||||
|
context: String,
|
||||||
|
#[serde(default)]
|
||||||
|
params: Map<String, Value>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error payload returned to the frontend. Mirrors the HTTP adapter's
|
||||||
|
/// `{"code", "message", "details?"}` shape; the TS-side transport reads
|
||||||
|
/// this and constructs a `MizanError`.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ErrorPayload {
|
||||||
|
pub code: &'static str,
|
||||||
|
pub message: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub details: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<MizanError> for ErrorPayload {
|
||||||
|
fn from(e: MizanError) -> Self {
|
||||||
|
let details = if let MizanError::ValidationFailed { details, .. } = &e {
|
||||||
|
Some(details.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
code: e.code(),
|
||||||
|
message: e.message().to_string(),
|
||||||
|
details,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Dispatch ===
|
||||||
|
|
||||||
|
/// The single Mizan dispatch command. Registered on the plugin's invoke
|
||||||
|
/// handler — the consumer never wires it directly.
|
||||||
|
///
|
||||||
|
/// `app: AppHandle` is auto-injected by Tauri; the function body borrows
|
||||||
|
/// it into a `RequestHandle` so `#[mizan::client]` functions can
|
||||||
|
/// `req.downcast::<tauri::AppHandle>()` for app-managed state or event
|
||||||
|
/// emission. Stateless functions ignore the handle.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn mizan_invoke<R: Runtime>(
|
||||||
|
app: tauri::AppHandle<R>,
|
||||||
|
envelope: Envelope,
|
||||||
|
) -> Result<Value, ErrorPayload> {
|
||||||
|
match envelope {
|
||||||
|
Envelope::Call {
|
||||||
|
function_name,
|
||||||
|
args,
|
||||||
|
} => handle_call(&app, &function_name, args).await,
|
||||||
|
Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_call<R: Runtime>(
|
||||||
|
app: &tauri::AppHandle<R>,
|
||||||
|
fn_name: &str,
|
||||||
|
args: Map<String, Value>,
|
||||||
|
) -> Result<Value, ErrorPayload> {
|
||||||
|
let fn_spec = lookup_function(fn_name).ok_or_else(|| {
|
||||||
|
ErrorPayload::from(MizanError::NotFound(format!(
|
||||||
|
"function {fn_name:?} not registered"
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let req = RequestHandle::new(app);
|
||||||
|
let result = fn_spec
|
||||||
|
.dispatch(req, Value::Object(args.clone()))
|
||||||
|
.await
|
||||||
|
.map_err(ErrorPayload::from)?;
|
||||||
|
|
||||||
|
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &args)
|
||||||
|
.iter()
|
||||||
|
.map(InvalidationTarget::to_json)
|
||||||
|
.collect();
|
||||||
|
let merges = compute_merges(fn_spec, &args, &result);
|
||||||
|
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut payload = json!({
|
||||||
|
"result": result,
|
||||||
|
"invalidate": invalidate,
|
||||||
|
});
|
||||||
|
if let Some(merge) = merge_payload {
|
||||||
|
payload
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("payload is a JSON object")
|
||||||
|
.insert("merge".into(), Value::Array(merge));
|
||||||
|
}
|
||||||
|
Ok(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_fetch<R: Runtime>(
|
||||||
|
app: &tauri::AppHandle<R>,
|
||||||
|
context_name: &str,
|
||||||
|
params: Map<String, Value>,
|
||||||
|
) -> Result<Value, ErrorPayload> {
|
||||||
|
if lookup_context(context_name).is_none() {
|
||||||
|
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||||
|
"context {context_name:?} not registered"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|f| f.context() == Some(context_name))
|
||||||
|
.collect();
|
||||||
|
if members.is_empty() {
|
||||||
|
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||||
|
"context {context_name:?} has no registered members"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bundled = Map::new();
|
||||||
|
for fn_spec in &members {
|
||||||
|
let args = filter_args(*fn_spec, ¶ms);
|
||||||
|
let req = RequestHandle::new(app);
|
||||||
|
let result = fn_spec
|
||||||
|
.dispatch(req, Value::Object(args))
|
||||||
|
.await
|
||||||
|
.map_err(ErrorPayload::from)?;
|
||||||
|
bundled.insert(fn_spec.name().to_string(), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Value::Object(bundled))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter the envelope's params down to keys this function declares as
|
||||||
|
/// input. The HTTP/axum adapter coerces string-typed query params to
|
||||||
|
/// JSON primitives in the equivalent step; the Tauri arg channel already
|
||||||
|
/// carries typed JSON, so the filter is sufficient on its own.
|
||||||
|
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> {
|
||||||
|
let mut out = Map::new();
|
||||||
|
for ip in fn_spec.input_params() {
|
||||||
|
if let Some(v) = params.get(ip.name) {
|
||||||
|
out.insert(ip.name.into(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -142,7 +142,14 @@ fn emit_struct(s: &DataStruct) -> TokenStream {
|
|||||||
.ident
|
.ident
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("named field always has an ident");
|
.expect("named field always has an ident");
|
||||||
let name = ident.to_string();
|
// Field-level `#[serde(rename = "...")]` wins; otherwise strip
|
||||||
|
// the raw-identifier prefix that Rust uses to escape keywords
|
||||||
|
// (`r#type` → `type`). Serde itself strips the prefix when
|
||||||
|
// computing the default field name; the IR has to match the
|
||||||
|
// wire form, not the Rust source form.
|
||||||
|
let raw_ident = ident.to_string();
|
||||||
|
let stripped = raw_ident.strip_prefix("r#").unwrap_or(&raw_ident);
|
||||||
|
let name = serde_rename(&field.attrs).unwrap_or_else(|| stripped.to_string());
|
||||||
let shape = type_shape_expr(&field.ty);
|
let shape = type_shape_expr(&field.ty);
|
||||||
|
|
||||||
// A field is `required` iff its type is not `Option<...>`. Defaults
|
// A field is `required` iff its type is not `Option<...>`. Defaults
|
||||||
|
|||||||
@@ -353,7 +353,13 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
|||||||
let output_nullable = analysis.nullable;
|
let output_nullable = analysis.nullable;
|
||||||
let private = args.private;
|
let private = args.private;
|
||||||
|
|
||||||
let dispatch_body = build_dispatch(&item, &input_args, has_input, &input_type_ident);
|
let dispatch_body = build_dispatch(
|
||||||
|
&item,
|
||||||
|
&input_args,
|
||||||
|
has_input,
|
||||||
|
&input_type_ident,
|
||||||
|
analysis.returns_result,
|
||||||
|
);
|
||||||
|
|
||||||
quote! {
|
quote! {
|
||||||
// Keep the user's original fn intact — the macro never rewrites the
|
// Keep the user's original fn intact — the macro never rewrites the
|
||||||
@@ -454,8 +460,18 @@ fn build_dispatch(
|
|||||||
input_args: &[InputArg],
|
input_args: &[InputArg],
|
||||||
has_input: bool,
|
has_input: bool,
|
||||||
input_type_ident: &syn::Ident,
|
input_type_ident: &syn::Ident,
|
||||||
|
returns_result: bool,
|
||||||
) -> TokenStream {
|
) -> TokenStream {
|
||||||
let inner = &item.sig.ident;
|
let inner = &item.sig.ident;
|
||||||
|
// When the user returns `Result<T, MizanError>`, lift Err out into the
|
||||||
|
// dispatch wrapper's outer Result so the HTTP/IPC adapter can surface
|
||||||
|
// it as the standard error envelope. When the user returns `T`,
|
||||||
|
// serialize directly — the substrate has no error path for them.
|
||||||
|
let unwrap_user_result = if returns_result {
|
||||||
|
quote! { ? }
|
||||||
|
} else {
|
||||||
|
TokenStream::new()
|
||||||
|
};
|
||||||
if has_input {
|
if has_input {
|
||||||
let arg_names: Vec<_> = input_args.iter().map(|a| &a.ident).collect();
|
let arg_names: Vec<_> = input_args.iter().map(|a| &a.ident).collect();
|
||||||
quote! {
|
quote! {
|
||||||
@@ -467,7 +483,7 @@ fn build_dispatch(
|
|||||||
let result = #inner(
|
let result = #inner(
|
||||||
&req,
|
&req,
|
||||||
#( validated.#arg_names ),*
|
#( validated.#arg_names ),*
|
||||||
).await;
|
).await #unwrap_user_result;
|
||||||
::mizan_core::__priv::serde_json::to_value(&result)
|
::mizan_core::__priv::serde_json::to_value(&result)
|
||||||
.map_err(|e| ::mizan_core::MizanError::InternalError(
|
.map_err(|e| ::mizan_core::MizanError::InternalError(
|
||||||
format!("output serialization failed: {e}"),
|
format!("output serialization failed: {e}"),
|
||||||
@@ -476,7 +492,7 @@ fn build_dispatch(
|
|||||||
} else {
|
} else {
|
||||||
quote! {
|
quote! {
|
||||||
let _ = args;
|
let _ = args;
|
||||||
let result = #inner(&req).await;
|
let result = #inner(&req).await #unwrap_user_result;
|
||||||
::mizan_core::__priv::serde_json::to_value(&result)
|
::mizan_core::__priv::serde_json::to_value(&result)
|
||||||
.map_err(|e| ::mizan_core::MizanError::InternalError(
|
.map_err(|e| ::mizan_core::MizanError::InternalError(
|
||||||
format!("output serialization failed: {e}"),
|
format!("output serialization failed: {e}"),
|
||||||
|
|||||||
@@ -16,20 +16,32 @@ pub struct ReturnAnalysis {
|
|||||||
pub is_vec: bool,
|
pub is_vec: bool,
|
||||||
/// When `is_vec`, this is the element type `T`.
|
/// When `is_vec`, this is the element type `T`.
|
||||||
pub vec_inner: Option<Type>,
|
pub vec_inner: Option<Type>,
|
||||||
|
/// True when the user's return type is `Result<T, MizanError>` — the
|
||||||
|
/// dispatch wrapper emits `?` so user-side errors bubble out as
|
||||||
|
/// `MizanError` instead of being serialized into the success payload.
|
||||||
|
/// The IR sees only the `T` side; the error variant is the substrate's
|
||||||
|
/// invariant, not part of the output shape.
|
||||||
|
pub returns_result: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn analyze_return(ty: &Type) -> ReturnAnalysis {
|
pub fn analyze_return(ty: &Type) -> ReturnAnalysis {
|
||||||
let (inner, nullable) = if let Some(t) = unwrap_option(ty) {
|
let (effective, returns_result) = if let Some(ok) = unwrap_result_ok(ty) {
|
||||||
(t, true)
|
(ok, true)
|
||||||
} else {
|
} else {
|
||||||
(ty.clone(), false)
|
(ty.clone(), false)
|
||||||
};
|
};
|
||||||
|
let (inner, nullable) = if let Some(t) = unwrap_option(&effective) {
|
||||||
|
(t, true)
|
||||||
|
} else {
|
||||||
|
(effective, false)
|
||||||
|
};
|
||||||
if let Some(elem) = unwrap_vec(&inner) {
|
if let Some(elem) = unwrap_vec(&inner) {
|
||||||
ReturnAnalysis {
|
ReturnAnalysis {
|
||||||
inner: inner.clone(),
|
inner: inner.clone(),
|
||||||
nullable,
|
nullable,
|
||||||
is_vec: true,
|
is_vec: true,
|
||||||
vec_inner: Some(elem),
|
vec_inner: Some(elem),
|
||||||
|
returns_result,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ReturnAnalysis {
|
ReturnAnalysis {
|
||||||
@@ -37,10 +49,27 @@ pub fn analyze_return(ty: &Type) -> ReturnAnalysis {
|
|||||||
nullable,
|
nullable,
|
||||||
is_vec: false,
|
is_vec: false,
|
||||||
vec_inner: None,
|
vec_inner: None,
|
||||||
|
returns_result,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// If `ty` is `Result<T, E>`, return `T`. Otherwise None. The substrate
|
||||||
|
/// only honors `Result<T, MizanError>`; the macro doesn't try to verify
|
||||||
|
/// `E` here — it lets rustc raise the type-mismatch at the `?` site if
|
||||||
|
/// the consumer used a non-MizanError variant.
|
||||||
|
pub fn unwrap_result_ok(ty: &Type) -> Option<Type> {
|
||||||
|
let path = match ty {
|
||||||
|
Type::Path(TypePath { qself: None, path }) => path,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let last = path.segments.last()?;
|
||||||
|
if last.ident != "Result" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
extract_single_generic(&last.arguments)
|
||||||
|
}
|
||||||
|
|
||||||
/// Emit a `TypeShape` const-expression for `ty`. Used inside `#[derive(Mizan)]`
|
/// Emit a `TypeShape` const-expression for `ty`. Used inside `#[derive(Mizan)]`
|
||||||
/// when constructing the struct field shapes.
|
/// when constructing the struct field shapes.
|
||||||
pub fn type_shape_expr(ty: &Type) -> TokenStream {
|
pub fn type_shape_expr(ty: &Type) -> TokenStream {
|
||||||
|
|||||||
@@ -30,6 +30,39 @@ 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 })`.
|
||||||
|
*/
|
||||||
|
export interface MizanTransport {
|
||||||
|
/** RPC dispatch — invokes a Mizan-registered function. */
|
||||||
|
call(
|
||||||
|
fnName: string,
|
||||||
|
args: Record<string, any>,
|
||||||
|
): Promise<MizanCallResponse>
|
||||||
|
/** Context-bundle fetch — invokes a Mizan-registered context. */
|
||||||
|
fetch(
|
||||||
|
contextName: string,
|
||||||
|
params?: Record<string, any>,
|
||||||
|
): Promise<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export interface MizanCallResponse {
|
||||||
|
result: any
|
||||||
|
invalidate?: Array<string | { context: string; params?: Record<string, any> } | { function: string }>
|
||||||
|
merge?: Array<{ context: string; slot: string; value: unknown; params?: Record<string, any> }>
|
||||||
|
}
|
||||||
|
|
||||||
// === Configuration ===
|
// === Configuration ===
|
||||||
|
|
||||||
interface MizanConfig {
|
interface MizanConfig {
|
||||||
@@ -45,6 +78,13 @@ interface MizanConfig {
|
|||||||
* this onto the schema-advertised capability surface.
|
* this onto the schema-advertised capability surface.
|
||||||
*/
|
*/
|
||||||
session: boolean
|
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.
|
||||||
|
*/
|
||||||
|
transport: MizanTransport
|
||||||
}
|
}
|
||||||
|
|
||||||
const config: MizanConfig = {
|
const config: MizanConfig = {
|
||||||
@@ -53,6 +93,8 @@ const config: MizanConfig = {
|
|||||||
csrfCookieName: 'csrftoken',
|
csrfCookieName: 'csrftoken',
|
||||||
csrfHeaderName: 'X-CSRFToken',
|
csrfHeaderName: 'X-CSRFToken',
|
||||||
session: true,
|
session: true,
|
||||||
|
// Initialized below once httpTransport is defined.
|
||||||
|
transport: null as unknown as MizanTransport,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configure(opts: Partial<MizanConfig>): void {
|
export function configure(opts: Partial<MizanConfig>): void {
|
||||||
@@ -344,42 +386,68 @@ 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).
|
||||||
|
*/
|
||||||
|
export function httpTransport(): MizanTransport {
|
||||||
|
return {
|
||||||
|
async call(functionName, args) {
|
||||||
|
const headers = await resolveHeaders()
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
|
||||||
|
const res = await fetch(`${config.baseUrl}/call/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ fn: functionName, args }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new MizanError(res.status, await res.text())
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
async fetch(contextName, params) {
|
||||||
|
const url = new URL(
|
||||||
|
`${config.baseUrl}/ctx/${contextName}/`,
|
||||||
|
typeof globalThis.location !== 'undefined'
|
||||||
|
? globalThis.location.origin
|
||||||
|
: 'http://localhost',
|
||||||
|
)
|
||||||
|
if (params) {
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
url.searchParams.set(k, String(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const headers = await resolveHeaders()
|
||||||
|
const res = await fetchWithRetry(url.toString(), {
|
||||||
|
headers,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new MizanError(res.status, await res.text())
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(
|
export async function mizanFetch(
|
||||||
contextName: string,
|
contextName: string,
|
||||||
params?: Record<string, any>,
|
params?: Record<string, any>,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const url = new URL(
|
return config.transport.fetch(contextName, params)
|
||||||
`${config.baseUrl}/ctx/${contextName}/`,
|
|
||||||
typeof globalThis.location !== 'undefined' ? globalThis.location.origin : 'http://localhost',
|
|
||||||
)
|
|
||||||
if (params) {
|
|
||||||
for (const [k, v] of Object.entries(params)) {
|
|
||||||
url.searchParams.set(k, String(v))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = await resolveHeaders()
|
|
||||||
const res = await fetchWithRetry(url.toString(), { headers, credentials: 'same-origin' })
|
|
||||||
if (!res.ok) throw new MizanError(res.status, await res.text())
|
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mizanCall(
|
export async function mizanCall(
|
||||||
functionName: string,
|
functionName: string,
|
||||||
args: Record<string, any>,
|
args: Record<string, any>,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const headers = await resolveHeaders()
|
const data = await config.transport.call(functionName, args)
|
||||||
headers['Content-Type'] = 'application/json'
|
|
||||||
|
|
||||||
const res = await fetch(`${config.baseUrl}/call/`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
credentials: 'same-origin',
|
|
||||||
body: JSON.stringify({ fn: functionName, args }),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new MizanError(res.status, await res.text())
|
|
||||||
|
|
||||||
const data = await res.json()
|
|
||||||
|
|
||||||
// Server-driven merges run before invalidations so a context that is
|
// Server-driven merges run before invalidations so a context that is
|
||||||
// both merged-into and invalidated ends in the invalidation state — the
|
// both merged-into and invalidated ends in the invalidation state — the
|
||||||
@@ -395,9 +463,12 @@ export async function mizanCall(
|
|||||||
for (const entry of data.invalidate) {
|
for (const entry of data.invalidate) {
|
||||||
if (typeof entry === 'string') {
|
if (typeof entry === 'string') {
|
||||||
invalidate(entry)
|
invalidate(entry)
|
||||||
} else {
|
} else if ('context' in entry) {
|
||||||
invalidate(entry.context, entry.params)
|
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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
frontends/mizan-tauri-transport/package.json
Normal file
15
frontends/mizan-tauri-transport/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@mizan/tauri-transport",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Mizan transport adapter routing calls through Tauri's IPC instead of HTTP. Paired with the `mizan-tauri` Rust plugin.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@mizan/base": "*",
|
||||||
|
"@tauri-apps/api": "^2"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
81
frontends/mizan-tauri-transport/src/index.ts
Normal file
81
frontends/mizan-tauri-transport/src/index.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* @mizan/tauri-transport — routes Mizan calls through Tauri's IPC
|
||||||
|
* instead of HTTP fetch. Paired with the `mizan-tauri` Rust plugin
|
||||||
|
* (which exposes a single `mizan_invoke` command).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
*
|
||||||
|
* import { configure } from '@mizan/base'
|
||||||
|
* import { tauriTransport } from '@mizan/tauri-transport'
|
||||||
|
*
|
||||||
|
* configure({ transport: tauriTransport() })
|
||||||
|
*
|
||||||
|
* The transport keeps the same protocol surface as the HTTP transport
|
||||||
|
* (call/fetch envelopes, {result, invalidate, merge} response shape),
|
||||||
|
* so the codegen output and React adapter are unchanged — only the
|
||||||
|
* wire channel differs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { MizanError, type MizanCallResponse, type MizanTransport } from '@mizan/base'
|
||||||
|
|
||||||
|
/** Plugin-prefixed Tauri command — `init()` on the Rust side installs
|
||||||
|
* the plugin under the name `mizan`, so the dispatch command is
|
||||||
|
* reachable from JS as `plugin:mizan|mizan_invoke`. */
|
||||||
|
const COMMAND = 'plugin:mizan|mizan_invoke'
|
||||||
|
|
||||||
|
type TauriError = {
|
||||||
|
code?: string
|
||||||
|
message?: string
|
||||||
|
details?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a `mizan-tauri` error payload into a `MizanError` so consumers
|
||||||
|
* see one error surface regardless of transport. Tauri's invoke()
|
||||||
|
* rejects with the raw `Err` payload from the Rust command; we re-shape
|
||||||
|
* it to the same `{error: {code, message, details}}` envelope the HTTP
|
||||||
|
* transport surfaces.
|
||||||
|
*/
|
||||||
|
function wrapError(raw: unknown): MizanError {
|
||||||
|
if (raw && typeof raw === 'object') {
|
||||||
|
const err = raw as TauriError
|
||||||
|
const body = JSON.stringify({ error: { code: err.code, message: err.message, details: err.details } })
|
||||||
|
// 0 means "no HTTP status" — the IPC transport bypassed the
|
||||||
|
// protocol's HTTP layer. The error envelope still carries
|
||||||
|
// .code/.message/.details so consumers don't have to special-case.
|
||||||
|
return new MizanError(0, body)
|
||||||
|
}
|
||||||
|
return new MizanError(0, JSON.stringify({ error: { message: String(raw) } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Mizan transport that routes through Tauri's IPC. Install via:
|
||||||
|
*
|
||||||
|
* import { configure } from '@mizan/base'
|
||||||
|
* import { tauriTransport } from '@mizan/tauri-transport'
|
||||||
|
* configure({ transport: tauriTransport() })
|
||||||
|
*/
|
||||||
|
export function tauriTransport(): MizanTransport {
|
||||||
|
return {
|
||||||
|
async call(fnName, args): Promise<MizanCallResponse> {
|
||||||
|
try {
|
||||||
|
const data = await invoke<MizanCallResponse>(COMMAND, {
|
||||||
|
envelope: { op: 'call', fn: fnName, args },
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
} catch (e) {
|
||||||
|
throw wrapError(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async fetch(contextName, params) {
|
||||||
|
try {
|
||||||
|
return await invoke(COMMAND, {
|
||||||
|
envelope: { op: 'fetch', context: contextName, params: params ?? {} },
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
throw wrapError(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
136
protocol/mizan-codegen/scripts/run_decoru.py
Normal file
136
protocol/mizan-codegen/scripts/run_decoru.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pydantic → Rust codegen helper invoked by mizan-codegen's
|
||||||
|
`[source.rust.pydantic]` step.
|
||||||
|
|
||||||
|
Reads a JSON payload from argv[1] with keys:
|
||||||
|
- module: Python module to import (e.g. "claude_manage.schema")
|
||||||
|
- output: Path to write the generated Rust file
|
||||||
|
- derives: List of derive identifiers to apply to every emitted item
|
||||||
|
- header: Optional file prefix (e.g. an AUTO-GENERATED warning)
|
||||||
|
|
||||||
|
Discovers every BaseModel subclass declared in the module (handled by
|
||||||
|
decoru) AND every Enum subclass declared there (handled inline — decoru
|
||||||
|
itself is scoped to BaseModel). Writes one Rust file containing both.
|
||||||
|
|
||||||
|
Bundled with the mizan-codegen binary (include_str!) and piped to
|
||||||
|
`python -` at codegen time — no install step beyond decoru being
|
||||||
|
importable in the python environment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import BaseModel # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
from decoru import emit_rust_struct, walk_pydantic_model # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
|
||||||
|
def _declared_in(module, obj) -> bool:
|
||||||
|
return getattr(obj, "__module__", None) == module.__name__
|
||||||
|
|
||||||
|
|
||||||
|
def discover_models(module) -> list[type[BaseModel]]:
|
||||||
|
"""BaseModel subclasses declared in this module. Imported helpers are
|
||||||
|
skipped — only own-module declarations qualify."""
|
||||||
|
return [
|
||||||
|
obj
|
||||||
|
for _, obj in inspect.getmembers(module, inspect.isclass)
|
||||||
|
if issubclass(obj, BaseModel)
|
||||||
|
and obj is not BaseModel
|
||||||
|
and _declared_in(module, obj)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def discover_enums(module) -> list[type[Enum]]:
|
||||||
|
"""Enum subclasses declared in this module. Filters out the
|
||||||
|
framework's own Enum class and anything imported from elsewhere."""
|
||||||
|
return [
|
||||||
|
obj
|
||||||
|
for _, obj in inspect.getmembers(module, inspect.isclass)
|
||||||
|
if issubclass(obj, Enum) and obj is not Enum and _declared_in(module, obj)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _pascal_case(s: str) -> str:
|
||||||
|
return "".join(part.capitalize() for part in s.split("_") if part)
|
||||||
|
|
||||||
|
|
||||||
|
# Last-variant-is-default matches the catch-all idiom (e.g. `Metadata`
|
||||||
|
# in `claude_manage.schema.EntryType`). Decoru's `emit_rust_struct`
|
||||||
|
# emits `impl Default` unconditionally on every BaseModel, so any
|
||||||
|
# enum-typed field that lacks a Pydantic default must still satisfy
|
||||||
|
# `EntryType::default()`. Forcing #[default] on the last member keeps
|
||||||
|
# the generated structs compilable without per-enum config.
|
||||||
|
_ENUM_TEMPLATE = textwrap.dedent("""\
|
||||||
|
#[derive({derives})]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum {name} {{
|
||||||
|
{variants}
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_variant(member: Enum, *, is_default: bool) -> str:
|
||||||
|
pascal = _pascal_case(member.name)
|
||||||
|
default_attr = " #[default]\n" if is_default else ""
|
||||||
|
return f"{default_attr} {pascal},"
|
||||||
|
|
||||||
|
|
||||||
|
def emit_rust_enum(enum_class: type[Enum], derives: tuple[str, ...]) -> str:
|
||||||
|
"""Render a Rust enum with PascalCase variants from Python member
|
||||||
|
names. Pairs `#[serde(rename_all = "snake_case")]` so the wire form
|
||||||
|
matches each member's `value`. Adds `Default` to the derives and
|
||||||
|
marks the last member `#[default]` — see `_ENUM_TEMPLATE` for the
|
||||||
|
rationale."""
|
||||||
|
name = enum_class.__name__
|
||||||
|
members = list(enum_class)
|
||||||
|
full_derives = ", ".join((*derives, "Default"))
|
||||||
|
variants = "\n".join(
|
||||||
|
_render_variant(m, is_default=(i == len(members) - 1))
|
||||||
|
for i, m in enumerate(members)
|
||||||
|
)
|
||||||
|
return _ENUM_TEMPLATE.format(derives=full_derives, name=name, variants=variants)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
sys.stderr.write("run_decoru.py: missing JSON payload argument\n")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
payload = json.loads(sys.argv[1])
|
||||||
|
module_name: str = payload["module"]
|
||||||
|
output_path = Path(payload["output"]).resolve()
|
||||||
|
derives = tuple(payload.get("derives", ()))
|
||||||
|
header = payload.get("header") or ""
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path.cwd()))
|
||||||
|
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
enums = discover_enums(module)
|
||||||
|
models = discover_models(module)
|
||||||
|
if not enums and not models:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"run_decoru.py: no Enum or BaseModel subclasses declared in {module_name!r}\n"
|
||||||
|
)
|
||||||
|
return 3
|
||||||
|
|
||||||
|
enum_blocks = [emit_rust_enum(e, derives) for e in enums]
|
||||||
|
struct_blocks = [emit_rust_struct(walk_pydantic_model(m), derives=derives) for m in models]
|
||||||
|
body = "\n".join((*enum_blocks, *struct_blocks))
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path.write_text(header + body)
|
||||||
|
|
||||||
|
sys.stderr.write(
|
||||||
|
f"run_decoru.py: wrote {len(enums)} enum(s) + {len(models)} struct(s) to {output_path}\n"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -62,6 +62,13 @@ pub struct SourceConfig {
|
|||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub django: Option<DjangoSource>,
|
pub django: Option<DjangoSource>,
|
||||||
|
|
||||||
|
/// Canonical "Pydantic + Rust" DX path. The Rust crate is the IR
|
||||||
|
/// authority; an optional `pydantic` sub-block invokes decoru as a
|
||||||
|
/// pre-step to author Rust types from Pydantic models. Pure-Rust
|
||||||
|
/// usage (no Pydantic) just omits the sub-block.
|
||||||
|
#[serde(default)]
|
||||||
|
pub rust: Option<RustSource>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -98,6 +105,107 @@ pub struct DjangoSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// `[source.rust]` — spawn a Cargo binary that emits the Mizan IR (KDL)
|
||||||
|
/// to stdout. The binary uses `mizan_core::build_ir()` after force-linking
|
||||||
|
/// the consumer crate's `#[derive(Mizan)]` types and `#[mizan::client]`
|
||||||
|
/// functions.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RustSource {
|
||||||
|
/// Path to the consumer's Cargo.toml, relative to the codegen config
|
||||||
|
/// directory. Defaults to `Cargo.toml` (i.e. config_dir/Cargo.toml).
|
||||||
|
#[serde(default)]
|
||||||
|
pub manifest_path: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Name of the binary under `[[bin]]` that exports the IR. Defaults
|
||||||
|
/// to `emit-mizan-ir` — the convention this substrate documents.
|
||||||
|
#[serde(default = "default_rust_bin")]
|
||||||
|
pub bin: String,
|
||||||
|
|
||||||
|
/// Cargo features to enable when building the bin.
|
||||||
|
#[serde(default)]
|
||||||
|
pub features: Vec<String>,
|
||||||
|
|
||||||
|
/// Build in release mode. Defaults to false (dev mode is faster for
|
||||||
|
/// codegen, and the binary is throwaway).
|
||||||
|
#[serde(default)]
|
||||||
|
pub release: bool,
|
||||||
|
|
||||||
|
/// Environment overrides for the cargo subprocess.
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: BTreeMap<String, String>,
|
||||||
|
|
||||||
|
/// Optional pre-step — invoke decoru on a Pydantic source module
|
||||||
|
/// before running the Cargo bin. When present, the pipeline becomes:
|
||||||
|
/// 1. python + decoru → write Rust types to `pydantic.output`
|
||||||
|
/// 2. cargo run --bin <bin> → emit IR to stdout
|
||||||
|
/// Omit for pure-Rust usage (hand-authored or otherwise-generated
|
||||||
|
/// Rust types with `#[derive(Mizan)]`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub pydantic: Option<PydanticPreStep>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn default_rust_bin() -> String {
|
||||||
|
"emit-mizan-ir".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Pydantic → Rust pre-step. Runs an embedded Python helper that walks
|
||||||
|
/// the named module for `BaseModel` subclasses and invokes decoru's
|
||||||
|
/// `walk_pydantic_model` + `emit_rust_struct` to produce a Rust file.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PydanticPreStep {
|
||||||
|
/// Python module name to import (e.g. `claude_manage.schema`).
|
||||||
|
pub module: String,
|
||||||
|
|
||||||
|
/// Path to write the generated Rust file, relative to the codegen
|
||||||
|
/// config directory.
|
||||||
|
pub output: PathBuf,
|
||||||
|
|
||||||
|
/// Working directory for the python subprocess, relative to the
|
||||||
|
/// codegen config directory. Defaults to the config directory itself.
|
||||||
|
/// The script prepends this to `sys.path` so the module imports.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cwd: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Python executable. Defaults to `python`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub python: Option<String>,
|
||||||
|
|
||||||
|
/// Full command override (e.g. `["uv", "run", "python"]`). Wins over
|
||||||
|
/// `python` when present.
|
||||||
|
#[serde(default)]
|
||||||
|
pub command: Option<Vec<String>>,
|
||||||
|
|
||||||
|
/// Derive macros to apply to every generated struct. The default
|
||||||
|
/// matches the Mizan-canonical set used in `cores/rust/blazr/session`
|
||||||
|
/// — serde + mizan_core::Mizan for end-to-end RPC participation.
|
||||||
|
#[serde(default = "default_pydantic_derives")]
|
||||||
|
pub derives: Vec<String>,
|
||||||
|
|
||||||
|
/// Optional prelude inserted at the top of the generated file
|
||||||
|
/// (typically a "// AUTO-GENERATED" warning + `use` statements for
|
||||||
|
/// referenced types not produced by decoru itself).
|
||||||
|
#[serde(default)]
|
||||||
|
pub header: Option<String>,
|
||||||
|
|
||||||
|
/// Environment overrides for the python subprocess.
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn default_pydantic_derives() -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"Debug".to_string(),
|
||||||
|
"Clone".to_string(),
|
||||||
|
"::serde::Serialize".to_string(),
|
||||||
|
"::serde::Deserialize".to_string(),
|
||||||
|
"::mizan_core::Mizan".to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum RustKernelSpec {
|
pub enum RustKernelSpec {
|
||||||
|
|||||||
@@ -4,24 +4,48 @@
|
|||||||
//! Backends:
|
//! Backends:
|
||||||
//! - FastAPI: `python -m mizan_fastapi.ir <module>`
|
//! - FastAPI: `python -m mizan_fastapi.ir <module>`
|
||||||
//! - Django: `python manage.py export_mizan_ir`
|
//! - Django: `python manage.py export_mizan_ir`
|
||||||
|
//! - Rust: `cargo run --bin <bin>` (consumer-side binary that
|
||||||
|
//! force-links its `#[derive(Mizan)]` types and
|
||||||
|
//! `#[mizan::client]` functions, then calls
|
||||||
|
//! `mizan_core::build_ir()`).
|
||||||
|
//!
|
||||||
|
//! The Rust source supports an optional `[source.rust.pydantic]`
|
||||||
|
//! pre-step that invokes decoru on a Pydantic module to author the
|
||||||
|
//! Rust types before the cargo bin runs — the "Pydantic + Rust"
|
||||||
|
//! canonical DX.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::config::{Config, DjangoSource, FastapiSource};
|
use crate::config::{Config, DjangoSource, FastapiSource, PydanticPreStep, RustSource};
|
||||||
use crate::ir::{parse_ir, MizanIR};
|
use crate::ir::{parse_ir, MizanIR};
|
||||||
|
|
||||||
|
|
||||||
|
/// Embedded decoru bridge script — piped to `python -` at codegen time
|
||||||
|
/// when `[source.rust.pydantic]` is set. The script imports decoru,
|
||||||
|
/// walks the named module's BaseModel subclasses, and writes a Rust
|
||||||
|
/// file. See `scripts/run_decoru.py` for the full body.
|
||||||
|
const DECORU_BRIDGE_SCRIPT: &str = include_str!("../scripts/run_decoru.py");
|
||||||
|
|
||||||
|
|
||||||
pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result<MizanIR> {
|
pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result<MizanIR> {
|
||||||
let raw = if let Some(fa) = &config.source.fastapi {
|
let raw = if let Some(rs) = &config.source.rust {
|
||||||
|
if let Some(py) = &rs.pydantic {
|
||||||
|
run_pydantic_prestep(py, config_dir)
|
||||||
|
.context("running [source.rust.pydantic] pre-step")?;
|
||||||
|
}
|
||||||
|
run_rust(rs, config_dir)?
|
||||||
|
} else if let Some(fa) = &config.source.fastapi {
|
||||||
run_fastapi(fa, config_dir)?
|
run_fastapi(fa, config_dir)?
|
||||||
} else if let Some(dj) = &config.source.django {
|
} else if let Some(dj) = &config.source.django {
|
||||||
run_django(dj, config_dir)?
|
run_django(dj, config_dir)?
|
||||||
} else {
|
} else {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"config.source must declare either [source.fastapi] or [source.django]"
|
"config.source must declare one of [source.rust], [source.fastapi], or [source.django]"
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,6 +135,96 @@ fn run_subprocess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn run_rust(src: &RustSource, config_dir: &Path) -> Result<String> {
|
||||||
|
let manifest = config_dir.join(
|
||||||
|
src.manifest_path
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("Cargo.toml")),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut args: Vec<String> = vec![
|
||||||
|
"run".to_string(),
|
||||||
|
"--quiet".to_string(),
|
||||||
|
"--manifest-path".to_string(),
|
||||||
|
manifest.to_string_lossy().into_owned(),
|
||||||
|
"--bin".to_string(),
|
||||||
|
src.bin.clone(),
|
||||||
|
];
|
||||||
|
if src.release {
|
||||||
|
args.push("--release".to_string());
|
||||||
|
}
|
||||||
|
if !src.features.is_empty() {
|
||||||
|
args.push("--features".to_string());
|
||||||
|
args.push(src.features.join(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
run_subprocess("cargo", &args, config_dir, &src.env, "Rust IR export")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn run_pydantic_prestep(src: &PydanticPreStep, config_dir: &Path) -> Result<()> {
|
||||||
|
let cwd = match &src.cwd {
|
||||||
|
Some(rel) => config_dir.join(rel),
|
||||||
|
None => config_dir.to_path_buf(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let output_abs = if src.output.is_absolute() {
|
||||||
|
src.output.clone()
|
||||||
|
} else {
|
||||||
|
config_dir.join(&src.output)
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"module": &src.module,
|
||||||
|
"output": output_abs.to_string_lossy(),
|
||||||
|
"derives": &src.derives,
|
||||||
|
"header": &src.header,
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let (program, mut args) = resolve_command(&src.command, &src.python);
|
||||||
|
// `python -` reads the script body from stdin; the JSON payload is
|
||||||
|
// passed as argv[1] (which lands on sys.argv[1] inside the script).
|
||||||
|
args.push("-".to_string());
|
||||||
|
args.push(payload);
|
||||||
|
|
||||||
|
let mut cmd = Command::new(&program);
|
||||||
|
cmd.args(&args).current_dir(&cwd);
|
||||||
|
for (k, v) in &src.env {
|
||||||
|
cmd.env(k, v);
|
||||||
|
}
|
||||||
|
cmd.stdin(Stdio::piped());
|
||||||
|
cmd.stdout(Stdio::inherit());
|
||||||
|
cmd.stderr(Stdio::inherit());
|
||||||
|
|
||||||
|
let mut child = cmd
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("spawning decoru bridge ({program})"))?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let stdin = child
|
||||||
|
.stdin
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| anyhow!("failed to acquire decoru bridge stdin"))?;
|
||||||
|
stdin
|
||||||
|
.write_all(DECORU_BRIDGE_SCRIPT.as_bytes())
|
||||||
|
.context("piping decoru bridge script to python")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = child
|
||||||
|
.wait()
|
||||||
|
.context("waiting for decoru bridge to complete")?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"[source.rust.pydantic]: decoru bridge exited with status {:?}",
|
||||||
|
status.code()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Library helper: parse a KDL IR from a string.
|
/// Library helper: parse a KDL IR from a string.
|
||||||
pub fn parse_ir_from_str(source: &str) -> Result<MizanIR> {
|
pub fn parse_ir_from_str(source: &str) -> Result<MizanIR> {
|
||||||
parse_ir(source)
|
parse_ir(source)
|
||||||
|
|||||||
@@ -2,30 +2,38 @@
|
|||||||
|
|
||||||
// AUTO-GENERATED by mizan — do not edit
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
{% set has_contexts = has_global || !named_contexts.is_empty() -%}
|
||||||
import {
|
import {
|
||||||
|
{% if has_contexts -%}
|
||||||
createContext,
|
createContext,
|
||||||
|
{% endif -%}
|
||||||
useCallback,
|
useCallback,
|
||||||
|
{% if has_contexts -%}
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
{% endif -%}
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
{% if has_contexts -%}
|
||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
|
{% endif -%}
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import {
|
import {
|
||||||
configure,
|
configure,
|
||||||
initSession,
|
|
||||||
mizanCall,
|
mizanCall,
|
||||||
mizanFetch,
|
mizanFetch,
|
||||||
MizanError,
|
{% if has_contexts -%}
|
||||||
registerContext,
|
registerContext,
|
||||||
type ContextState,
|
type ContextState,
|
||||||
|
{% endif -%}
|
||||||
} from '@mizan/base'
|
} from '@mizan/base'
|
||||||
|
|
||||||
{% if !stage1_imports.is_empty() -%}
|
{% if !stage1_imports.is_empty() -%}
|
||||||
import { {{ stage1_imports|join(", ") }} } from './index'
|
import { {{ stage1_imports|join(", ") }} } from './index'
|
||||||
|
|
||||||
{% endif -%}
|
{% endif -%}
|
||||||
|
{% if has_contexts -%}
|
||||||
// Internal — runs inside a Provider, registers with the kernel exactly once.
|
// Internal — runs inside a Provider, registers with the kernel exactly once.
|
||||||
function useContextSubscription<T>(
|
function useContextSubscription<T>(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -46,6 +54,7 @@ function useContextSubscription<T>(
|
|||||||
|
|
||||||
return useSyncExternalStore(handle.subscribe, handle.getState, handle.getState)
|
return useSyncExternalStore(handle.subscribe, handle.getState, handle.getState)
|
||||||
}
|
}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
// Internal — wraps an imperative call() with isPending / error state.
|
// Internal — wraps an imperative call() with isPending / error state.
|
||||||
interface MutationHook<TArgs, TResult> {
|
interface MutationHook<TArgs, TResult> {
|
||||||
|
|||||||
Reference in New Issue
Block a user