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
|
||||
.as_ref()
|
||||
.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);
|
||||
|
||||
// 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 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! {
|
||||
// Keep the user's original fn intact — the macro never rewrites the
|
||||
@@ -454,8 +460,18 @@ fn build_dispatch(
|
||||
input_args: &[InputArg],
|
||||
has_input: bool,
|
||||
input_type_ident: &syn::Ident,
|
||||
returns_result: bool,
|
||||
) -> TokenStream {
|
||||
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 {
|
||||
let arg_names: Vec<_> = input_args.iter().map(|a| &a.ident).collect();
|
||||
quote! {
|
||||
@@ -467,7 +483,7 @@ fn build_dispatch(
|
||||
let result = #inner(
|
||||
&req,
|
||||
#( validated.#arg_names ),*
|
||||
).await;
|
||||
).await #unwrap_user_result;
|
||||
::mizan_core::__priv::serde_json::to_value(&result)
|
||||
.map_err(|e| ::mizan_core::MizanError::InternalError(
|
||||
format!("output serialization failed: {e}"),
|
||||
@@ -476,7 +492,7 @@ fn build_dispatch(
|
||||
} else {
|
||||
quote! {
|
||||
let _ = args;
|
||||
let result = #inner(&req).await;
|
||||
let result = #inner(&req).await #unwrap_user_result;
|
||||
::mizan_core::__priv::serde_json::to_value(&result)
|
||||
.map_err(|e| ::mizan_core::MizanError::InternalError(
|
||||
format!("output serialization failed: {e}"),
|
||||
|
||||
@@ -16,20 +16,32 @@ pub struct ReturnAnalysis {
|
||||
pub is_vec: bool,
|
||||
/// When `is_vec`, this is the element type `T`.
|
||||
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 {
|
||||
let (inner, nullable) = if let Some(t) = unwrap_option(ty) {
|
||||
(t, true)
|
||||
let (effective, returns_result) = if let Some(ok) = unwrap_result_ok(ty) {
|
||||
(ok, true)
|
||||
} else {
|
||||
(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) {
|
||||
ReturnAnalysis {
|
||||
inner: inner.clone(),
|
||||
nullable,
|
||||
is_vec: true,
|
||||
vec_inner: Some(elem),
|
||||
returns_result,
|
||||
}
|
||||
} else {
|
||||
ReturnAnalysis {
|
||||
@@ -37,10 +49,27 @@ pub fn analyze_return(ty: &Type) -> ReturnAnalysis {
|
||||
nullable,
|
||||
is_vec: false,
|
||||
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)]`
|
||||
/// when constructing the struct field shapes.
|
||||
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 ===
|
||||
|
||||
interface MizanConfig {
|
||||
@@ -45,6 +78,13 @@ interface MizanConfig {
|
||||
* this onto the schema-advertised capability surface.
|
||||
*/
|
||||
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 = {
|
||||
@@ -53,6 +93,8 @@ const config: MizanConfig = {
|
||||
csrfCookieName: 'csrftoken',
|
||||
csrfHeaderName: 'X-CSRFToken',
|
||||
session: true,
|
||||
// Initialized below once httpTransport is defined.
|
||||
transport: null as unknown as MizanTransport,
|
||||
}
|
||||
|
||||
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(
|
||||
contextName: string,
|
||||
params?: Record<string, any>,
|
||||
): Promise<any> {
|
||||
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()
|
||||
return config.transport.fetch(contextName, params)
|
||||
}
|
||||
|
||||
export async function mizanCall(
|
||||
functionName: string,
|
||||
args: Record<string, any>,
|
||||
): Promise<any> {
|
||||
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())
|
||||
|
||||
const data = await res.json()
|
||||
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
|
||||
@@ -395,9 +463,12 @@ export async function mizanCall(
|
||||
for (const entry of data.invalidate) {
|
||||
if (typeof entry === 'string') {
|
||||
invalidate(entry)
|
||||
} else {
|
||||
} 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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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)]
|
||||
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)]
|
||||
#[serde(untagged)]
|
||||
pub enum RustKernelSpec {
|
||||
|
||||
@@ -4,24 +4,48 @@
|
||||
//! Backends:
|
||||
//! - FastAPI: `python -m mizan_fastapi.ir <module>`
|
||||
//! - 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::process::Command;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
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};
|
||||
|
||||
|
||||
/// 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> {
|
||||
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)?
|
||||
} else if let Some(dj) = &config.source.django {
|
||||
run_django(dj, config_dir)?
|
||||
} else {
|
||||
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.
|
||||
pub fn parse_ir_from_str(source: &str) -> Result<MizanIR> {
|
||||
parse_ir(source)
|
||||
|
||||
@@ -2,30 +2,38 @@
|
||||
|
||||
// AUTO-GENERATED by mizan — do not edit
|
||||
|
||||
{% set has_contexts = has_global || !named_contexts.is_empty() -%}
|
||||
import {
|
||||
{% if has_contexts -%}
|
||||
createContext,
|
||||
{% endif -%}
|
||||
useCallback,
|
||||
{% if has_contexts -%}
|
||||
useContext,
|
||||
useEffect,
|
||||
{% endif -%}
|
||||
useRef,
|
||||
useState,
|
||||
{% if has_contexts -%}
|
||||
useSyncExternalStore,
|
||||
{% endif -%}
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
configure,
|
||||
initSession,
|
||||
mizanCall,
|
||||
mizanFetch,
|
||||
MizanError,
|
||||
{% if has_contexts -%}
|
||||
registerContext,
|
||||
type ContextState,
|
||||
{% endif -%}
|
||||
} from '@mizan/base'
|
||||
|
||||
{% if !stage1_imports.is_empty() -%}
|
||||
import { {{ stage1_imports|join(", ") }} } from './index'
|
||||
|
||||
{% endif -%}
|
||||
{% if has_contexts -%}
|
||||
// Internal — runs inside a Provider, registers with the kernel exactly once.
|
||||
function useContextSubscription<T>(
|
||||
name: string,
|
||||
@@ -46,6 +54,7 @@ function useContextSubscription<T>(
|
||||
|
||||
return useSyncExternalStore(handle.subscribe, handle.getState, handle.getState)
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
// Internal — wraps an imperative call() with isPending / error state.
|
||||
interface MutationHook<TArgs, TResult> {
|
||||
|
||||
Reference in New Issue
Block a user