Roll the working tree back to the last approved shape, before the post-LICENSE span that false-greened the AFI parity matrix with symbol-presence probes and smuggled an unauthorized SQLAlchemy dependency into FastAPI's Shapes binding.
Forward commit, not a history rewrite — the six commits since 4effcc7 stay in the log as the record of what happened.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
221 lines
7.0 KiB
Rust
221 lines
7.0 KiB
Rust
//! 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
|
|
}
|