mizan-axum + macros: state threading, array/map lowering, merge shape semantics

Three substrate extensions surfaced by the Blazr session port:

1. **App-state threading.** mizan-axum::router() is now generic over a
   user-supplied state type and threads `Arc<dyn Any + Send + Sync>` into
   every dispatch via RequestHandle. Handlers downcast to their concrete
   AppState. The stateless AFI fixture uses `router_stateless()` (matches
   the prior signature). RequestHandle gains a `from_dyn()` constructor
   to wrap already-erased trait-object references.

2. **`[T; N]` and `BTreeMap<K, V>` lowering in #[derive(Mizan)].** Fixed
   arrays emit as `List<T>` (matches Python `tuple[float,...]` → JSON
   array). String-keyed maps emit as `List<V>` — closest approximation
   until KDL grows a `dict` shape. Also: vec-element registrations get a
   per-function scope suffix so two handlers returning `Vec<Same>` don't
   collide at the static-name layer.

3. **`types_match` for merge: upsert-into-list semantics.** Now matches
   Python `types_match_for_merge`: direct (T == T), upsert (slot is
   `Alias(List(T))`, value is T), and list-replace (both sides list).
   The AFI fixture only exercised the direct path; the Blazr port's
   `morph_set_value` returning a single `MorphLayer` into a context with
   `Vec<MorphLayer>` slot is what surfaced the gap.

AFI codegen + wire parity stays 12/12 green after these substrate changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 23:40:33 -04:00
parent 45bde51166
commit a1d1d6928f
7 changed files with 156 additions and 21 deletions

View File

@@ -16,9 +16,29 @@ pub(crate) fn resolve_type_shape(name: &str) -> Option<NamedType> {
None
}
/// Structural equality on named types. Two types are merge-compatible iff
/// they have identical shape — matches Python's `types_match_for_merge`.
pub(crate) fn types_match(a: &NamedType, b: &NamedType) -> bool {
/// Merge-compatibility on named types. A mutation return `value` can
/// splice into a context slot `slot` when any of three shapes hold —
/// matches Python's `types_match_for_merge`:
/// * direct: `slot` shape equals `value` shape → replace
/// * upsert: `slot` is `list[T]`, `value` is `T` → upsert by id
/// * list-replace: `slot` is `list[T]`, `value` is `list[T]`
///
/// The first argument is the slot (context member's output type); the
/// second is the value (mutation's output type).
pub(crate) fn types_match(slot: &NamedType, value: &NamedType) -> bool {
if named_shapes_equal(slot, value) {
return true;
}
// Upsert: slot is `Alias(List(T))`, value is `T`-shaped.
if let NamedType::Alias(TypeShape::List(elem)) = slot {
if shape_matches_named(elem, value) {
return true;
}
}
false
}
fn named_shapes_equal(a: &NamedType, b: &NamedType) -> bool {
match (a, b) {
(NamedType::Struct(fa), NamedType::Struct(fb)) => fields_match(fa, fb),
(NamedType::Alias(sa), NamedType::Alias(sb)) => shapes_match(sa, sb),
@@ -27,6 +47,21 @@ pub(crate) fn types_match(a: &NamedType, b: &NamedType) -> bool {
}
}
/// True when a `TypeShape` (the slot's list-element) describes the same
/// shape as a `NamedType` (the mutation's full output).
fn shape_matches_named(shape: &TypeShape, named: &NamedType) -> bool {
match shape {
TypeShape::Ref(name) => {
if let Some(referenced) = resolve_type_shape(name) {
named_shapes_equal(&referenced, named)
} else {
false
}
}
_ => false,
}
}
fn fields_match(a: &[StructField], b: &[StructField]) -> bool {
if a.len() != b.len() {
return false;

View File

@@ -17,10 +17,18 @@ pub struct RequestHandle<'a> {
}
impl<'a> RequestHandle<'a> {
/// Wrap a typed reference. The most common path — handlers downcast back
/// to `T` via `downcast::<T>()`.
pub fn new<T: Any + Send + Sync>(req: &'a T) -> Self {
Self { inner: req }
}
/// Wrap an already-erased `dyn Any` reference. Used by HTTP adapters
/// that thread an `Arc<dyn Any + Send + Sync>` app state in.
pub fn from_dyn(req: &'a (dyn Any + Send + Sync)) -> Self {
Self { inner: req }
}
pub fn downcast<T: Any + Send + Sync>(&self) -> Option<&'a T> {
self.inner.downcast_ref::<T>()
}