Compare commits

...

20 Commits

Author SHA1 Message Date
587be8c4ab SSR migrated to Rust 2026-06-04 21:37:24 -04:00
ae684a36cb Restore approved state (tree of 4effcc7 "Added LICENSE")
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>
2026-06-04 14:59:53 -04:00
adcc027894 Deleted hallucination docs 2026-06-04 14:28:30 -04:00
6c5f6f1fba AFI parity: close all 35 gaps — every adapter wires every AFI-common capability
The conformance board (tests/afi/test_capability_parity.py) is now fully green:
90 capability cells + 4 meta-locks + 3 codegen byte-parity = 97 passed. The
gaps the prose table used to launder as "Django-only" / "out of scope" are
wired, against the pinned-spec model (single-authored spec, byte-identical
conformance across languages) — never per-language reimplementation.

FastAPI — edge_manifest + PSR (logic single-sourced in mizan_core.manifest),
WebSocket RPC (/ws/ through the shared dispatch), SSR (the framework-agnostic
SSRBridge relocated to mizan_core.ssr; Django rides it from there), Shapes
(SQLAlchemy projection, same declaration surface as django-readers), Forms
(Pydantic schema/validate/submit).

Rust (Axum + Tauri + cores/mizan-rust) — X-Mizan-Invalidate header, auth=
enforcement, origin HMAC cache, edge manifest + PSR, WebSocket handler / IPC
subscription channel, multipart upload, SSR bridge, Shapes, Forms; JWT/MWT
mint+verify and cache-key derivation byte-pinned to the Python reference
(cache_keys_pin, token_pin, invalidate_header_pin).

TypeScript — a KDL IR emitter byte-identical to the Python build_ir (so a TS
backend can feed the codegen — the largest gap), multipart upload, session-init,
WebSocket transport, SSR bridge, JWT/MWT mint (pinned to Python), Shapes, Forms.

Verified in the merged tree: core 25, fastapi 74, django 353/21-skip,
mizan-rust (incl. cross-language pins) green, axum 10, tauri 8, mizan-ts 103/2-skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:44:35 -04:00
58d2cb2848 AFI parity: generate the matrix from conformance probes, not prose
The per-adapter parity table was hand-maintained prose. An adapter that
never wired a capability (FastAPI SSR, Axum WebSocket) got its gap
relabelled "Django-only" or "out of scope — use native equivalents," and
nothing went red. The de-scope was crystallized in five mutually-ratifying
sites: the README §Stack-extensions table, the AFI fixture docstring
("channels/forms/shapes aren't AFI-common"), the core registry's
extension-hook framing, the mizan-fastapi __init__ docstring, and a
"CSRF is Django-only" comment in two adapters' session endpoints.

Replace prose-parity with conformance-generated parity:

- tests/afi/manifest.py declares the AFI-common surface as data — one list
  of capabilities, one of adapters. Applicability ("—") is derived from
  transport, never typed.
- tests/afi/probes.py independently inspects each backend's source for the
  artifact a capability requires (comment-stripped, backend-scoped). Green
  means wired; a cell can't be set by editing a word.
- tests/afi/test_capability_parity.py asserts every (capability × applicable
  adapter) pair is wired. 35 unwired gaps are now loud red TFDD tests, each
  naming an owed binding. No xfail/skip.
- tests/afi/parity_table.py generates the README table from the probes;
  `make parity-check` fails CI on any hand-edit, like the codegen byte-parity.

Purge the five de-scope sites. The IR byte-parity gate is unchanged and green.
`make test-afi` is now intentionally red on the 35 gaps — that board is the
owed parity work, itemized; a gap turns green by being wired, never described.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:58:03 -04:00
b41f469bbd Corrected agent confabulations 2026-06-04 12:05:13 -04:00
66b2db81fb FastAPI and TypeScript improved 2026-06-04 05:14:29 -04:00
67ad91b673 Added file upload support 2026-06-04 04:20:05 -04:00
4effcc7597 Added LICENSE 2026-06-04 03:38:26 -04:00
776e0cf27a Added parity table. 2026-06-04 02:52:08 -04:00
ffdf9aa24d Cleaned dead code and updated documents 2026-06-04 02:42:13 -04:00
578e124d67 Latest states 2026-06-04 01:15:41 -04:00
a5ef93b879 mizan-webview-transport + webview-channels: VSCode webview as Mizan frontend
Two new frontend packages let a VSCode webview consume a Mizan backend
through its postMessage channel — peer transports to `mizan-tauri-transport`
and the default `httpTransport()`.

- `@mizan/webview-transport` implements `MizanTransport` (call/fetch)
  over postMessage with correlation-id pairing. Drop-in for `configure({
  transport: webviewTransport() })`; codegen output and React adapter
  are unchanged.

- `@mizan/webview-channels` mirrors mizan-react's WebSocket-based
  ChannelConnection — RPC + subscribe over the same postMessage channel
  for long-running ops where short request/reply isn't enough.

Both expect an extension-host-side dispatcher that reads envelopes via
`webview.onDidReceiveMessage` and routes them through mizan-ts's
`handleMutationCall` / `handleContextFetch`. First consumer is the
holomorphic VSCode extension.

mizan-codegen: new `[source.script]` generic source. Spawns an arbitrary
command and reads stdout as KDL IR. Keeps mizan-codegen out of the
business of knowing every possible backend language while preserving
the "subprocess emits KDL" contract every other source already follows.
Holomorphic uses it to invoke `python -m holomorphic.emit_ir` against
the mizan_core registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:51:12 -04:00
22dcf0e3c1 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>
2026-05-19 19:01:45 -04:00
54f060c273 mizan: IR inline-substitution + reachability tree-shake + serde rename_all
Three substrate moves required by the Blazr-session port that surfaced
real cross-backend divergences:

1. Inline-substitution for primitive aliases and string enums. Named
   types whose body is `Alias(Primitive(_))` or `Enum(_)` collapse into
   their inline TypeShape at every `Ref` use site, and don't emit as
   their own `type "X" { ... }` entry. Matches Python's Pydantic Literal
   and `Foo = str` alias inlining — codegen consumers see the primitive
   directly rather than chasing a single-hop indirection.

2. Reachability tree-shake on the type registry. `#[derive(Mizan)]` now
   auto-registers every Mizan type into the TYPES slice; the emitter
   then transitively walks Refs from function inputs/outputs and emits
   only the reachable subset. Original-named entries from derive
   register only when something refs them; canonical-renamed entries
   from the function macro are reachable by definition. Mirrors
   Python's `_collect_named_types`.

3. `#[serde(rename_all = "...")]` + `#[serde(rename = "...")]`
   propagation in `#[derive(Mizan)]` for enums. IR enum variants now
   match the on-wire JSON casing (lowercase / snake_case / kebab-case /
   etc.), not the Rust variant idents. Supports all serde casings.

AFI codegen + wire parity stays green after these changes (the AFI
fixture's enum-free + Pydantic-shape types are unchanged by the three
substrate extensions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:01:28 -04:00
a1d1d6928f 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>
2026-05-17 23:40:33 -04:00
45bde51166 Mizan-Rust backend adapter: server-side substrate + three-way parity
Adds first-class Rust-backed Mizan to sit alongside mizan-django and
mizan-fastapi. A Rust dev writes:

    #[derive(Mizan, Serialize, Deserialize)]
    pub struct ProfileOutput { pub user_id: i64, pub name: String }

    #[mizan::context("user")]
    pub struct UserCtx;

    #[mizan::client(context = UserCtx)]
    pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput { ... }

…and gets byte-identical KDL to the Python emitters, served over the
same wire protocol the React / Rust / Vue / Svelte kernels speak.

New crates:
- cores/mizan-rust/         (Cargo: mizan-core)     — IR types, KDL emitter, traits, registry,
                                                       runtime (compute_invalidation / compute_merges
                                                       ported from mizan-fastapi), graph_check with
                                                       structural type-matching
- cores/mizan-rust-macros/  (Cargo: mizan-macros)   — #[derive(Mizan)], #[mizan::context],
                                                       #[mizan::client] proc macros
- backends/mizan-rust-axum/ (Cargo: mizan-axum)     — axum HTTP adapter: /session/, /call/, /ctx/:name/
- tests/afi/rust_app/                                — AFI fixture port + server / export-ir binaries

Substrate-shape moves required by cross-language equivalence:
- IR canonicalization: functions / contexts / context-members / shared-by
  now sort alphabetically in both Python and Rust emitters. The IR is a
  contract; linkme doesn't preserve declaration order, so canonical sort
  is the only stable mapping. afi_ir.kdl + per-target baselines regenerated.
- MizanType::TYPE_NAME is a const (with a default type_name() reader) so
  it's usable in linkme TypeEntry static initializers.
- Tree-shaken type registry: #[derive(Mizan)] only emits the trait impl;
  the #[mizan::client] macro registers canonical-named entries from
  fn signatures, including Vec<T> element types for ref resolution.
- Merge resolution is structural (NamedType shape comparison) rather than
  by name — matches the Python types_match_for_merge semantics.

Three-way forcing functions:
- tests/afi/test_codegen_parity.py — Django ≡ FastAPI ≡ Rust on KDL bytes (3 pass)
- tests/rust/run_wire_parity.py    — 12/12 probes against FastAPI + Rust (EXIT=0)

Incidental fixes surfaced by the new tests:
- Stale `from .registry import validate_registry` import removed from
  mizan-django/setup/discovery.py (referenced a function that no longer
  exists; was masking codegen-parity).
- BASE_DIR added to tests/afi/django_app/project/settings.py.
- /session/ endpoint added to mizan-fastapi for protocol-shaped readiness
  probe parity (wire-parity harness now polls /api/mizan/session/ on both
  backends rather than FastAPI's /openapi.json).
- Root .gitignore picks up Rust target/ across the tree so new crates
  don't need per-crate gitignore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:31:26 -04:00
9900f8a36f Mizan IR: cut over to KDL, delete OpenAPI envelope
Replaces the transitional OpenAPI 3.0 + `x-mizan-*` extensions
substrate with the canonical Mizan IR as KDL, per docs/AFI_ARCHITECTURE.md:
"KDL is the contract; everything else (REST envelopes, OpenAPI
documents, framework idioms) is sediment around it."

End-to-end cutover. No transitional path left on main.

Forward direction:
  cores/mizan-python/src/mizan_core/ir.py
    build_ir() walks mizan_core.registry, introspects Pydantic
    models directly (no JSON-Schema indirection), and emits the
    Mizan IR document. The KDL grammar is locked in this file's
    module docstring.

Backends emit KDL:
  backends/mizan-fastapi/src/mizan_fastapi/ir.py
    `python -m mizan_fastapi.ir <module>` — CLI entry point.
  backends/mizan-django/.../management/commands/export_mizan_ir.py
    `manage.py export_mizan_ir` — Django mgmt command.

Codegen consumes KDL:
  protocol/mizan-codegen/Cargo.toml: + kdl = "6"
  protocol/mizan-codegen/src/ir.rs: NamedType { Struct/List/Enum/Alias }
    + TypeShape { Primitive/Ref/List/Optional/Enum/Union } sum types,
    replacing the JsonSchema sprawl. KDL parser walks the
    `kdl::KdlDocument` tree into typed Rust structs.
  protocol/mizan-codegen/src/fetch.rs: subprocess command switches
    to the new IR-export entry points.
  All emit modules (stage1 / react / python / rust / vue / svelte /
    channels) port their type-walkers from JsonSchema to the new
    sum types — case analysis collapses substantially.

Substrate-honesty wins beyond the moat closure:
  - `int | bool` multi-arm unions land as `TypeShape::Union` (was
    silently coerced to "string" before).
  - `<CamelName>Output = list[T]` returns emit as named alias
    types instead of struct-shaped wrappers, so consumer code
    `.map()` works directly on the type.
  - Pydantic field defaults flow through to `default` properties
    in KDL, then back to non-optional shape in every target.

Deleted:
  - backends/mizan-fastapi/src/mizan_fastapi/{cli,schema}.py
  - backends/mizan-django/.../export_mizan_schema.py
  - openapi-bearing half of mizan/export/__init__.py (edge
    manifest generator preserved — separate concern).
  - tests/afi/schema_normalizer.py
  - tests/fixtures/{afi_schema.json, channels_schema.json}
  - tests/fixtures/js_* baseline directories.

Verification:
  - 20 mizan-codegen unit tests green (IR deserialization,
    byte-equivalence parity across stage1/rust/python/react/vue/svelte
    against fresh KDL-driven baselines, channels structural).
  - tests/rust/run_wire_parity.py: 12/12 probes green driving
    the binary end-to-end through KDL.
  - Blazr studio-ui typechecks against the regenerated React client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:14:47 -04:00
7fb0c4a400 Mutation→context merge primitive across the stack
The @client(merge=[context, ...]) decorator lets a mutation patch its
return value directly into the cached context bundle by matching the
mutation's Output type against each context-function's Output type
to identify the slot, then splicing server-side. Kernel runs
splice_slot on the response to apply locally — no refetch, no
invalidate-cascade.

Lands H14, H15, H16, M19, M20 from ISSUES.md.

Backends (Django + FastAPI):
  _resolve_merges() in both executors walks @client(merge=...) targets,
  resolves the per-context slot via types_match_for_merge, and emits
  {context, slot, value, params?} entries on the response. Param
  auto-scoping mirrors _resolve_invalidation's tier-1 logic.

Frontend kernel (mizan-base):
  Response handler reads the merge[] array and applies splice_slot
  for each entry — locates the cached context bundle by name+params,
  overwrites the named slot with the new value, notifies subscribers.

Core (mizan-python):
  @client decorator extended with merge= parameter. Schema export
  threads merge metadata onto the OpenAPI x-mizan-functions entries.

Examples / fixtures:
  fastapi-react-site harness exercises merge + Playwright spec covers
  the end-to-end happy path (mutation → instant UI update without
  network refetch). AFI fixture's rename_user function is the
  canonical merge target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:29:06 -04:00
43bcf3f26f Mizan codegen substrate: Rust kernel + Rust codegen binary, JS generator deleted
The Mizan codegen substrate moves off JavaScript template-literal emission
onto a compiled Rust binary that consumes the same OpenAPI + x-mizan-* IR
the JS substrate consumed. Three structural wins fall out of one move:

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:26:32 -04:00
253 changed files with 28553 additions and 4184 deletions

4
.gitignore vendored
View File

@@ -11,6 +11,10 @@ node_modules/
dist/
package-lock.json
# Rust — every crate's build dir, anywhere in the tree
target/
**/target/
# Playwright
/test-results/
/playwright-report/

467
CLAUDE.md
View File

@@ -1,467 +0,0 @@
# Mizan — Technical Reference
## What Mizan Is
Mizan is an Application Framework Interface (AFI). One decorator on a server function. Typed client generated. Invalidation automatic. Caching protocol-driven. SSR via subprocess.
Django + React ships first. The protocol is language-agnostic (proven by mizan-ts).
---
## Package Layout
Tree organized by role. Per-framework adapters wrap a single shared kernel; codegen targets the adapter.
```
backends/ server protocol adapters
mizan-django/ Django adapter
mizan-fastapi/ FastAPI adapter (RPC + context + invalidation; AFI-common scope)
mizan-ts/ TypeScript adapter (proves the protocol is language-agnostic)
frontends/ client kernel + per-framework adapters
mizan-base/ framework-agnostic kernel; owns data, status, error; adapters subscribe
mizan-react/ React contexts + hooks over the kernel
mizan-vue/ Vue composables over the kernel
mizan-svelte/ Svelte stores/runes over the kernel
cores/ shared language-level primitives
mizan-python/ @client decorator, registry, MWT, HMAC cache keys; consumed by both Python backends
protocol/ protocol-level tooling
mizan-generate/ codegen — fetches schema from any backend, emits typed React/Vue/Svelte client
workers/ runtime workers / bridges
mizan-ssr/ Bun subprocess used by the Django template backend
```
---
## The Three Protocols
### 1. RPC Protocol (Anti-REST)
No resources. No CRUD. Functions in, results out.
**Context fetch (reads):**
```
GET /api/mizan/ctx/<context_name>/?param1=val1&param2=val2
200 OK
Cache-Control: no-store
Content-Type: application/json
{
"user_profile": {"name": "Ryth", "email": "ryth@example.com"},
"user_orders": [{"id": 1, "total": 100}]
}
```
All functions sharing a context name are bundled into one response. Keys are function names. Values are return values.
**Mutation call (writes):**
```
POST /api/mizan/call/
Content-Type: application/json
{"fn": "update_profile", "args": {"user_id": 5, "name": "Ryth"}}
200 OK
Cache-Control: no-store
X-Mizan-Invalidate: user;user_id=5
{
"result": {"ok": true},
"invalidate": [{"context": "user", "params": {"user_id": 5}}]
}
```
### 2. Invalidation-on-Mutation Protocol
Two transports for the same signal. Both are first-class.
**Transport 1 — JSON body** (for RPC/SPA clients):
```json
{"result": {...}, "invalidate": ["user"]}
{"result": {...}, "invalidate": [{"context": "user", "params": {"user_id": 5}}]}
```
**Transport 2 — HTTP header** (for Edge, htmx, view-path functions):
```
X-Mizan-Invalidate: user
X-Mizan-Invalidate: user;user_id=5
X-Mizan-Invalidate: user;user_id=5, notifications
```
Format: comma-separated contexts, semicolon-separated URL-encoded params per context.
**Three-tier auto-scoping** (no developer annotation needed):
1. **Argument name matching:** mutation has `user_id` param, context has `user_id` param → scoped automatically
2. **Auth inference:** Edge-side concern (reads JWT/MWT to extract user identity)
3. **Broad fallback:** invalidate all instances of the context
**Return-type branching** determines which transport:
- Function returns data (dict, BaseModel) → RPC path → JSON body + header
- Function returns HttpResponse (redirect, HTML) → View path → header only
### 3. Frontend-Agnostic Rendering (SSR + PSR)
**SSR** — Django template backend integration. `render(request, 'ProfilePage', props)` calls a persistent Bun subprocess that runs `renderToString`.
**PSR** (Preemptive Static Rendering) — pages re-rendered on mutation, not on request. Edge caches the result. Controlled by the manifest's `render_strategy` field.
**The Bun worker protocol** — JSON-RPC over stdin/stdout:
```
→ {"id": 1, "method": "render", "params": {"component": "ProfilePage", "props": {"userId": 5}}}
← {"id": 1, "html": "<div>...</div>"}
```
Worker stays alive across requests. Django's `SSRBridge` manages the subprocess lifecycle with thread-safe request correlation via message IDs.
---
## The @client Decorator — Full API
```python
from mizan import client, ReactContext, GlobalContext
UserContext = ReactContext('user')
```
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `context` | `ReactContext \| str \| False` | `False` | Named context for grouping. `False` = standalone function. |
| `affects` | `ReactContext \| str \| list` | `None` | What this mutation invalidates. Mutually exclusive with `context`. |
| `private` | `bool` | `False` | Not client-callable. No RPC endpoint. No codegen. Still in invalidation graph. |
| `route` | `str \| None` | `None` | Mizan-owned URL pattern for view-path functions. |
| `methods` | `list[str] \| None` | `None` | HTTP methods for route. Default: `['GET']` for context, `['POST']` for mutation. |
| `auth` | `bool \| str \| callable \| None` | `None` | Auth requirement: `True`, `'staff'`, `'superuser'`, or `callable(request) -> bool`. |
| `websocket` | `bool` | `False` | Enable WebSocket RPC transport. |
| `rev` | `int` | `0` | Cache revision. Increment to bust cached entries on deploy. |
| `cache` | `int \| False` | (default) | Cache TTL hint. `False` = never cache. Integer = TTL seconds. |
### Usage Patterns
```python
# Global context — auto-mounted at root, SSR-hydrated
@client(context=GlobalContext)
def current_user(request) -> UserShape:
return UserShape.query(lambda qs: qs.filter(pk=request.user.pk))[0]
# Named context — bundled GET, generates typed hooks
@client(context=UserContext)
def user_profile(request, user_id: int) -> UserShape:
return UserShape.query(lambda qs: qs.filter(pk=user_id))[0]
@client(context=UserContext)
def user_orders(request, user_id: int) -> list[OrderShape]:
return OrderShape.query(lambda qs: qs.filter(user_id=user_id))
# Mutation — auto-scoped invalidation (user_id matches)
@client(affects=UserContext)
def update_profile(request, user_id: int, name: str) -> dict:
request.user.name = name
request.user.save()
return {"ok": True}
# Function-level affects — only user_profile refetches
@client(affects='user_profile')
def update_name(request, user_id: int, name: str) -> dict:
...
# View-path context — registered in invalidation graph, no codegen
@client(context=UserContext, route='/profile/<user_id>/')
def profile_page(request, user_id: int) -> HttpResponse:
return render(request, 'profile.html', {...})
# View-path mutation — invalidation via header on the redirect
@client(affects=UserContext, route='/profile/<user_id>/update/', methods=['POST'])
def update_profile_view(request, user_id: int) -> HttpResponse:
form = ProfileForm(request.POST)
if form.is_valid():
form.save()
return redirect(f'/profile/{user_id}/')
# Private webhook — not client-callable, emits invalidation
@client(affects='subscription', private=True, route='/webhooks/stripe/', methods=['POST'])
def stripe_webhook(request) -> HttpResponse:
event = json.loads(request.body)
process_stripe_event(event)
return HttpResponse(status=200)
# Auth guards
@client(auth=True)
def secret(request) -> dict: ...
@client(auth='staff')
def admin_action(request) -> dict: ...
@client(auth=lambda req: req.user.email.endswith('@company.com'))
def internal_tool(request) -> dict: ...
```
### _meta Dict Structure
After decoration, the function class has `_meta` with these possible keys:
```python
{
"context": "user", # context name string (if context=)
"affects": [ # normalized affects targets (if affects=)
{"type": "context", "name": "user"},
{"type": "function", "name": "user_profile", "context": "user"},
],
"private": True, # if private=True
"route": "/webhooks/stripe/", # if route=
"methods": ["POST"], # if route= (defaults applied)
"view_path": True, # if return type is HttpResponse
"websocket": True, # if websocket=True
"auth": "required", # "required" | "staff" | "superuser" | callable
"rev": 3, # if rev=
"cache": 60, # if cache=
"form": True, # if form function
"form_name": "contact", # form name
"form_role": "schema", # "schema" | "validate" | "submit"
}
```
---
## Cache System
### Required Settings
```python
# settings.py
MIZAN_CACHE_SECRET = "your-32-byte-hmac-signing-key" # Required for cache
MIZAN_CACHE_REDIS_URL = "redis://localhost:6379/0" # Required for cache
```
Both must be set. If either is missing, caching is disabled with a warning.
### HMAC Key Derivation
Cache keys are derived from HMAC-SHA256 over a JSON-canonical form:
```python
derive_cache_key(secret, context, params, user_id=None, rev=0) -> str
```
**Canonical form** (the HMAC message):
```json
{"c":"user","p":{"user_id":"5"},"r":0}
```
With optional `"u":"5"` for user-scoped entries.
- `c` = context name
- `p` = sorted params dict (all values stringified)
- `r` = revision number
- `u` = user ID (for auth-scoped cache entries)
**Key format:** `ctx:{context}:{hmac_hex}`
- Example: `ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6`
**Cross-language conformance:** The TypeScript adapter (`mizan-ts/src/cache/keys.ts`) produces identical keys for identical inputs. Pin tests verify this.
### Cache Operations
```python
from mizan.cache import cache_get, cache_put, cache_purge
# Store
cache_put(secret, backend, "user", {"user_id": "5"}, b'{"name":"Ryth"}')
# Retrieve
data = cache_get(secret, backend, "user", {"user_id": "5"})
# Scoped purge (recomputes HMAC, deletes one key)
cache_purge(backend, "user", params={"user_id": "5"}, secret=secret)
# Broad purge (SCAN by prefix "ctx:user:*")
cache_purge(backend, "user")
```
### Backends
**MemoryCache** — dict-based, for testing. No persistence.
**RedisCache** — production backend.
- Connection pooling (50 max connections)
- 24h default TTL safety net
- Key prefix: `mizan:` (configurable)
- `delete_by_prefix` uses Redis SCAN (1000 keys per batch)
- `delete` uses UNLINK (non-blocking)
### Cache Integration in Dispatch
`context_fetch_view` checks origin-side cache before executing functions. On cache miss, executes functions and stores the result. On mutation, purges affected cache entries based on the invalidation targets.
All HTTP responses emit `Cache-Control: no-store`. Origin-side caching is internal — the HTTP layer never caches at the CDN. Edge caching is managed by Mizan Edge (closed-source Cloudflare Workers) which uses the manifest and MWT tokens.
---
## MWT (Mizan Web Token) and JWT
### Two Token Systems
**JWT** — standard user authentication tokens. Access + refresh pair. Session-tied for revocation.
```python
# settings.py
JWT_PRIVATE_KEY = "your-secret-key" # Required
JWT_ALGORITHM = "HS256" # Default, or RS256 for asymmetric
JWT_ACCESS_TOKEN_EXPIRES_IN = 300 # 5 minutes
JWT_REFRESH_TOKEN_EXPIRES_IN = 604800 # 7 days
JWT_VALIDATE_SESSION = True # Check session exists on use
```
JWT claims: `sub` (user ID), `sid` (session key), `staff`, `super`, `type` (access/refresh), `iat`, `exp`.
Session validation: on every JWT use, checks that the session still exists. Logging out destroys the session → immediately revokes all tokens tied to it.
**MWT** — Mizan Web Token. Protocol-owned identity for Edge cache keying. Separate secret from JWT and cache.
```python
# settings.py
MIZAN_MWT_SECRET = "your-mwt-signing-key" # Separate from JWT_PRIVATE_KEY
MIZAN_MWT_TTL = 300 # 5 minutes
```
MWT is used by Mizan Edge to derive user-scoped cache keys without exposing the cache secret to the client. The MWT carries claims that Edge needs (user identity, permissions) in a short-lived token that travels on a custom header (`X-Mizan-Token`).
### Secret Separation
Three independent secrets, each with its own blast radius:
| Secret | Setting | Purpose | Compromise Impact |
|--------|---------|---------|-------------------|
| JWT secret | `JWT_PRIVATE_KEY` | User auth tokens | Auth bypass |
| Cache secret | `MIZAN_CACHE_SECRET` | HMAC cache keys | Cache poisoning |
| MWT secret | `MIZAN_MWT_SECRET` | Edge identity tokens | Cache key spoofing |
---
## SSR Implementation
### Django Template Backend
```python
# settings.py
TEMPLATES = [
{
'BACKEND': 'mizan.ssr.MizanTemplates',
'OPTIONS': {
'worker_path': 'frontend/ssr-worker.tsx',
'timeout': 5,
},
},
]
```
### Usage in Views
```python
from django.shortcuts import render
def profile_page(request, user_id):
profile = get_user_profile(user_id)
return render(request, 'ProfilePage', {'profile': profile})
```
`render()` calls `MizanTemplates.get_template('ProfilePage')` which returns a `MizanTemplate`. The template's `render(context)` sends JSON-RPC to the Bun worker.
### SSR Bridge (bridge.py)
- Spawns `bun run <worker_path>` on first render
- Persistent subprocess — stays alive across requests
- JSON-RPC over stdin/stdout with message ID correlation
- Thread-safe: multiple Django workers can call `render()` concurrently
- Auto-restarts on crash
- Waits for `{"id": 0, "ready": true}` before accepting requests
### Bun Worker (worker.tsx)
- Reads newline-delimited JSON from stdin
- Component registry: `registerComponent('ProfilePage', ProfilePage)`
- Calls `renderToString(createElement(Component, props))`
- Returns `{"id": N, "html": "..."}` or `{"id": N, "error": "..."}`
- Health check: `{"method": "ping"}``{"pong": true}`
---
## Edge Manifest
Generated by `generate_edge_manifest()` or `python manage.py export_edge_manifest`.
```json
{
"contexts": {
"user": {
"functions": [
{"name": "user_profile", "path": "rpc"},
{"name": "profile_page", "path": "view", "route": "/profile/<user_id>/"}
],
"endpoints": ["/api/mizan/ctx/user/"],
"params": ["user_id"],
"user_scoped": true,
"render_strategy": "dynamic_cached",
"page_routes": ["/profile/<user_id>/"]
}
},
"mutations": {
"update_profile": {
"affects": ["user"],
"auto_scoped_params": ["user_id"]
},
"stripe_webhook": {
"affects": ["subscription"],
"private": true,
"route": "/webhooks/stripe/",
"methods": ["POST"]
}
}
}
```
**render_strategy**: `"psr"` (no user-scoped params) or `"dynamic_cached"` (user-scoped). Derived automatically from whether params overlap with `{user_id, user, owner_id, account_id}`.
---
## URL Patterns
```python
# mizan/urls.py
urlpatterns = [
path("session/", session_init_view), # GET — CSRF cookie
path("call/", function_call_view), # POST — RPC dispatch
path("ctx/<str:context_name>/", context_fetch_view), # GET — bundled context fetch
]
```
Mounted at `/api/mizan/` by convention:
```python
urlpatterns = [
path("api/mizan/", include("mizan.urls")),
]
```
---
## Codegen — Current State
The codegen is `protocol/mizan-generate/` — framework-agnostic, two-stage. Stage 1 emits the protocol layer (`callXxx` for mutations, `fetchXxx` for context bundles, types). Stage 2 emits per-framework hooks/composables/stores that subscribe to the `mizan-base` kernel.
**What's in place:**
- Function hooks (`useEcho`, `useUserProfile`, etc.) in the React adapter, subscribing to kernel state via `useSyncExternalStore`
- Context hooks for named contexts and `global`
- Channel hooks for WebSocket transport
- Vue and Svelte equivalents (Stage 2 templates compile, but no live-backend example exercises them — see `ISSUES.md` A4)
**What's not yet emitted (the wrapper layer):**
- `<MizanContext>` provider component for React (calls `configure()` and mounts the kernel into the component tree)
- `useMizan()` hook for accessing the kernel from React
- Framework-named error class (e.g. `DjangoError`) wrapping `MizanError` from the kernel
- Vue and Svelte equivalents
The legacy `MizanProvider` in `mizan-react/src/context.tsx` (~750 lines) and the `forms.ts` consumer (~1163 lines) still depend on the pre-kernel API. They're tracked as `ISSUES.md` A1 and A3. Removing them is gated on the wrapper layer above being emitted.
The SSR pipeline is independent of the codegen — it renders whatever components are registered in the Bun worker.

107
INVARIANTS.md Normal file
View File

@@ -0,0 +1,107 @@
# Application Framework Interface Invariants
All invariants are absolute. Agents are not permitted to modify this file unless **DIRECTLY PROMPTED BY RYTH**.
If an invariant is not satisfiable by the backend's native functionality (for example, FastAPI is missing a native ORM for Shapes),
then a canonical technology must be proposed. The technology *MUST* be approved by Ryth before implementation.
## Backend Adapters
Django (python)
FastAPI (python)
Typescript (generic)
Rust/Axum (generic)
Tauri (Rust)
## Frontend Adapters
React (Typescript)
Vue (Typescript)
Svelte (Typescript)
Tauri (Rust)
### Client Function RPC
---
No REST endpoints.
Client functions are decorated functions (decorator or registration call at definition-site) that both receive and return HTTP & JSON compliant arguments.
The decoration mechanism must implement the full variadic or kwarg set (websocket, auth, context wiring).
### WebSocket Support
---
A client function declared `websocket=` is dispatched over a persistent connection rather than request/response. Server-initiated messages reach the subscribed contexts; invalidation travels the socket with the same semantics it has over HTTP.
The per-adapter transport differs — Django Channels, a native WebSocket route, a Tauri IPC subscription channel — but the declaration and the wire semantics do not. Mixing socket and non-socket transport within one context is a registration-time error.
### Named Contexts
---
Any string passed to `context=` is a named context. Functions sharing a context name are grouped at registration into one provider, one fetch, and one set of generated hooks — a single read request, never N round-trips. `context='global'` is the one reserved name: fetched once at the root and SSR-hydrated.
Shared parameters elevate to required provider props; non-shared params elevate to optional props with per-function override. A read context is GET-dispatched and cacheable, and it is the unit a mutation invalidates.
### Mutation Invalidation
---
A mutation declares what it `affects=` — a context name, a function reference, or a list — and that relationship is generated into the client. On success the affected contexts refetch; on failure nothing invalidates. The developer never writes a cache key, never calls an invalidate function, never maintains a query-key map.
Invalidation auto-scopes by matching parameter name: a mutation carrying `user_id=123` invalidates the `user_id=123` entry, not the whole context.
This is the invariant that separates the AFI from typed RPC. An adapter that dispatches calls and projects shapes but leaves the client hand-writing invalidation has not satisfied it. The client holds a server-reconciled view, never a parallel source of truth.
### API Shapes
---
A backend adapter supports the "API Shape" feature to the fullest extent:
- ORM Integration
- Auto-diffing (Receive a list of objects, check primary keys for add/modify/delete semantics, use Django as reference)
- Backend-for-Frontend Authoring DX (Shape schema must be easily authorable near used function)
### Auth
---
A function declaring `auth=` is enforced at dispatch on every adapter — the guard rejects before the function body runs, identically across transports. Authorization is a property of the declared function, carried in the IR, not middleware an adapter bolts on or omits.
### File Uploads
---
The `Upload` type is a first-class argument carried end to end — IR, codegen, and dispatch binding. Arguments are otherwise HTTP- and JSON-compliant; `Upload` is the one binary exception, bound from multipart over HTTP and from the envelope over IPC. The declaration is uniform; the transport binding is per-adapter.
### Canonical IR & Codegen
---
Every backend adapter emits the canonical KDL IR describing its functions, contexts, types, and invalidation graph. Every frontend client is generated from that IR. No REST envelope, no OpenAPI document, no per-backend converter sits between a backend and a frontend — the IR is the only contract.
This is the invariant that collapses the backends × frontends quadratic to one adapter per stack. A backend that does not emit the IR, or a frontend not generated from it, is outside the AFI: the boundary is the IR, and nothing crosses it untyped.
### Client Kernel
---
Every frontend adapter is a thin idiomatic wrapper over one shared kernel. The kernel owns the reconciled cache — context state, status, error, server-driven merge and invalidate, session init — and reaches the backend through a pluggable transport (HTTP, Tauri IPC, webview channel). Framework adapters subscribe and render in their own idiom (React hooks, Vue composables, Svelte runes); codegen targets the adapter surface, never the raw kernel.
No adapter keeps its own copy of the truth. The reconciled view lives once, in the kernel.
### SSR
---
Server rendering is the AFI's second product, orthogonal to RPC and composable with it — either ships standalone. A function's registered render strategy renders on the server through the bridge and hydrates on the client; the contexts a page reads are SSR-hydrated at the root, so first paint carries data rather than a loading state.
## Compositions
Stdlib over the invariants above, not invariants in themselves — named so the boundary is explicit and an adapter is never marked short for lacking them as primitives:
- **Forms** — three role-tagged client functions (schema / validate / submit) plus field validation. RPC and validation composed; not its own primitive.
- **Context classes (`send` / `receive`)** — the read/write class form with Shape diffing. Named Contexts + API Shapes + Mutation Invalidation composed into one declaration; the heavy DX surface over the primitives, not a new primitive.

184
ISSUES.md
View File

@@ -1,173 +1,23 @@
# Mizan — Known Issues
Identified by domain expert review (Cloudflare, Serverless, Vercel, React Query, Django, Laravel, Vue/Svelte).
Status board against the current codebase: Rust codegen (`protocol/mizan-codegen`),
KDL IR, kernel-owned frontend state (`@mizan/base`). Issues that the earlier
expert-review board filed against the deleted JavaScript codegen and the
pre-kernel `mizan-react` provider have been removed — they audited files that
no longer exist.
## Fixed
## Open
- ~~C1~~ Scoped cache purge now passes user_id
- ~~C2~~ initSession retries 3x, resets on failure
- ~~C3~~ SSR backend injects `__MIZAN_SSR_DATA__` script tag
- ~~C4~~ SSR bridge uses _write_lock for stdin
- ~~C5~~ SSR bridge registers atexit handler
- ~~C7~~ View-path mutations now purge origin cache
- ~~H1~~ pendingScoped is Array, not Map (no overwrite)
- ~~H2~~ stableKey() sorts JSON keys (order-independent)
- ~~H3~~ mizanFetch retries 2x on 5xx/network errors
- ~~H4~~ Named contexts skip refetch if SSR data exists
- ~~H6~~ refreshContext uses GET /ctx/ not POST /call/
- ~~H10~~ _meta always fresh dict
- ~~H11~~ Python normalizes True→"true" for cross-language HMAC
- ~~H13~~ isValid checks all required fields are touched
- ~~M11~~ execute_function return type includes HttpResponseBase
- ~~M18~~ registerContext cleanup uses ?. (no crash)
- [ ] **Vue / Svelte frontend packages are unimplemented stubs.** `frontends/mizan-vue` and `frontends/mizan-svelte` contain only a `package.json` — no `src/`. The Rust codegen emits Vue composables and Svelte stores (`src/emit/vue.rs`, `src/emit/svelte.rs`, byte-checked by `vue_svelte_parity.rs`), but there is no runtime kernel-adapter package for either and no example app exercises them against a live backend. React is the only frontend with full integration verification.
- [ ] **Svelte adapter emits Svelte 4 stores.** `src/emit/svelte.rs` generates `readable` stores from `svelte/store`. Svelte 5 `$state`/`$derived` runes are the current idiom.
- [ ] **Forms have no codegen target.** `mizan-react/src/forms.ts` (form core hooks) is hand-written and consumed via the pre-kernel `MizanProvider`; the e2e harness has its form fixtures removed. A form codegen target wired to `mizanCall` is owed.
- [ ] **Pre-kernel MizanProvider still shipped.** `mizan-react/src/context.tsx` (~750 lines) is the pre-kernel provider, still imported by the desktop example. It coexists with the codegen-emitted `MizanContext` (which subscribes to `@mizan/base`). Migrating the desktop example onto the generated provider retires it.
- [ ] **Cache module open issues.** See `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`: purge atomicity, cross-language stringification, per-param sub-index cleanup, thundering-herd protection, `cache_get`/`cache_put` argument inconsistency, RedisCache test coverage.
- [ ] **Packages missing a README.** `frontends/mizan-base` (the kernel everything imports), `protocol/mizan-codegen` (the codegen binary), `frontends/mizan-vue`, `frontends/mizan-svelte`, `frontends/mizan-rust`, `backends/mizan-ts`, `backends/mizan-rust-axum`, `cores/mizan-python`.
## Remaining Critical
## Resolved this pass
### C6. No loading/error/stale states in runtime
**File:** `mizan-base/src/index.ts`
The kernel stores only `{params, refetch}`. No `data`, `status`, `error`. Every adapter reinvents loading tracking. Blocks stale-while-revalidate.
## Remaining High
### H5. Mutation hooks expose no loading/error state
**File:** `protocol/mizan-generate/generator/lib/adapters/react.mjs`
Returns bare `useCallback`. No `isPending`, `error`, `isSuccess`.
### H7. Redis SCAN blocks request path at scale
**File:** `mizan-django/src/mizan/cache/backend.py`
Synchronous SCAN at 1M keys: multi-second blocking.
### H8. Svelte codegen uses Svelte 4 stores
**File:** `protocol/mizan-generate/generator/lib/adapters/svelte.mjs`
Should use Svelte 5 `$state`/`$derived` runes.
### H9. Svelte destroy() not auto-called
**File:** `protocol/mizan-generate/generator/lib/adapters/svelte.mjs`
Memory leak if user forgets `onDestroy`.
### H12. Forms triggerValidation captures stale data
**File:** `mizan-react/src/forms.ts`
Debounced validation uses stale closure data.
## Remaining Medium
### M1. SSR bridge not fork-safe
gunicorn prefork shares file descriptors and Redis connections.
### M2. cache_purge_user() not implemented
No way to purge all cache entries for one user.
### M3. No garbage collection for context entries
Runtime `contexts` Map grows monotonically.
### M4. No cross-tab invalidation
No BroadcastChannel. Logout in tab 1 doesn't affect tab 2.
### M5. React 18 Strict Mode double-fetch
useEffect runs twice in dev mode.
### M6. No request deduplication
Two components mounting same context fire parallel fetches.
### M7. SSR worker module cache never invalidates
Dynamic imports cached forever.
### M8. Vue injection key not exported
Can't inject directly without generated composables.
### M9. Vue onMounted won't pre-fetch in Vue SSR
Needs `onServerPrefetch` for Nuxt.
### M10. Svelte should use setContext/getContext
Module-level stores don't scope to component tree.
### M12. render_strategy heuristic uses hardcoded param names
Misses `member_id`, `customer_id`, non-English names.
### M13. initSession called for token-auth requests
Wastes GET /session/ round-trip for JWT/MWT apps.
### M14. Vue watch imported but unused
Params not watched — reactive param changes don't trigger refetch.
### M15. Vue mutation composables misleading `use` prefix
`export const useXxx = callXxx` — not a real composable.
### M16. Svelte mutation imports bypass Stage 1 index
Should import from `'../index'` consistently.
### M17. Side effects in React state updater
Context listeners called inside `setContextStore()` updater.
## Architectural / Cleanup Debt
### A1. Legacy MizanProvider not yet removed
**File:** `mizan-react/src/context.tsx` (~750 lines)
Superseded by the kernel (`mizan-base`) + generated React adapter (`useSyncExternalStore`). Still exported as `MizanProvider`, `useMizan`, `useMizanContext`, etc. Must be deleted or replaced with thin shims that call `configure()` + delegate to the new generated hooks.
### A2. Allauth pending extraction
**File:** `legacy/allauth/` (44 files)
Sitting in `legacy/` since the cleanup pass. Should become its own `mizan-django-allauth` package consuming Mizan's public API. Unblocks v1 mizan-react publishing.
### A3. Forms codegen not adapted to kernel
**File:** `mizan-react/src/forms.ts` (~1163 lines)
Still uses `useMizan().call()` from the legacy MizanProvider. Needs rewrite to use `mizanCall` from the kernel. Currently the only consumer of MizanProvider — blocks A1.
### A4. Codegen for Vue/Svelte not validated end-to-end
The Stage 2 templates produce code that compiles, but no example app exercises Vue or Svelte rendering against a live backend. React is the only adapter with full integration verification.
### A5. ROADMAP.md is stale
**File:** `ROADMAP.md`
Lists SSR Bridge, Edge Manifest, Codegen Rewrite, etc. as "Next" — all are done. Doesn't reflect:
- Two-stage codegen with Vue/Svelte adapters
- C6 kernel-owned state (`ContextState<T>`)
- mizan-ts cross-language adapter
- Cleanup of djarea/Django-specific naming
### A6. CLAUDE.md may also be stale
**File:** `CLAUDE.md`
Written before the kernel rewrite. References to MizanProvider responsibilities and the old codegen pattern are likely outdated. Needs audit.
## Test Coverage Gaps
### T1. No tests for C6 kernel state machine
**File:** `mizan-base/` has no `tests/` directory at all
The state-owning kernel has zero unit tests. No coverage of:
- `registerContext` returning `getState/subscribe/refetch/unregister`
- Status transitions: idle → loading → success/error
- Subscriber notifications on state change
- Refetch reusing the same entry on Strict Mode re-mount
- `unregister` clearing listeners
### T2. No tests for generated Vue adapter output
The `vue.mjs` template produces code, but no test verifies it generates valid Vue 3 composables, that `onServerPrefetch` is wired correctly, or that the kernel subscription bridges to Vue reactivity.
### T3. No tests for generated Svelte adapter output
Same as T2. Readable store factory pattern is unverified against actual Svelte components.
### T4. No tests for view-path cache purge (C7 fix unverified)
The fix added `_purge_cache_for_invalidation()` to the view-path branch, but no test asserts that an `HttpResponse`-returning mutation actually purges the origin cache.
### T5. No tests for SSR thread safety (C4 fix unverified)
The `_write_lock` was added but no concurrent-render test exists to prove it prevents JSON interleaving.
### T6. No tests for SSR atexit cleanup (C5 fix unverified)
`atexit.register(self.shutdown)` was added but not exercised — no test that asserts the Bun process is reaped on Python exit.
### T7. No tests for SSR hydration injection (C3 fix unverified)
The `<script>window.__MIZAN_SSR_DATA__=...</script>` was added to template output but no test asserts it appears in rendered HTML or that the JSON is valid/safe.
### T8. No cross-language HMAC pin test for booleans/None (H11 fix unverified)
Python now normalizes True→"true", but there's no test comparing Python's `derive_cache_key(secret, ctx, {flag: True})` against TypeScript's equivalent to prove they produce identical hex output.
### T9. No tests for retry logic (H3)
`fetchWithRetry` retries 5xx/network errors with backoff. No test for: 5xx triggers retry, 4xx does not, mutation calls bypass retry, max retries respected.
### T10. No end-to-end integration test
Nothing exercises the full pipeline: Django function defined → schema exported → codegen runs → generated React mounts → mutation fires → server response includes invalidate → kernel refetches → DOM updates. Each layer is tested in isolation.
### T11. No tests for `isValid` requiring all required fields touched (H13 fix unverified)
The forms fix checks `field.required && !touched` but no test exercises a form with untouched required fields to confirm `isValid === false`.
### T12. No tests for `_meta` fresh-dict isolation (H10 fix unverified)
The shared-dict fix replaced `{**FunctionWrapper._meta, **meta}` with `{**meta}`. No test confirms that mutating one function's `_meta` doesn't leak into others.
- [x] **Codegen test suite compile break** — every `mizan-codegen` test constructed `SourceConfig` without the `rust`/`script` fields added alongside the Rust-backend work. Suite now compiles and is green.
- [x] **React parity baseline** — the emitter correctly drops the dead `initSession`/`MizanError` top-level imports (they are only re-exported, never used in the module body); baseline regenerated. Fixed the template whitespace artifact that indented the `} from '@mizan/base'` closing brace.
- [x] **Edge manifest non-determinism**`generate_edge_manifest` iterated registration order; now sorts context and mutation keys, so the manifest is deterministic regardless of registration order.
- [x] **Dead code removed**`workers/mizan-ssr/src/test-worker.tsx` (a relic of the rejected `registerComponent` registry), unused TS helpers `isResponseReturn` and `sortedStringify` (mizan-ts), the unused `IndexMap` import (`emit/python.rs`), the dead `debug_expose_names` Django setting, and the dead `package.json` exports + vite aliases (`./client/nextjs`, `./allauth`, `./allauth/nextjs`) pointing at source that does not exist.

95
LICENSE Normal file
View File

@@ -0,0 +1,95 @@
Copyright (c) 2026 Ryth Azhur
Elastic License 2.0
URL: https://www.elastic.co/licensing/elastic-license
## Acceptance
By using the software, you agree to all of the terms and conditions below.
## Copyright License
The licensor grants you a non-exclusive, royalty-free, worldwide,
non-sublicensable, non-transferable license to use, copy, distribute, make
available, and prepare derivative works of the software, in each case subject to
the limitations and conditions below.
## Limitations
You may not provide the software to third parties as a hosted or managed
service, where the service provides users with access to any substantial set of
the features or functionality of the software.
You may not move, change, disable, or circumvent the license key functionality
in the software, and you may not remove or obscure any functionality in the
software that is protected by the license key.
You may not alter, remove, or obscure any licensing, copyright, or other notices
of the licensor in the software. Any use of the licensors trademarks is subject
to applicable law.
## Patents
The licensor grants you a license, under any patent claims the licensor can
license, or becomes able to license, to make, have made, use, sell, offer for
sale, import and have imported the software, in each case subject to the
limitations and conditions in this license. This license does not cover any
patent claims that you cause to be infringed by modifications or additions to
the software. If you or your company make any written claim that the software
infringes or contributes to infringement of any patent, your patent license for
the software granted under these terms ends immediately. If your company makes
such a claim, your patent license ends immediately for work on behalf of your
company.
## Notices
You must ensure that anyone who gets a copy of any part of the software from you
also gets a copy of these terms.
If you modify the software, you must include in any modified copies of the
software prominent notices stating that you have modified the software.
## No Other Rights
These terms do not imply any licenses other than those expressly granted in
these terms.
## Termination
If you use the software in violation of these terms, such use is not licensed,
and your licenses will automatically terminate. If the licensor provides you
with a notice of your violation, and you cease all violation of this license no
later than 30 days after you receive that notice, your licenses will be
reinstated retroactively. However, if you violate these terms after such
reinstatement, any additional violation of these terms will cause your licenses
to terminate automatically and permanently.
## No Liability
*As far as the law allows, the software comes as is, without any warranty or
condition, and the licensor will not be liable to you for any damages arising
out of these terms or the use or nature of the software, under any kind of
legal claim.*
## Definitions
The **licensor** is the entity offering these terms, and the **software** is the
software the licensor makes available under these terms, including any portion
of it.
**you** refers to the individual or entity agreeing to these terms.
**your company** is any legal entity, sole proprietorship, or other kind of
organization that you work for, plus all organizations that have control over,
are under the control of, or are under common control with that
organization. **control** means ownership of substantially all the assets of an
entity, or the power to direct its management and policies by vote, contract, or
otherwise. Control can be direct or indirect.
**your licenses** are all the licenses granted to you for the software under
these terms.
**use** means anything you do with the software requiring one of your licenses.
**trademark** means trademarks, service marks, and similar rights.

View File

@@ -1,14 +1,19 @@
# MIZAN — Named Contexts & Mutation Architecture
> **Historical design spec.** The original named-contexts / mutation design
> document from the January 2025 design conversation. Kept as a record of design
> intent, not as a description of the current build — names and surfaces here
> predate the implementation (the codegen is the Rust binary
> `protocol/mizan-codegen`, never shipped under the working name "Maison"). For
> current architecture, read `CLAUDE.md` (wire protocol, package layout, codegen
> state) and `docs/` (`AFI_ARCHITECTURE.md`, `SSR_ARCHITECTURE.md`,
> `CACHE_KEYING.md`, `MWT_SPEC.md`).
## For Claude Code
This plan was written by Ryth's Claude.ai session after an extended design conversation
reviewing the full codebase, the original @compose discussion from January 2025, and
several rounds of architectural refinement. Treat this as the spec.
The framework formerly called mizan is now called **MIZAN**. Package names, imports,
and references should be updated accordingly. The internal codegen engine is called
**Maison** — it lives inside Mizan and does not need its own public surface.
several rounds of architectural refinement.
---

122
README.md Normal file
View File

@@ -0,0 +1,122 @@
# Mizan
Mizan is an Application Framework Interface (AFI). A single `@client` decorator on a
server function generates a typed frontend client; cache invalidation and caching are
handled by the protocol.
```python
from mizan import client, ReactContext
UserContext = ReactContext('user')
# Context function — bundled into GET /api/mizan/ctx/user/
@client(context=UserContext)
def user_profile(request, user_id: int) -> UserShape:
return UserShape.query(lambda qs: qs.filter(pk=user_id))[0]
# Mutation — invalidation scoped automatically by matching param name
@client(affects=UserContext)
def update_profile(request, user_id: int, name: str) -> dict:
...
```
Adapters exist for Django, FastAPI, Rust/Axum, Tauri, and TypeScript. Django is the
reference implementation; per-adapter support is inventoried below.
> **Status:** Mizan is not production-tested. It passes its own test suites but has not
> been run in a production deployment. Treat it as pre-release.
## Documentation
- [`docs/`](docs/) — architecture references: AFI, SSR, cache keying, MWT, PSR vs. Edge
- [`ROADMAP.md`](ROADMAP.md) · [`ISSUES.md`](ISSUES.md) — planned work and known gaps
## Backend adapters
Every adapter implements the same AFI wire protocol. The matrix below inventories
support per adapter, grouped to separate protocol guarantees from Django-specific
features (forms, ORM projection, auth providers, SSR). A cell counts as supported only
when that adapter wires the capability into its own dispatch surface, not merely that a
shared core primitive exists.
Legend: ✅ supported · ◑ partial · ❌ not implemented · — not applicable to this transport
### Protocol core
The surface every Mizan adapter implements.
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|---|:---:|:---:|:---:|:---:|:---:|
| RPC call dispatch (`{result, invalidate}`) | ✅ | ✅ | ✅ | ✅ ¹ | ✅ |
| Named-context bundle fetch | ✅ | ✅ | ✅ | ✅ | ✅ |
| Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ |
| Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ |
| Codegen IR export (KDL) | ✅ | ✅ | ✅ ⁶ | ✅ ⁶ | — ⁸ |
### Edge, cache & enforcement
Protocol transports and guarantees co-equal with the body channel in the spec.
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|---|:---:|:---:|:---:|:---:|:---:|
| Invalidation — `X-Mizan-Invalidate` header | ✅ | ❌ | ❌ | — ¹ | ✅ |
| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ❌ ⁵ | ◑ ⁵ | ❌ |
| Origin-side HMAC cache | ✅ | ❌ | ❌ | ❌ | ✅ |
| Edge manifest export | ✅ | ❌ | ❌ | — | ✅ |
| PSR (`render_strategy` in manifest) | ✅ | ❌ | ❌ | — | ✅ |
| Session / CSRF init endpoint | ✅ | ◑ ⁷ | ◑ ⁷ | — | ❌ |
> **Caveat:** Rust/Axum and Tauri accept `auth=` on a function but do not yet enforce
> it — do not rely on `auth=` for access control on those adapters.
### Stack extensions (Django)
Django ecosystem features Mizan wraps. Other adapters provide these only where the
target stack calls for them.
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|---|:---:|:---:|:---:|:---:|:---:|
| WebSocket channels (declared transport) | ✅ | ❌ | ◑ ² | ❌ | ❌ |
| Forms (schema / validate / submit) | ✅ | ❌ | ◑ ³ | ❌ | ❌ |
| Formsets | ✅ | ❌ | ❌ | ❌ | ❌ |
| API shapes (ORM query projection) ⁴ | ✅ | — | — | — | — |
| JWT auth (access / refresh, session validation) | ✅ | ❌ | ❌ | ❌ | ❌ |
| MWT (edge identity token) | ✅ | ❌ | ❌ | — | ❌ |
| SSR bridge | ✅ | ❌ | ❌ | — | ❌ |
| Auth-provider integration (allauth) | ✅ | ❌ | ❌ | ❌ | ❌ |
**Notes**
1. Tauri's transport is Tauri IPC (a single `#[tauri::command]` envelope), not HTTP.
Invalidation rides in the JSON response body; there is no header channel.
2. Rust/Axum declares `Transport::Websocket` in the IR/macro but routes no Axum
WebSocket handler yet.
3. Rust/Axum carries `is_form`/`form_role` trait stubs but no validate/submit endpoint.
4. "API shapes" is Django's django-readers queryset projection — ORM-coupled. Every
adapter carries typed input/output through the KDL IR; the projection primitive
itself is Django-only.
5. Tauri's `FunctionSpec` carries `auth`/`private` fields; the dispatch path does not
enforce them. Rust/Axum has no enforcement either.
6. Rust/Axum and Tauri are the IR authority via the `#[mizan::client]` macro + linkme
registry; the codegen links the crate directly (`build_ir()` / the `export-ir` bin)
rather than fetching over HTTP.
7. FastAPI and Rust/Axum expose `GET /session/` returning a null CSRF token for wire
parity; CSRF is Django-only.
8. TypeScript is an edge/protocol-reference adapter (HMAC cache, manifest, PSR), not a
codegen source — it demonstrates the cache + invalidation protocol is
language-agnostic.
## Conformance
Adapter parity is gated by the AFI conformance suite in [`tests/afi/`](tests/afi/). It
currently asserts **IR-shape parity** — the same fixture through Django, FastAPI, and
the Rust adapter emits byte-identical KDL (`test_codegen_parity.py`). Per-capability
runtime assertions (header transport, `auth=` enforcement, cache behavior) are planned.
## License
Mizan is licensed under the [Elastic License 2.0](LICENSE) (SPDX: `Elastic-2.0`). You
may use, copy, modify, and distribute it freely, including in commercial products you
build on top of it. You may **not** provide Mizan to third parties as a hosted or
managed service that exposes a substantial set of its features.

View File

@@ -4,47 +4,48 @@
### Done
- **`@client` decorator** — `context=`, `affects=`, `auth=`, `websocket=`, `private=`, `route=`, `methods=`, `rev=`, `cache=`
- **`ReactContext` class** — type-safe context/affects references with linting
- **Named contexts** — functions sharing a context name grouped into one provider and one fetch
- **Context bundling endpoint** — `GET /api/mizan/ctx/<name>/` returns all functions in one response
- **Server-driven invalidation (JSON body)** — mutation responses carry `{"result": ..., "invalidate": [...]}`
- **`X-Mizan-Invalidate` header** — second invalidation transport for view-path responses (redirects, HTML)
- **Return-type branching** — data return → RPC path; `HttpResponse` return → view path
- **Scoped invalidation** — `affects_params` lambda; runtime supports `{context, params}` form
- **Auth guards** — `auth=True`, `auth='staff'`, `auth='superuser'`, `auth=callable`
- **JWT + session auth** — auto-detected, CSRF handled
- **MWT** — Mizan Web Token for Edge cache keying (separate secret from JWT/cache)
- **Shapes** — Pydantic + django-readers for typed query projections
- **WebSocket channels** — real-time bidirectional communication
- **HMAC cache keying** — origin-side cache with cross-language HMAC conformance (Python + TypeScript pin)
- **Edge manifest** — `python manage.py export_edge_manifest`; both RPC and view-path functions
- **SSR bridge** — Django template backend → persistent Bun subprocess via JSON-RPC
- **`mizan-base` kernel** — framework-agnostic imperative client primitives (data/status/error owned by kernel)
- **Two-stage codegen** — Stage 1 emits framework-agnostic protocol layer; Stage 2 emits per-framework hooks (React, Vue, Svelte)
- **`mizan-ts`** — TypeScript backend adapter; proves the protocol is language-agnostic
- [x] **`@client` decorator** — `context=`, `affects=`, `auth=`, `websocket=`, `private=`, `route=`, `methods=`, `rev=`, `cache=`
- [x] **`ReactContext` class** — type-safe context/affects references with linting
- [x] **Named contexts** — functions sharing a context name grouped into one provider and one fetch
- [x] **Context bundling endpoint**`GET /api/mizan/ctx/<name>/` returns all functions in one response
- [x] **Server-driven invalidation (JSON body)** — mutation responses carry `{"result": ..., "invalidate": [...]}`
- [x] **`X-Mizan-Invalidate` header** — second invalidation transport for view-path responses (redirects, HTML)
- [x] **Return-type branching** — data return → RPC path; `HttpResponse` return → view path
- [x] **Scoped invalidation**`affects_params` lambda; runtime supports `{context, params}` form
- [x] **Auth guards**`auth=True`, `auth='staff'`, `auth='superuser'`, `auth=callable`
- [x] **JWT + session auth** — auto-detected, CSRF handled
- [x] **MWT** — Mizan Web Token for Edge cache keying (separate secret from JWT/cache)
- [x] **Shapes** — Pydantic + django-readers for typed query projections
- [x] **WebSocket channels** — real-time bidirectional communication
- [x] **HMAC cache keying** — origin-side cache with cross-language HMAC conformance (Python + TypeScript pin)
- [x] **Edge manifest**`python manage.py export_edge_manifest`; both RPC and view-path functions; deterministic (sorted) output
- [x] **SSR bridge** — Django template backend → persistent Bun subprocess via JSON-RPC; the worker resolves components by file path (`import(file)` + `renderToString`)
- [x] **`mizan-base` kernel** — framework-agnostic imperative client primitives (data/status/error owned by kernel)
- [x] **Rust codegen**`protocol/mizan-codegen`, a Rust binary reading KDL IR and emitting per-target clients (react, vue, svelte, channels, stage1, python, rust), each byte-parity-tested. `mizan-generate` is the thin npm launcher.
- [x] **React wrapper layer**codegen emits the `MizanContext` root provider, `useMizan` escape hatch, and `useMutation`-backed hooks exposing `{ mutate, isPending, error }`
- [x] **Additional backend adapters**`mizan-ts` (TypeScript), `mizan-rust-axum` (Rust/Axum with three-way parity), `mizan-tauri`
- [x] **Frontend transports**`mizan-tauri-transport`, `mizan-webview-transport`, `mizan-webview-channels`
---
### Next (in progress)
### Next
- **React adapter wrapper layer** — codegen emits `MizanContext` provider, `useMizan` hook, `DjangoError` class on top of the `mizan-base` kernel. Equivalent wrapper layers for Vue and Svelte adapters. The harness in `examples/django-react-site` is blocked on this.
- **Legacy `MizanProvider` removal (A1)** — `mizan-react/src/context.tsx` (~750 lines) replaced by codegen-emitted wrappers. Blocks v1 `mizan-react` publishing.
- **Forms migration to kernel (A3)** — `mizan-react/src/forms.ts` (~1163 lines) currently consumes legacy `MizanProvider`. Rewrite to use `mizanCall` from the kernel. Blocks A1.
- **Allauth extraction (A2)** — `legacy/allauth/` becomes `mizan-django-allauth` package consuming Mizan's public API.
- **Vue/Svelte e2e validation (A4)** — example apps exercising a live backend end-to-end, like `examples/django-react-site` does for React.
- **Test coverage gaps** — T1T12 in `ISSUES.md` (kernel state machine, view-path purge, SSR thread safety, retry logic, cross-language HMAC pin, etc.)
- [ ] **Vue / Svelte runtime packages**`frontends/mizan-vue` and `frontends/mizan-svelte` are unimplemented stubs. The codegen emits their clients (byte-parity-tested), but a kernel-adapter runtime package and a live-backend example are owed for each.
- [ ] **Svelte 5 runes** — the Svelte target emits Svelte 4 `readable` stores; migrate to `$state`/`$derived`.
- [ ] **Forms codegen target** — emit form clients wired to `mizanCall` from the kernel; retire the hand-written `mizan-react/src/forms.ts` and its dependence on the pre-kernel provider.
- [ ] **Desktop example onto the generated provider**migrate `examples/django-react-desktop-app` off the pre-kernel `MizanProvider` (`mizan-react/src/context.tsx`) so it can be retired.
- [ ] **Cache hardening**purge atomicity, per-param sub-index cleanup, thundering-herd protection, RedisCache coverage (see `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`).
- [ ] **Package READMEs**`mizan-base`, `mizan-codegen`, and the other packages missing one (see `ISSUES.md`).
---
### Quality
## Core Consolidation — Rust Binary
- **H5** — Mutation hooks expose no loading/error state
- **H7** — Redis SCAN blocks request path at scale
- **H8** — Svelte codegen uses Svelte 4 stores; should use Svelte 5 runes
- **H9** — Svelte `destroy()` not auto-called (memory leak)
- **H12** — Forms `triggerValidation` captures stale data
- Medium issues (M1M18) per developer judgment
Move all core functionality unrelated to language introspection into the Rust binary. Other languages invoke it through FFI (PyO3 and equivalents) rather than carrying their own copy — centralizing behavior for the whole Mizan toolchain.
Language-specific core code then exists only for actual framework mechanics — registering client functions, binding Shapes to an ORM — never for behavior the binary already owns.
**SSR in the binary.** Because SSR works directly from the IR's typed schemas, the binary can drive it rather than forcing each backend adapter to author SSR by hand. That also lets the binary own SSR validation, keeping it consistent across adapters instead of each backend deriving it manually and drifting apart.
---

View File

@@ -144,40 +144,33 @@ Frontend gets `useChatChannel({ room })`.
## Generate the frontend
The codegen is `mizan-generate` (in `protocol/mizan-generate/`). From your
frontend project, point a config at the Django backend and run the CLI:
The codegen is the `mizan-generate` Rust binary (source at
`protocol/mizan-codegen/`; `protocol/mizan-generate/` is a thin npm
launcher that dispatches to the platform binary). From your frontend
project, point a `mizan.toml` at the Django backend and run the CLI:
```js
// frontend/django.config.mjs
import path from "path"
import { fileURLToPath } from "url"
```toml
# frontend/mizan.toml
output = "src/api"
targets = ["react"]
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, "..")
[source.django]
manage_path = "../backend/manage.py"
command = ["uv", "run", "python"] # optional — defaults to ["python"]
export default {
source: {
django: {
managePath: path.join(root, "backend/manage.py"),
command: ["uv", "run", "python"],
env: {
PYTHONPATH: path.join(root, "backend"),
DJANGO_SETTINGS_MODULE: "myproject.settings",
},
},
},
output: "src/api",
}
[source.django.env]
PYTHONPATH = "../backend"
DJANGO_SETTINGS_MODULE = "myproject.settings"
```
```bash
npx mizan-generate --config django.config.mjs
mizan-generate --config mizan.toml
```
The codegen drives Django's management command (`export_mizan_schema`) under
the hood, then emits Stage 1 (typed `callXxx`/`fetchXxx` over the runtime
kernel) + Stage 2 (`<MizanContext>` provider, per-context providers,
`use{Hook}()` hooks) into `src/api/`.
The codegen drives Django's management command (`export_mizan_ir`) under
the hood, parses the emitted KDL IR, then emits Stage 1 (typed
`callXxx`/`fetchXxx` over the runtime kernel) + Stage 2 (`<MizanContext>`
provider, per-context providers, `use{Hook}()` hooks) into `src/api/`.
```tsx
// app.tsx

View File

@@ -1,6 +1,7 @@
[project]
name = "mizan"
version = "1.0.1"
license = "Elastic-2.0"
description = "Django + React server functions framework"
readme = "README.md"
requires-python = ">=3.10"

View File

@@ -1,65 +1,40 @@
# Cache Module — Known Issues
Issues identified by 8-domain-expert review. Status tracked here.
Open issues against the current cache implementation. Resolved items are
removed once their fix lands.
## Critical (Security / Data Corruption)
## Correctness
### 1. ~~User-scoped content cached without user_id~~ FIXED
`context_fetch_view` now extracts `user_id` from `request.user.pk` and
passes it to `cache_get`/`cache_put`.
### Purge race condition (non-atomic index operations)
`cache_purge` reads the index and deletes as separate operations. A
concurrent `cache_put` between the two steps can orphan entries. Mitigated
by AND-intersection purge semantics, but full atomicity (Lua script or
`WATCH`/`MULTI` on the Redis backend) is still owed.
### 2. Purge race condition (non-atomic index operations)
`cache_purge` does index reads and deletes as separate operations.
Concurrent `cache_put` between steps can orphan entries.
**Status:** Partially mitigated by AND semantics fix. Full atomicity
(Lua script or WATCH/MULTI) still needed for Redis backend.
### Cross-language stringification divergence
Python `str(True)``"True"` vs JS `String(true)``"true"`. `_normalize`
canonicalizes `True`/`False`/`None` today, but the rules for the remaining
value types are not yet pinned in the protocol spec — so Python and
TypeScript HMAC keys can still diverge on an un-normalized type.
### 3. ~~No Redis error handling~~ FIXED
All cache operations in `executor.py` wrapped in try/except with
`logger.warning`. Redis failure falls through to uncached execution.
## Performance / Operability
### 4. ~~Scoped purge uses OR semantics~~ FIXED
Changed to AND (intersection). `{user_id: 5, org_id: 3}` now only
deletes entries matching BOTH params.
### Broad purge leaves per-param sub-indexes
A broad `cache_purge(context)` deletes the entries but not the per-param
sub-indexes — a slow Redis memory leak.
## High (Correctness / Operability)
### No thundering-herd protection
Concurrent cold misses on the same key all execute and write. No
single-flight / request-coalescing.
### 5. ~~No TTL on Redis entries~~ FIXED
`RedisCache.put` now sets `ex=86400` (24h safety-net TTL) by default.
## API shape
### 6. Cross-language str() vs String() divergence
Python `str(True)` -> `"True"`, JS `String(true)` -> `"true"`.
**Status:** Open. Needs canonical stringification rules in protocol spec.
### cache_get / cache_put argument inconsistency
`cache_get`/`cache_put` take explicit args while the executor resolves some
inputs from module globals — two access patterns for one concern.
### 7. Broad purge doesn't clean per-param sub-indexes
**Status:** Open. Slow memory leak in Redis.
## Coverage
### 8. ~~build_index_keys doesn't stringify values~~ FIXED
Now calls `str(v)` on all values, matching `derive_cache_key`.
### 9. ~~Silent exception swallowing in get_cache()~~ FIXED
Now logs warnings for partial config and connection failures.
### 10. ~~_initialized flag not thread-safe~~ FIXED
Now uses `threading.Lock` for thread-safe initialization.
## Medium (Design / Performance)
### 11. No thundering-herd protection
**Status:** Open. Concurrent cold misses all execute and write.
### 12. ~~Wire-protocol internals in __all__~~ FIXED
`derive_cache_key` and `build_index_keys` removed from `__all__`.
### 13. Inconsistent API pattern
**Status:** Open. `cache_get`/`cache_put` take explicit args but executor
fetches from globals.
### 14. ~~clear() uses SCAN + DELETE without pipeline~~ FIXED
Now uses pipeline with UNLINK for batched async deletes.
### 15. ~~No Redis connection timeouts~~ FIXED
`socket_connect_timeout=5`, `socket_timeout=5`, `health_check_interval=30`.
### 16. No RedisCache test coverage
**Status:** Open. Only MemoryCache is tested.
### RedisCache lacks test coverage
Only `MemoryCache` is exercised by the suite. `RedisCache` (connection
pooling, TTL, SCAN/UNLINK batching, socket timeouts) is untested.

View File

@@ -305,6 +305,75 @@ def _resolve_invalidation(
return result if result else None
def _resolve_merges(
view_class: type | None,
input_data: dict[str, Any] | None,
result_data: Any,
) -> list[dict[str, Any]] | None:
"""
Resolve merge targets from @client(merge=...).
Each entry is `{context, slot, value, params?}` — `slot` is the
function-name inside the context bundle the value lands in, resolved
server-side by matching the mutation's return type against each
context-function's return type. Kernel does no shape inference.
Mirrors _resolve_invalidation's tier-1 auto-scoping for params.
Entries whose slot can't be uniquely resolved are dropped.
"""
if view_class is None:
return None
from mizan_core.type_utils import types_match_for_merge
meta = getattr(view_class, "_meta", {})
targets = meta.get("merge") or []
if not targets:
return None
mutation_output = getattr(view_class, "Output", None)
out: list[dict[str, Any]] = []
seen: set[str] = set()
for ctx_name in targets:
if ctx_name in seen:
continue
seen.add(ctx_name)
slot = _resolve_merge_slot(ctx_name, mutation_output, types_match_for_merge)
if slot is None:
continue
entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result_data}
if input_data:
context_params = _get_context_param_names(ctx_name)
matched = {
k: v for k, v in input_data.items()
if k in context_params
}
if matched:
entry["params"] = matched
out.append(entry)
return out
def _resolve_merge_slot(context_name: str, mutation_output: Any, type_matcher: Any) -> str | None:
"""Find the unique function-name slot in context whose return type matches mutation's output."""
if mutation_output is None:
return None
groups = get_context_groups()
fn_names = groups.get(context_name, [])
matches: list[str] = []
for fn_name in fn_names:
fn_cls = get_function(fn_name)
if fn_cls is None:
continue
fn_output = getattr(fn_cls, "Output", None)
if fn_output is not None and type_matcher(fn_output, mutation_output):
matches.append(fn_name)
return matches[0] if len(matches) == 1 else None
def _format_invalidate_header(
invalidate: list[str | dict[str, Any]],
) -> str:
@@ -488,10 +557,12 @@ def execute_function(
output["Cache-Control"] = "no-store"
return output
# RPC path — serialize output
if output is None:
return FunctionResult(data=None)
return FunctionResult(data=output.model_dump())
# RPC path — serialize output. to_jsonable_python walks BaseModel /
# list / dict recursively, so list[BaseModel] (and nested shapes) come
# out wire-ready without a per-shape branch.
from pydantic_core import to_jsonable_python
return FunctionResult(data=to_jsonable_python(output))
def _try_mwt_auth(request: HttpRequest) -> bool:
@@ -731,9 +802,12 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
view_class = get_function(fn_name)
response_data = {"result": result.data}
invalidate_contexts = _resolve_invalidation(view_class, input_data)
merges = _resolve_merges(view_class, input_data, result.data)
if invalidate_contexts:
response_data["invalidate"] = invalidate_contexts
if merges:
response_data["merge"] = merges
response = JsonResponse(response_data)
response["Cache-Control"] = "no-store"

View File

@@ -1,363 +1,30 @@
"""
mizan OpenAPI Schema Generator
Mizan Edge Manifest Generator.
Generates OpenAPI 3.0 compatible schema from registered server functions.
Uses Django Ninja's battle-tested schema generation for robust Pydantic→OpenAPI conversion.
This schema is consumed by the frontend generator which uses openapi-typescript
for robust type generation.
NOTE: Schema export is only available via management command for security.
HTTP endpoint has been removed to prevent function enumeration.
Generates the Edge manifest — a static JSON mapping contexts to URL
patterns and params, consumed by Mizan Edge at deploy time for CDN
cache invalidation. Independent from the Mizan IR; the IR drives
codegen, the manifest drives CDN purging.
Usage:
python manage.py export_mizan_schema
from mizan.export import generate_edge_manifest, generate_edge_manifest_json
"""
from __future__ import annotations
import json
import re
from typing import TYPE_CHECKING, Any
from typing import Any
# Lazy imports to avoid Django settings access at module load time
# (asgi.py imports mizan before Django is fully configured)
if TYPE_CHECKING:
from django import forms
from ninja import NinjaAPI
from mizan_core.registry import get_registry, get_schema, get_context_groups, get_function
from mizan_core.registry import get_context_groups, get_registry
__all__ = [
"get_schema",
"generate_openapi_schema",
"generate_openapi_json",
"generate_edge_manifest",
"generate_edge_manifest_json",
]
def _extract_form_fields(form_class: type) -> list[dict[str, Any]]:
"""
Extract field definitions with constraints from a Django Form class.
Returns a list of field metadata suitable for Zod schema generation:
- name: field name
- zodType: base Zod type ("string", "number", "boolean", "array")
- required: whether field is required
- constraints: dict of Zod-compatible constraints
Constraints include:
- min/max: for string length or number range
- email/url: for format validation
- regex: for pattern validation
- choices: for enum validation
"""
try:
# Try to instantiate form to get bound fields
form = form_class()
fields_dict = form.fields
except TypeError:
# Form requires extra args - use base_fields
fields_dict = getattr(form_class, "base_fields", {})
result = []
for name, field in fields_dict.items():
field_meta = _extract_field_constraints(name, field)
result.append(field_meta)
return result
def _extract_field_constraints(name: str, field: "forms.Field") -> dict[str, Any]:
"""
Extract Zod-compatible constraints from a single Django form field.
"""
from django import forms # Lazy import
meta: dict[str, Any] = {
"name": name,
"required": field.required,
"constraints": {},
}
# Determine base Zod type
if isinstance(field, forms.BooleanField):
meta["zodType"] = "boolean"
elif isinstance(field, (forms.IntegerField, forms.FloatField, forms.DecimalField)):
meta["zodType"] = "number"
if isinstance(field, forms.IntegerField):
meta["constraints"]["int"] = True
elif isinstance(field, forms.MultipleChoiceField):
meta["zodType"] = "array"
meta["constraints"]["items"] = "string"
elif isinstance(field, forms.FileField):
meta["zodType"] = "file"
else:
# Default to string (CharField, EmailField, URLField, etc.)
meta["zodType"] = "string"
# Extract string constraints
if hasattr(field, "max_length") and field.max_length is not None:
meta["constraints"]["max"] = field.max_length
if hasattr(field, "min_length") and field.min_length is not None:
meta["constraints"]["min"] = field.min_length
# Extract number constraints
if hasattr(field, "max_value") and field.max_value is not None:
meta["constraints"]["max"] = field.max_value
if hasattr(field, "min_value") and field.min_value is not None:
meta["constraints"]["min"] = field.min_value
# Email/URL format
if isinstance(field, forms.EmailField):
meta["constraints"]["email"] = True
elif isinstance(field, forms.URLField):
meta["constraints"]["url"] = True
# Choices (for enum validation)
if hasattr(field, "choices") and field.choices:
# Extract choice values (not labels)
choices = []
for choice in field.choices:
if isinstance(choice, (list, tuple)) and len(choice) >= 1:
# Skip empty/blank choices
if choice[0] != "":
choices.append(str(choice[0]))
else:
choices.append(str(choice))
if choices:
meta["constraints"]["choices"] = choices
# Regex validators
for validator in field.validators:
if hasattr(validator, "regex"):
# RegexValidator - extract pattern
pattern = validator.regex.pattern
meta["constraints"]["regex"] = pattern
if hasattr(validator, "message"):
meta["constraints"]["regexMessage"] = validator.message
break # Only use first regex validator
return meta
def snake_to_camel(name: str) -> str:
"""Convert snake_case or dotted.name to camelCase.
Examples:
- login -> login
- login.schema -> loginSchema
- activate_totp -> activateTotp
- activate_totp.schema -> activateTotpSchema
"""
# Split on both underscores and dots
components = re.split(r"[._]", name)
return components[0] + "".join(x.title() for x in components[1:])
def _register_schema_endpoint(
api: "NinjaAPI",
path: str,
operation_id: str,
summary: str,
input_cls: type | None,
output_cls: type,
) -> None:
"""
Register a dummy endpoint on the API for schema generation.
Sets __annotations__ directly to avoid closure capture issues
and exec() security concerns.
"""
if input_cls is not None:
def endpoint(request, data):
pass
# Set annotations directly to the actual type objects (not strings)
endpoint.__annotations__ = {"data": input_cls}
else:
def endpoint(request):
pass
# Register with Ninja
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(
endpoint
)
def generate_openapi_schema() -> dict[str, Any]:
"""
Generate OpenAPI 3.0 schema for all registered mizan functions.
Uses Django Ninja's schema generation internally to ensure proper
Pydantic→OpenAPI conversion (handling $refs, nested types, etc.).
Returns a complete OpenAPI document that can be processed by openapi-typescript.
"""
from ninja import NinjaAPI # Lazy import
from pydantic import BaseModel, create_model # Lazy import
registry = get_registry()
functions = registry.get("functions", {})
# Create a temporary Ninja API for schema generation only
# This is NOT exposed as an HTTP endpoint - purely for leveraging Ninja's
# battle-tested Pydantic→OpenAPI conversion
schema_api = NinjaAPI(
title="mizan Server Functions",
version="1.0.0",
description="Auto-generated schema for mizan server functions",
docs_url=None, # No docs endpoint
openapi_url=None, # No openapi endpoint
)
function_metadata: list[dict[str, Any]] = []
# Store dynamically created classes so they persist for schema generation
schema_classes: dict[str, type] = {}
for name, fn_class in functions.items():
camel_name = snake_to_camel(name)
meta = getattr(fn_class, "_meta", {})
# Get Input/Output classes
input_cls = getattr(fn_class, "Input", None)
output_cls = getattr(fn_class, "Output", None) or BaseModel
# Check if input_cls is a valid Pydantic model with fields
has_input = (
input_cls is not None
and input_cls is not BaseModel
and hasattr(input_cls, "model_fields")
and bool(input_cls.model_fields)
)
# Determine type names for metadata
input_type_name = f"{camel_name}Input" if has_input else None
output_type_name = f"{camel_name}Output"
# Create renamed Pydantic classes for cleaner schema names
# Store them in schema_classes so they persist beyond loop scope
# Uses create_model to avoid metaclass conflicts with custom base classes
if has_input:
schema_classes[input_type_name] = create_model(
input_type_name, __base__=input_cls
)
schema_classes[output_type_name] = create_model(
output_type_name, __base__=output_cls
)
# Register endpoint using helper to avoid closure capture issues
_register_schema_endpoint(
api=schema_api,
path=f"/mizan/{name}",
operation_id=camel_name,
summary=fn_class.__doc__ or f"Call {name}",
input_cls=schema_classes.get(input_type_name),
output_cls=schema_classes[output_type_name],
)
# Collect function metadata for provider generation
fn_meta_entry: dict[str, Any] = {
"name": name,
"camelName": camel_name,
"hasInput": has_input,
"inputType": input_type_name,
"outputType": output_type_name,
"transport": "websocket" if meta.get("websocket") else "http",
"isContext": meta.get("context", False),
# Form metadata
"isForm": meta.get("form", False),
"formName": meta.get("form_name"),
"formRole": meta.get("form_role"), # "schema", "validate", "submit"
}
# Affects metadata (mutation invalidation)
if meta.get("affects"):
fn_meta_entry["affects"] = meta["affects"]
# For form schema functions, extract field definitions for Zod generation
if meta.get("form") and meta.get("form_role") == "schema":
form_class = meta.get("form_class")
if form_class is not None:
try:
fn_meta_entry["formFields"] = _extract_form_fields(form_class)
except Exception as e:
# Don't fail schema generation if field extraction fails
fn_meta_entry["formFields"] = []
fn_meta_entry["formFieldsError"] = str(e)
function_metadata.append(fn_meta_entry)
# Get the OpenAPI schema from Ninja (handles all Pydantic conversion properly)
schema = schema_api.get_openapi_schema(path_prefix="")
# Add custom extension with function metadata for provider generation
schema["x-mizan-functions"] = function_metadata
# Add x-mizan-contexts: grouped context metadata with param elevation
context_groups = get_context_groups()
if context_groups:
contexts_meta: dict[str, Any] = {}
for ctx_name, fn_names in context_groups.items():
# Analyze params across all functions in the context
param_info: dict[str, dict[str, Any]] = {}
for fn_name in fn_names:
fn_cls = get_function(fn_name)
if fn_cls is None:
continue
input_cls = getattr(fn_cls, "Input", None)
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
for field_name, field_info in input_cls.model_fields.items():
if field_name not in param_info:
annotation = field_info.annotation
# Map Python types to JSON schema types
type_name = "string"
if annotation in (int,):
type_name = "integer"
elif annotation in (float,):
type_name = "number"
elif annotation in (bool,):
type_name = "boolean"
param_info[field_name] = {
"type": type_name,
"sharedBy": [],
}
param_info[field_name]["sharedBy"].append(fn_name)
# A param is required if ALL functions in the context declare it
for p_name, p_meta in param_info.items():
p_meta["required"] = len(p_meta["sharedBy"]) == len(fn_names)
contexts_meta[ctx_name] = {
"functions": fn_names,
"params": param_info,
}
schema["x-mizan-contexts"] = contexts_meta
# Add x-mizan metadata to each operation
for fn_meta in function_metadata:
path = f"/mizan/{fn_meta['name']}"
if path in schema.get("paths", {}):
schema["paths"][path]["post"]["x-mizan"] = {
"transport": fn_meta["transport"],
"isContext": fn_meta["isContext"],
}
return schema
def generate_openapi_json(indent: int = 2) -> str:
"""Generate OpenAPI schema as formatted JSON string."""
schema = generate_openapi_schema()
return json.dumps(schema, indent=indent)
def generate_edge_manifest(
base_url: str = "/api/mizan",
view_urls: dict[str, list[str]] | None = None,
@@ -377,14 +44,10 @@ def generate_edge_manifest(
view_urls: Optional mapping of context names to URL patterns for
view-path functions. These are URLs that Edge should
also purge when a context is invalidated.
Example: {"user": ["/profile/:user_id/"]}
Returns:
Manifest dict suitable for JSON serialization.
"""
from pydantic import BaseModel as PydanticBaseModel
# Common user identity param names for user_scoped detection
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
groups = get_context_groups()
@@ -393,8 +56,7 @@ def generate_edge_manifest(
manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}}
for ctx_name, fn_names in groups.items():
# Collect params and routes from all functions in this context
for ctx_name, fn_names in sorted(groups.items()):
param_names: set[str] = set()
functions_meta: list[dict[str, Any]] = []
page_routes: list[str] = []
@@ -404,40 +66,31 @@ def generate_edge_manifest(
if fn_cls is None:
continue
meta = getattr(fn_cls, "_meta", {})
is_view = meta.get("view_path", False)
# Collect param names from Input schema
input_cls = getattr(fn_cls, "Input", None)
if (
input_cls
and input_cls is not PydanticBaseModel
and hasattr(input_cls, "model_fields")
):
param_names.update(input_cls.model_fields.keys())
if input_cls is not None and hasattr(input_cls, "model_fields"):
for param_name in input_cls.model_fields:
param_names.add(param_name)
meta = getattr(fn_cls, "_meta", {})
route = meta.get("route")
view_path = meta.get("view_path")
fn_entry: dict[str, Any] = {
"name": fn_name,
"path": "view" if is_view else "rpc",
"path": "view" if view_path else "rpc",
}
# Collect routes from view-path functions
fn_route = meta.get("route")
if fn_route:
fn_entry["route"] = fn_route
if route:
fn_entry["route"] = route
fn_entry["methods"] = meta.get("methods", ["GET"])
page_routes.append(fn_route)
# Cache protocol metadata
if "rev" in meta:
page_routes.append(route)
if meta.get("rev"):
fn_entry["rev"] = meta["rev"]
if "cache" in meta:
if meta.get("cache") is not None and meta.get("cache") is not True:
fn_entry["cache"] = meta["cache"]
functions_meta.append(fn_entry)
sorted_params = sorted(param_names)
user_scoped = bool(param_names & _USER_SCOPED_PARAMS)
user_scoped = any(p in _USER_SCOPED_PARAMS for p in param_names)
ctx_entry: dict[str, Any] = {
"functions": functions_meta,
@@ -447,69 +100,57 @@ def generate_edge_manifest(
"render_strategy": "dynamic_cached" if user_scoped else "psr",
}
# Add page routes from view-path functions with route=
if page_routes:
ctx_entry["page_routes"] = page_routes
# Add externally-declared view URLs
if view_urls and ctx_name in view_urls:
ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name])
manifest["contexts"][ctx_name] = ctx_entry
# Mutations section — all functions with affects=
for fn_name, fn_cls in all_functions.items():
for fn_name, fn_cls in sorted(all_functions.items()):
meta = getattr(fn_cls, "_meta", {})
affects = meta.get("affects")
if not affects:
if not meta.get("affects"):
continue
# Resolve context names from affects targets
affected_contexts = []
for target in affects:
if target["type"] == "context":
affected_contexts.append(target["name"])
elif target["type"] == "function" and target.get("context"):
affected_contexts.append(target["context"])
affected_contexts = list(dict.fromkeys(affected_contexts))
affected_contexts = list({a["name"] for a in meta["affects"]})
mutation: dict[str, Any] = {"affects": affected_contexts}
# Determine which params auto-scope
auto_scoped = []
# Auto-scoped params — function params that match context params
input_cls = getattr(fn_cls, "Input", None)
if input_cls and input_cls is not PydanticBaseModel and hasattr(input_cls, "model_fields"):
if input_cls is not None and hasattr(input_cls, "model_fields"):
fn_params = set(input_cls.model_fields.keys())
auto_scoped: list[str] = []
for ctx_name in affected_contexts:
ctx_params = set()
for ctx_fn_name in groups.get(ctx_name, []):
ctx_param_names: set[str] = set()
ctx_fns = groups.get(ctx_name, [])
for ctx_fn_name in ctx_fns:
ctx_fn_cls = all_functions.get(ctx_fn_name)
if ctx_fn_cls:
if ctx_fn_cls is None:
continue
ctx_input = getattr(ctx_fn_cls, "Input", None)
if ctx_input and ctx_input is not PydanticBaseModel and hasattr(ctx_input, "model_fields"):
ctx_params.update(ctx_input.model_fields.keys())
auto_scoped.extend(sorted(fn_params & ctx_params))
auto_scoped = list(dict.fromkeys(auto_scoped))
mutation_entry: dict[str, Any] = {
"affects": affected_contexts,
}
if ctx_input is not None and hasattr(ctx_input, "model_fields"):
ctx_param_names.update(ctx_input.model_fields.keys())
for p in fn_params:
if p in ctx_param_names and p not in auto_scoped:
auto_scoped.append(p)
if auto_scoped:
mutation_entry["auto_scoped_params"] = auto_scoped
if meta.get("private"):
mutation_entry["private"] = True
if meta.get("route"):
mutation_entry["route"] = meta["route"]
mutation_entry["methods"] = meta.get("methods", ["POST"])
mutation["auto_scoped_params"] = sorted(auto_scoped)
manifest["mutations"][fn_name] = mutation_entry
if meta.get("private"):
mutation["private"] = True
if meta.get("route"):
mutation["route"] = meta["route"]
mutation["methods"] = meta.get("methods", ["POST"])
manifest["mutations"][fn_name] = mutation
return manifest
def generate_edge_manifest_json(
indent: int = 2,
base_url: str = "/api/mizan",
view_urls: dict[str, list[str]] | None = None,
indent: int = 2,
) -> str:
"""Generate Edge manifest as formatted JSON string."""
manifest = generate_edge_manifest(base_url=base_url, view_urls=view_urls)
return json.dumps(manifest, indent=indent, sort_keys=True)
"""JSON-serialize the Edge manifest."""
return json.dumps(generate_edge_manifest(base_url, view_urls), indent=indent)

View File

@@ -0,0 +1,28 @@
"""
Mizan IR (KDL) export — Django management command.
Usage:
python manage.py export_mizan_ir
Triggers Mizan client discovery to populate the registry, then writes
the canonical Mizan IR as KDL to stdout. The Rust codegen binary
consumes this directly.
"""
from __future__ import annotations
from django.core.management.base import BaseCommand
from mizan_core.ir import build_ir
class Command(BaseCommand):
help = "Export every registered @client function as Mizan IR (KDL)."
def handle(self, *args, **options) -> None:
# Load every project-side @client function so the registry is
# populated before we emit. Conventionally apps/*/clients.py.
from mizan.setup.discovery import mizan_clients
mizan_clients("apps")
self.stdout.write(build_ir(), ending="")

View File

@@ -1,49 +0,0 @@
"""
Export mizan Schema
Management command to export the mizan OpenAPI schema for TypeScript code generation.
The schema is consumed by openapi-typescript for robust type generation.
Usage:
python manage.py export_mizan_schema # Output to stdout
python manage.py export_mizan_schema --output schema.json # Output to file
"""
import json
from pathlib import Path
from django.core.management.base import BaseCommand
from mizan.export import generate_openapi_schema
class Command(BaseCommand):
help = "Export mizan OpenAPI schema for TypeScript code generation"
def add_arguments(self, parser):
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path. If not specified, outputs to stdout.",
)
parser.add_argument(
"--indent",
type=int,
default=2,
help="JSON indentation level (0 for compact output)",
)
def handle(self, *args, **options):
schema = generate_openapi_schema()
indent = options["indent"] if options["indent"] > 0 else None
json_output = json.dumps(schema, indent=indent)
if options["output"]:
output_path = Path(options["output"])
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json_output)
self.stdout.write(self.style.SUCCESS(f"Schema written to {output_path}"))
else:
self.stdout.write(json_output)

View File

@@ -71,9 +71,6 @@ def mizan_clients(apps_root: str, layer: str = "clients") -> None:
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
visitor.visit(_RegisterServerFunctions())
from .registry import validate_registry
validate_registry()
def mizan_module(module_path: str) -> None:
"""

View File

@@ -14,9 +14,6 @@ from django.conf import settings as django_settings
class mizanSettings:
"""mizan configuration."""
# Whether to expose function names in DEBUG mode errors
debug_expose_names: bool
# Cache HMAC signing secret (required when cache is enabled)
cache_secret: str | None
@@ -36,12 +33,10 @@ def get_settings() -> mizanSettings:
Load mizan settings from Django settings.
Settings:
mizan_DEBUG_EXPOSE_NAMES: Show function names in errors when DEBUG=True (default: True)
MIZAN_CACHE_SECRET: HMAC signing key for cache keys (default: None)
MIZAN_CACHE_REDIS_URL: Redis connection URL (default: None)
"""
return mizanSettings(
debug_expose_names=getattr(django_settings, "mizan_DEBUG_EXPOSE_NAMES", True),
cache_secret=getattr(django_settings, "MIZAN_CACHE_SECRET", None),
cache_redis_url=getattr(django_settings, "MIZAN_CACHE_REDIS_URL", None),
mwt_secret=getattr(django_settings, "MIZAN_MWT_SECRET", None),

View File

@@ -1033,6 +1033,44 @@ class ServerDrivenInvalidationTests(TestCase):
self.assertEqual(response.status_code, 404)
self.assertEqual(response["Cache-Control"], "no-store")
def test_mutation_response_includes_merge(self):
"""@client(merge=...) emits a merge entry carrying the return value."""
from mizan.client.executor import function_call_view
UserCtx = ReactContext("user")
@client(context=UserCtx)
def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
return ValidOutput(valid=True)
@client(merge=UserCtx)
def rename(request: HttpRequest, user_id: int, name: str) -> ValidOutput:
return ValidOutput(valid=True)
register(user_profile, "user_profile")
register(rename, "rename")
request = self.factory.post(
"/api/mizan/call/",
json.dumps({"fn": "rename", "args": {"user_id": 7, "name": "Ryth"}}),
content_type="application/json",
)
request.user = AnonymousUser()
request._dont_enforce_csrf_checks = True
response = function_call_view(request)
data = json.loads(response.content)
self.assertIn("merge", data)
# Server resolves slot — user_profile is the unique ValidOutput-returning fn in the context
self.assertEqual(
data["merge"],
[{"context": "user", "slot": "user_profile", "params": {"user_id": 7}, "value": {"valid": True}}],
)
# Merge-only mutations don't emit invalidate
self.assertNotIn("invalidate", data)
self.assertNotIn("X-Mizan-Invalidate", response)
class ContextFetchTests(TestCase):
"""Tests for the bundled context fetch endpoint (execute_context)."""
@@ -1383,6 +1421,30 @@ class TypeAnnotationTests(TestCase):
self.assertIsInstance(result, FunctionResult)
self.assertIsNone(result.data)
def test_list_basemodel_return_not_wrapped(self):
"""list[BaseModel] should reach the wire as a bare array, not {result: [...]}."""
class Item(BaseModel):
id: int
name: str
@client
def list_items(request: HttpRequest) -> list[Item]:
return [Item(id=1, name="a"), Item(id=2, name="b")]
register(list_items, "list_items")
factory = RequestFactory()
request = factory.get("/")
request.user = AnonymousUser()
result = execute_function(request, "list_items", {})
self.assertIsInstance(result, FunctionResult)
self.assertEqual(
result.data,
[{"id": 1, "name": "a"}, {"id": 2, "name": "b"}],
)
# =============================================================================
# RPC Mode Tests

View File

@@ -8,7 +8,7 @@ HTTP endpoints:
Security:
- Schema export is NOT exposed over HTTP to prevent API enumeration
- Use the management command instead: python manage.py export_mizan_schema
- Use the management command instead: python manage.py export_mizan_ir
"""
from django.http import JsonResponse

View File

@@ -108,37 +108,30 @@ anonymous request. The executor branches on those for `auth=True`,
## Generate the frontend
The codegen is `mizan-generate` (in `protocol/mizan-generate/`). Point a
config at your FastAPI app and run the CLI:
The codegen is the `mizan-generate` Rust binary (source at
`protocol/mizan-codegen/`; `protocol/mizan-generate/` is a thin npm
launcher that dispatches to the platform binary). Point a `mizan.toml` at
your FastAPI app and run the CLI:
```js
// frontend/fastapi.config.mjs
import path from "path"
import { fileURLToPath } from "url"
```toml
# frontend/mizan.toml
output = "src/api"
targets = ["react"]
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, "..")
export default {
source: {
fastapi: {
module: "main", // module to import for @client side effects
cwd: path.join(root, "backend"), // python cwd for module resolution
command: ["uv", "run", "python"], // optional — defaults to ["python"]
},
},
output: "src/api",
}
[source.fastapi]
module = "main" # module to import for @client side effects
cwd = "../backend" # python cwd for module resolution
command = ["uv", "run", "python"] # optional — defaults to ["python"]
```
```bash
npx mizan-generate --config fastapi.config.mjs
mizan-generate --config mizan.toml
```
The codegen drives `python -m mizan_fastapi.cli <module>` under the hood,
then emits Stage 1 (typed `callXxx`/`fetchXxx` over the runtime kernel) +
Stage 2 (`<MizanContext>` provider, per-context providers, `use{Hook}()`
hooks) into `src/api/`.
The codegen drives `python -m mizan_fastapi.ir <module>` under the hood,
parses the emitted KDL IR, then emits Stage 1 (typed `callXxx`/`fetchXxx`
over the runtime kernel) + Stage 2 (`<MizanContext>` provider, per-context
providers, `use{Hook}()` hooks) into `src/api/`.
```tsx
// app.tsx
@@ -171,13 +164,13 @@ uv run pytest
For codegen consumption (or any tooling that wants the Mizan schema):
```bash
python -m mizan_fastapi.cli <module>
python -m mizan_fastapi.ir <module>
```
Imports the named module (which must register every `@client` function as
import-time side effects), then prints the OpenAPI schema as JSON to stdout.
Mirrors mizan-django's `manage.py export_mizan_schema` so the codegen
consumes either backend the same subprocess way.
import-time side effects), then prints the Mizan KDL IR to stdout.
Mirrors mizan-django's `manage.py export_mizan_ir` so the codegen consumes
either backend the same subprocess way.
## Architecture

View File

@@ -1,6 +1,7 @@
[project]
name = "mizan-fastapi"
version = "0.1.0"
license = "Elastic-2.0"
description = "Mizan FastAPI backend adapter — HTTP RPC dispatch + context bundling, built on mizan-core."
requires-python = ">=3.10"
dependencies = [

View File

@@ -35,7 +35,6 @@ from .executor import (
execute_function,
)
from .router import router, mizan_exception_handler, mizan_validation_handler
from .schema import build_schema
__all__ = [
"router",
@@ -43,7 +42,6 @@ __all__ = [
"mizan_validation_handler",
"execute_function",
"compute_invalidation",
"build_schema",
"ErrorCode",
"MizanError",
"NotFound",

View File

@@ -1,45 +0,0 @@
"""
Schema-export CLI for codegen consumption.
Usage:
python -m mizan_fastapi.cli <module>
Imports the named module (whose import side effects must register every
@client function with mizan_core.registry — typically by `@client` plus
`register(...)` calls at module top level), then prints the OpenAPI
schema to stdout as JSON.
Mirrors mizan-django's `manage.py export_mizan_schema` so the codegen
CLI can fetch from either backend the same subprocess way.
"""
from __future__ import annotations
import importlib
import json
import sys
from .schema import build_schema
def main(argv: list[str] | None = None) -> int:
args = list(sys.argv[1:] if argv is None else argv)
if len(args) != 1:
print("usage: python -m mizan_fastapi.cli <module>", file=sys.stderr)
return 2
module_name = args[0]
try:
importlib.import_module(module_name)
except Exception as e:
print(f"failed to import {module_name!r}: {e}", file=sys.stderr)
return 1
schema = build_schema()
json.dump(schema, sys.stdout)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -12,9 +12,11 @@ from __future__ import annotations
from enum import Enum
from typing import Any
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel, ValidationError
from mizan_core.registry import get_function
from mizan_core.registry import get_context_groups, get_function
from mizan_core.type_utils import types_match_for_merge
# ─── Error taxonomy ─────────────────────────────────────────────────────────
@@ -148,15 +150,21 @@ def _resolve_function(fn_name: str) -> Any:
def _serialize(result: Any) -> Any:
return result.model_dump(mode="json") if isinstance(result, BaseModel) else result
# jsonable_encoder walks BaseModel / list / dict recursively, so list[BaseModel]
# (and nested shapes) come out wire-ready without a per-shape branch here.
return jsonable_encoder(result)
def execute_function(
async def execute_function(
request: Any,
fn_name: str,
input_data: dict[str, Any] | None = None,
) -> Any:
"""Dispatch a registered function. Returns the serialized result, or raises MizanError."""
"""Dispatch a registered function. Returns the serialized result, or raises MizanError.
Awaits `view.acall` — async handlers run on the loop, sync handlers run
in the default threadpool, both via the same entrypoint.
"""
view_class = _resolve_function(fn_name)
_enforce_auth(request, view_class._meta.get("auth"))
@@ -164,7 +172,7 @@ def execute_function(
validated = _validate_input(view.Input, input_data)
try:
result = view.call(validated)
result = await view.acall(validated)
except NotImplementedError as e:
raise NotImplementedYet(str(e) or "Not implemented") from e
except MizanError:
@@ -184,12 +192,70 @@ def compute_invalidation(view_class: Any, input_data: dict[str, Any] | None) ->
return [_invalidation_target(target, input_data or {}) for target in affects]
def compute_merges(view_class: Any, input_data: dict[str, Any] | None, result: Any) -> list[dict[str, Any]]:
"""Build the `merge` list from @client(merge=...) metadata.
Each entry is `{context, slot, value, params?}` where `slot` names the
function inside the context bundle the value lands in. The slot is
resolved server-side via `types_match_for_merge` so the kernel does
no shape inference — the server has the schema, type-checked routing
lives here. Entries whose slot can't be uniquely resolved are dropped
with a warning; the consumer falls back to refetch via `affects`.
"""
targets = getattr(view_class, "_meta", {}).get("merge") or []
if not targets:
return []
mutation_output = getattr(view_class, "Output", None)
out: list[dict[str, Any]] = []
for ctx_name in targets:
slot = _resolve_merge_slot(ctx_name, mutation_output)
if slot is None:
continue
entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result}
scoped = _scoped_params(ctx_name, input_data or {})
if scoped:
entry["params"] = scoped
out.append(entry)
return out
def _resolve_merge_slot(context_name: str, mutation_output: Any) -> str | None:
"""Find the unique function-name slot whose return type matches the mutation's output.
Returns None on no match or ambiguous match (multiple candidates).
"""
if mutation_output is None:
return None
matches: list[str] = []
for fn_name in get_context_groups().get(context_name, []):
fn_cls = get_function(fn_name)
if fn_cls is None:
continue
fn_output = getattr(fn_cls, "Output", None)
if fn_output is not None and types_match_for_merge(fn_output, mutation_output):
matches.append(fn_name)
return matches[0] if len(matches) == 1 else None
def _scoped_params(context_name: str, input_data: dict[str, Any]) -> dict[str, Any]:
"""Match input args against the context's declared Input field names."""
fn_names = get_context_groups().get(context_name, [])
declared: set[str] = set()
for fn_name in fn_names:
fn_cls = get_function(fn_name)
if fn_cls is None:
continue
input_cls = getattr(fn_cls, "Input", None)
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
declared.update(input_cls.model_fields.keys())
return {k: v for k, v in input_data.items() if k in declared}
def _invalidation_target(target: dict[str, Any], input_data: dict[str, Any]) -> Any:
match target.get("type"):
case "context":
name = target["name"]
scope_keys = (target.get("params") or {}).keys()
scoped = {k: input_data[k] for k in scope_keys if k in input_data}
scoped = _scoped_params(name, input_data)
return {"context": name, "params": scoped} if scoped else name
case "function":
return {"function": target["name"]}

View File

@@ -0,0 +1,39 @@
"""
Mizan IR (KDL) export CLI for FastAPI backends.
Usage:
python -m mizan_fastapi.ir <module>
Imports the named module (whose import side effects must register every
@client function with `mizan_core.registry`), then writes the canonical
Mizan IR as KDL to stdout. The Rust codegen binary consumes this
directly.
"""
from __future__ import annotations
import importlib
import sys
from mizan_core.ir import build_ir
def main(argv: list[str] | None = None) -> int:
args = list(sys.argv[1:] if argv is None else argv)
if len(args) != 1:
print("usage: python -m mizan_fastapi.ir <module>", file=sys.stderr)
return 2
module_name = args[0]
try:
importlib.import_module(module_name)
except Exception as e:
print(f"failed to import {module_name!r}: {e}", file=sys.stderr)
return 1
sys.stdout.write(build_ir())
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -28,6 +28,7 @@ from .executor import (
MizanError,
NotFound,
compute_invalidation,
compute_merges,
execute_function,
)
@@ -42,6 +43,17 @@ def _no_store(payload: Any, status_code: int = 200) -> JSONResponse:
# ─── Endpoints ──────────────────────────────────────────────────────────────
@router.get("/session/")
async def session_init() -> JSONResponse:
"""Session-init probe. Parity with mizan-django's session endpoint.
CSRF is a Django-only concern at the protocol level; FastAPI surfaces a
null token so the response shape stays uniform across backends. The
wire-parity harness uses this endpoint as its readiness probe.
"""
return _no_store({"csrfToken": None})
class CallBody(BaseModel):
fn: str = Field(..., min_length=1)
args: dict[str, Any] = Field(default_factory=dict)
@@ -49,10 +61,15 @@ class CallBody(BaseModel):
@router.post("/call/")
async def function_call(body: CallBody, request: Request) -> JSONResponse:
"""RPC dispatch — `{"fn": "...", "args": {...}}` → `{"result": ..., "invalidate": [...]}`."""
result = execute_function(request, body.fn, body.args)
invalidate = compute_invalidation(get_function(body.fn), body.args)
return _no_store({"result": result, "invalidate": invalidate})
"""RPC dispatch — `{"fn": "...", "args": {...}}` → `{"result": ..., "invalidate": [...], "merge"?: [...]}`."""
fn_class = get_function(body.fn)
result = await execute_function(request, body.fn, body.args)
invalidate = compute_invalidation(fn_class, body.args)
merges = compute_merges(fn_class, body.args, result)
payload: dict[str, Any] = {"result": result, "invalidate": invalidate}
if merges:
payload["merge"] = merges
return _no_store(payload)
@router.get("/ctx/{context_name}/")
@@ -63,7 +80,7 @@ async def context_fetch(context_name: str, request: Request) -> JSONResponse:
raise NotFound(f"Context '{context_name}' not found")
params = dict(request.query_params)
bundled = {fn: execute_function(request, fn, params) for fn in fn_names}
bundled = {fn: await execute_function(request, fn, params) for fn in fn_names}
return _no_store(bundled)

View File

@@ -1,209 +0,0 @@
"""
Mizan schema export for FastAPI backends.
Builds an OpenAPI 3.0 document from the registered Mizan functions, mirroring
the shape mizan-django emits via Django Ninja so the codegen consumes either
backend identically.
Usage:
from mizan_fastapi.schema import build_schema
schema = build_schema() # uses globally registered functions
"""
from __future__ import annotations
import re
from typing import Any
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from pydantic import BaseModel, create_model
from mizan_core.registry import get_all_functions, get_context_groups, get_function
__all__ = ["build_schema", "snake_to_camel"]
# Common user identity param names — mirrors mizan-django's _USER_SCOPED_PARAMS
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
def snake_to_camel(name: str) -> str:
"""Convert snake_case or dotted.name to camelCase. Mirrors mizan-django."""
components = re.split(r"[._]", name)
return components[0] + "".join(c.title() for c in components[1:])
def _has_input(input_cls: Any) -> bool:
return (
input_cls is not None
and input_cls is not BaseModel
and hasattr(input_cls, "model_fields")
and bool(input_cls.model_fields)
)
def _annotation_to_jsonschema_type(annotation: Any) -> str:
if annotation is int:
return "integer"
if annotation is float:
return "number"
if annotation is bool:
return "boolean"
return "string"
def _function_metadata(name: str, fn_class: Any) -> dict[str, Any]:
"""Build one entry of x-mizan-functions. Mirrors Django's shape exactly."""
camel = snake_to_camel(name)
meta = getattr(fn_class, "_meta", {})
input_cls = getattr(fn_class, "Input", None)
has_input = _has_input(input_cls)
entry: dict[str, Any] = {
"name": name,
"camelName": camel,
"hasInput": has_input,
"inputType": f"{camel}Input" if has_input else None,
"outputType": f"{camel}Output",
"transport": "websocket" if meta.get("websocket") else "http",
"isContext": meta.get("context", False),
# Form metadata — always emitted so the schema shape matches Django's,
# even for FastAPI projects that don't use forms (these stay False/None).
"isForm": meta.get("form", False),
"formName": meta.get("form_name"),
"formRole": meta.get("form_role"),
}
if meta.get("affects"):
entry["affects"] = meta["affects"]
return entry
def _context_metadata(context_groups: dict[str, list[str]]) -> dict[str, Any]:
"""Build x-mizan-contexts. Mirrors Django's param-elevation logic."""
out: dict[str, Any] = {}
for ctx_name, fn_names in context_groups.items():
param_info: dict[str, dict[str, Any]] = {}
for fn_name in fn_names:
fn_cls = get_function(fn_name)
if fn_cls is None:
continue
input_cls = getattr(fn_cls, "Input", None)
if not _has_input(input_cls):
continue
for field_name, field_info in input_cls.model_fields.items():
if field_name not in param_info:
param_info[field_name] = {
"type": _annotation_to_jsonschema_type(field_info.annotation),
"sharedBy": [],
}
param_info[field_name]["sharedBy"].append(fn_name)
# A param is required iff every function in the context declares it.
for p_meta in param_info.values():
p_meta["required"] = len(p_meta["sharedBy"]) == len(fn_names)
out[ctx_name] = {
"functions": list(fn_names),
"params": param_info,
}
return out
def build_schema() -> dict[str, Any]:
"""
Build an OpenAPI 3.0 schema for all registered Mizan functions.
Drives FastAPI's native OpenAPI generation by registering a stub endpoint
per function with the function's Input/Output Pydantic models, then
appends the protocol's `x-mizan-functions` and `x-mizan-contexts`
extensions.
Returns a dict in the same shape mizan-django's schema export emits, so
the same codegen pipeline consumes either.
"""
functions = get_all_functions()
context_groups = get_context_groups()
schema_app = FastAPI(
title="mizan Server Functions",
version="1.0.0",
description="Auto-generated schema for mizan server functions",
)
# Per-function endpoints + renamed Pydantic models so component names are
# camelCase + "Input"/"Output" rather than the user's original class names.
schema_classes: dict[str, type[BaseModel]] = {}
function_metadata: list[dict[str, Any]] = []
for name, fn_class in functions.items():
camel = snake_to_camel(name)
input_cls = getattr(fn_class, "Input", None)
output_cls = getattr(fn_class, "Output", None) or BaseModel
has_input = _has_input(input_cls)
input_type_name = f"{camel}Input" if has_input else None
output_type_name = f"{camel}Output"
if has_input:
schema_classes[input_type_name] = create_model(
input_type_name, __base__=input_cls,
)
schema_classes[output_type_name] = create_model(
output_type_name, __base__=output_cls,
)
# Stub endpoint — only exists so FastAPI walks Pydantic types into
# components.schemas. Never invoked. Annotations are set explicitly
# rather than via closures so forward-ref resolution doesn't trip on
# locally-bound type names.
if has_input:
async def stub(payload):
return None
stub.__annotations__ = {"payload": schema_classes[input_type_name]}
else:
async def stub():
return None
schema_app.post(
f"/mizan/{name}",
response_model=schema_classes[output_type_name],
operation_id=camel,
summary=fn_class.__doc__ or f"Call {name}",
)(stub)
function_metadata.append(_function_metadata(name, fn_class))
schema = get_openapi(
title=schema_app.title,
version=schema_app.version,
description=schema_app.description,
routes=schema_app.routes,
)
schema["x-mizan-functions"] = function_metadata
if context_groups:
schema["x-mizan-contexts"] = _context_metadata(context_groups)
# Attach x-mizan operation metadata, mirroring Django.
paths = schema.get("paths", {})
for fn_meta in function_metadata:
op = paths.get(f"/mizan/{fn_meta['name']}", {}).get("post")
if op is not None:
op["x-mizan"] = {
"transport": fn_meta["transport"],
"isContext": fn_meta["isContext"],
}
return schema

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
import asyncio
import pytest
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
@@ -34,6 +36,11 @@ class UserOutput(BaseModel):
authenticated: bool
class ItemOutput(BaseModel):
id: int
name: str
@pytest.fixture
def app():
"""Build a fresh FastAPI app + Mizan router with a few @client functions."""
@@ -63,12 +70,39 @@ def app():
def whoami(request) -> UserOutput:
return UserOutput(email="real@example.com", authenticated=True)
@client
def list_items(request) -> list[ItemOutput]:
return [ItemOutput(id=1, name="a"), ItemOutput(id=2, name="b")]
@client
def find_item(request, item_id: int) -> ItemOutput | None:
return ItemOutput(id=item_id, name="found") if item_id > 0 else None
@client(merge="items")
def set_item_name(request, id: int, name: str) -> ItemOutput:
return ItemOutput(id=id, name=name)
@client(context="items")
def items_list(request) -> list[ItemOutput]:
return [ItemOutput(id=1, name="orig")]
@client
async def async_echo(request, text: str) -> EchoOutput:
# await something on the loop to prove we're really running async
await asyncio.sleep(0)
return EchoOutput(message=f"async: {text}")
register(echo, "echo")
register(add, "add")
register(current_user, "current_user")
register(user_count, "user_count")
register(update_email, "update_email")
register(whoami, "whoami")
register(list_items, "list_items")
register(find_item, "find_item")
register(set_item_name, "set_item_name")
register(items_list, "items_list")
register(async_echo, "async_echo")
fastapi_app = FastAPI()
fastapi_app.include_router(mizan_router, prefix="/api/mizan")
@@ -171,3 +205,58 @@ class InvalidationTests:
body = r.json()
# affects='user' is a context-name string → invalidate list contains 'user'
assert "user" in body["invalidate"]
# ─── Structured-output shapes ───────────────────────────────────────────────
class StructuredOutputTests:
"""list[BaseModel] and Optional[BaseModel] should reach the wire as bare values, not {result: ...}."""
def test_list_of_basemodel_returns_bare_array(self, http):
r = http.post("/api/mizan/call/", json={"fn": "list_items", "args": {}})
assert r.status_code == 200
body = r.json()
assert body["result"] == [
{"id": 1, "name": "a"},
{"id": 2, "name": "b"},
]
def test_optional_basemodel_returns_inner_or_none(self, http):
r_found = http.post("/api/mizan/call/", json={"fn": "find_item", "args": {"item_id": 5}})
assert r_found.status_code == 200
assert r_found.json()["result"] == {"id": 5, "name": "found"}
r_missing = http.post("/api/mizan/call/", json={"fn": "find_item", "args": {"item_id": 0}})
assert r_missing.status_code == 200
assert r_missing.json()["result"] is None
# ─── Merge protocol ─────────────────────────────────────────────────────────
class AsyncHandlerTests:
"""`async def` handlers dispatch on the loop via view.acall."""
def test_async_handler_returns_awaited_result(self, http):
r = http.post("/api/mizan/call/", json={"fn": "async_echo", "args": {"text": "hello"}})
assert r.status_code == 200
assert r.json()["result"] == {"message": "async: hello"}
class MergeTests:
"""@client(merge=...) emits a `merge` field in the response so the kernel can splice without refetch."""
def test_merge_target_emits_merge_entry(self, http):
r = http.post(
"/api/mizan/call/",
json={"fn": "set_item_name", "args": {"id": 42, "name": "renamed"}},
)
assert r.status_code == 200
body = r.json()
# Server resolves slot — items_list returns list[ItemOutput], mutation returns ItemOutput
assert body["merge"] == [
{"context": "items", "slot": "items_list", "value": {"id": 42, "name": "renamed"}}
]
# invalidate stays empty when only merge is declared
assert body["invalidate"] == []

591
backends/mizan-rust-axum/Cargo.lock generated Normal file
View File

@@ -0,0 +1,591 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "linkme"
version = "0.3.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf"
dependencies = [
"linkme-impl",
]
[[package]]
name = "linkme-impl"
version = "0.3.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mio"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "mizan-axum"
version = "0.1.0"
dependencies = [
"axum",
"mizan-core",
"serde",
"serde_json",
"tokio",
"tower",
"tower-http",
]
[[package]]
name = "mizan-core"
version = "0.1.0"
dependencies = [
"async-trait",
"linkme",
"mizan-macros",
"serde",
"serde_json",
]
[[package]]
name = "mizan-macros"
version = "0.1.0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "tokio"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
dependencies = [
"bitflags",
"bytes",
"http",
"http-body",
"pin-project-lite",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View File

@@ -0,0 +1,15 @@
[package]
name = "mizan-axum"
version = "0.1.0"
edition = "2021"
description = "axum HTTP adapter for Mizan — typed RPC dispatch + context-bundle fetch on top of mizan-core's compile-time function registry."
license = "Elastic-2.0"
[dependencies]
mizan-core = { path = "../../cores/mizan-rust" }
axum = "0.7"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower = "0.5"
tower-http = { version = "0.6", features = ["trace"] }

View File

@@ -0,0 +1,27 @@
//! Convert `MizanError` into axum's `Response`. Mirrors mizan-fastapi's
//! envelope: `{"error": {"code": "...", "message": "...", "details": ...}}`
//! with a Cache-Control: no-store header.
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use mizan_core::MizanError;
pub struct ApiError(pub MizanError);
impl From<MizanError> for ApiError {
fn from(e: MizanError) -> Self {
Self(e)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = StatusCode::from_u16(self.0.http_status())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let mut resp = (status, Json(self.0.to_json())).into_response();
resp.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
resp
}
}

View File

@@ -0,0 +1,162 @@
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`.
use axum::extract::{Path, Query, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use mizan_core::{
compute_invalidation, compute_merges, lookup_function, lookup_context, FunctionSpec,
InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::any::Any;
use std::collections::BTreeMap;
use std::sync::Arc;
use crate::errors::ApiError;
/// Type-erased application state threaded into every `dispatch()` call via
/// `RequestHandle`. User handlers downcast to their concrete state type.
/// `Arc` keeps the clone cheap across per-request handler invocations.
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
/// Body for POST /call/. Matches the Python `CallBody` shape.
#[derive(Debug, Deserialize)]
pub struct CallBody {
pub fn_: Option<String>,
#[serde(rename = "fn")]
pub function_name: Option<String>,
#[serde(default)]
pub args: Map<String, Value>,
}
impl CallBody {
fn resolved_name(&self) -> Option<&str> {
self.function_name
.as_deref()
.or(self.fn_.as_deref())
}
}
#[derive(Debug, Serialize)]
pub struct CallResponse {
pub result: Value,
pub invalidate: Vec<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub merge: Option<Vec<Value>>,
}
fn no_store(json: Value) -> Response {
let mut resp = (StatusCode::OK, Json(json)).into_response();
resp.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
resp
}
/// POST /call/ — RPC dispatch.
pub async fn function_call(
State(app_state): State<AppStateAny>,
Json(body): Json<CallBody>,
) -> Result<Response, ApiError> {
let fn_name = body
.resolved_name()
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
.to_string();
let fn_spec = lookup_function(&fn_name)
.ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?;
let req = RequestHandle::from_dyn(app_state.as_ref());
let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?;
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
.iter()
.map(InvalidationTarget::to_json)
.collect();
let merges = compute_merges(fn_spec, &body.args, &result);
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
None
} else {
Some(merges.iter().map(MergeEntry::to_json).collect())
};
let payload = CallResponse {
result,
invalidate,
merge: merge_payload,
};
Ok(no_store(serde_json::to_value(&payload).unwrap()))
}
/// GET /ctx/:context_name/ — bundled context fetch.
pub async fn context_fetch(
State(app_state): State<AppStateAny>,
Path(context_name): Path<String>,
Query(params): Query<BTreeMap<String, String>>,
) -> Result<Response, ApiError> {
if lookup_context(&context_name).is_none() {
return Err(ApiError(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(ApiError(MizanError::NotFound(format!(
"context {context_name:?} has no registered members"
))));
}
// Convert query params (all-string values) to the JSON arg map. Numeric
// params get parsed via the per-function input_params primitive table.
let mut bundled = Map::new();
for fn_spec in &members {
let args = coerce_query_args(*fn_spec, &params);
let req = RequestHandle::from_dyn(app_state.as_ref());
let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?;
bundled.insert(fn_spec.name().to_string(), result);
}
Ok(no_store(Value::Object(bundled)))
}
/// Coerce string-valued query params into typed JSON values using the
/// function's declared input_params. Strings that don't parse stay as
/// strings — the dispatch wrapper will raise ValidationFailed downstream.
fn coerce_query_args(
fn_spec: &dyn FunctionSpec,
params: &BTreeMap<String, String>,
) -> Map<String, Value> {
let mut out = Map::new();
for ip in fn_spec.input_params() {
if let Some(raw) = params.get(ip.name) {
let parsed = match ip.primitive {
mizan_core::Primitive::Integer => raw.parse::<i64>().ok().map(Value::from),
mizan_core::Primitive::Number => raw.parse::<f64>().ok().and_then(|v| {
serde_json::Number::from_f64(v).map(Value::Number)
}),
mizan_core::Primitive::Boolean => raw.parse::<bool>().ok().map(Value::from),
mizan_core::Primitive::String => Some(Value::from(raw.clone())),
};
if let Some(v) = parsed {
out.insert(ip.name.into(), v);
} else {
out.insert(ip.name.into(), Value::from(raw.clone()));
}
}
}
out
}
/// GET /session/ — placeholder for the Mizan-protocol session-init endpoint.
/// CSRF is a Django-only concern; the Rust adapter returns a null token so
/// readiness-probe consumers see a well-formed response.
pub async fn session_init() -> Response {
let body = serde_json::json!({ "csrfToken": null });
no_store(body)
}

View File

@@ -0,0 +1,58 @@
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry.
//!
//! Usage:
//! ```ignore
//! use axum::Router;
//! use mizan_axum::router;
//!
//! #[tokio::main]
//! async fn main() {
//! let app = Router::new().nest("/api/mizan", router());
//! let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap();
//! axum::serve(listener, app).await.unwrap();
//! }
//! ```
//!
//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`):
//! * `GET /session/` — session-init probe (placeholder CSRF token)
//! * `POST /call/` — RPC dispatch with invalidate+merge response
//! * `GET /ctx/:name/` — bundled context fetch
mod errors;
mod handlers;
pub use errors::ApiError;
pub use handlers::{
context_fetch, function_call, session_init, AppStateAny, CallBody, CallResponse,
};
use axum::routing::{get, post};
use axum::Router;
use std::any::Any;
use std::sync::Arc;
/// Build the Mizan router with user-supplied app state. The state is
/// type-erased into an `Arc<dyn Any + Send + Sync>` and threaded into every
/// dispatch via `RequestHandle`. Handlers downcast to their concrete state
/// type.
///
/// Mount under a prefix:
/// `Router::new().nest("/api/mizan", router(my_state))`.
pub fn router<S>(state: S) -> Router
where
S: Any + Send + Sync + 'static,
{
let state: AppStateAny = Arc::new(state);
Router::new()
.route("/session/", get(handlers::session_init))
.route("/call/", post(handlers::function_call))
.route("/ctx/:context_name/", get(handlers::context_fetch))
.with_state(state)
}
/// Router variant for callers that have no app state to thread — the
/// dispatch path receives a unit-typed handle. Used by the AFI fixture
/// and other stateless test apps.
pub fn router_stateless() -> Router {
router(())
}

4621
backends/mizan-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View 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 = "Elastic-2.0"
[dependencies]
mizan-core = { path = "../../cores/mizan-rust" }
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -0,0 +1,290 @@
# mizan-tauri
Tauri backend adapter for the Mizan protocol. One plugin call on the Rust
side. `#[mizan::client]` on async functions. Typed React client generated.
Invalidation automatic — same protocol surface as mizan-fastapi /
mizan-django / mizan-rust-axum, routed through Tauri's IPC instead of HTTP.
## Scope
mizan-tauri targets the **AFI-common subset** — RPC dispatch, context
bundling, server-driven invalidation/merge. The transport channel is
Tauri's `invoke()`; the dispatch table is the linkme-backed `FUNCTIONS`
slice from `mizan-core`. No HTTP server is involved — the Tauri runtime
handles message framing, the plugin handles dispatch.
Forms / SSR / Channels are out of scope (those are Django-side primitives).
Tauri apps using mizan-tauri get RPC + context bundling + invalidation,
nothing more.
## Install
```toml
# src-tauri/Cargo.toml
[dependencies]
tauri = "2"
mizan-core = { path = "../../mizan/cores/mizan-rust" }
mizan-tauri = { path = "../../mizan/backends/mizan-tauri" }
serde = { version = "1", features = ["derive"] }
```
```jsonc
// package.json
{
"dependencies": {
"@mizan/base": "file:../mizan/frontends/mizan-base",
"@mizan/tauri-transport": "file:../mizan/frontends/mizan-tauri-transport",
"@tauri-apps/api": "^2"
}
}
```
## Setup — Rust
Install the plugin on the Tauri builder. The plugin registers a single
command (`plugin:mizan|mizan_invoke`) that routes call/fetch envelopes
through the function registry. No per-function `#[tauri::command]` is
needed; the macro-emitted FunctionSpec IS the dispatch table.
```rust
// src-tauri/src/lib.rs
mod commands;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(mizan_tauri::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
```
`commands` must be reachable from the binary's link graph — `mod
commands;` works (private mod stays linked because `lib.rs` references
it through file inclusion). If a separate binary (e.g. the IR-export
bin below) also needs to see the registrations, mark it `pub mod
commands;` so the integration-test / sibling-binary path can force-link.
## Define server functions
```rust
// src-tauri/src/commands.rs
use mizan_core::{self as mizan, MizanError, RequestHandle};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, mizan_core::Mizan)]
pub struct Greeting {
pub message: String,
}
#[mizan::client]
pub async fn greet(_req: &RequestHandle<'_>, name: String) -> Greeting {
Greeting { message: format!("hello, {name}") }
}
// Result<T, MizanError> is supported when the function can fail; the
// dispatch wrapper `?`-unwraps it so server-side errors surface as the
// protocol's standard {code, message, details?} envelope.
#[mizan::client]
pub async fn read_file(
_req: &RequestHandle<'_>,
path: String,
) -> Result<Greeting, MizanError> {
let body = std::fs::read_to_string(&path)
.map_err(|e| MizanError::NotFound(e.to_string()))?;
Ok(Greeting { message: body })
}
```
`#[mizan::client]` parameters mirror the other backends — `context = …`,
`affects = …`, `merge = …`, `private`. See `mizan-rust-axum`'s README for
the full set.
### App-state access
The first parameter is `req: &RequestHandle<'_>` — the same handle the
HTTP adapter threads through. Inside a Tauri-mounted plugin, the handle
wraps `tauri::AppHandle`, so user functions can downcast for access to
Tauri's managed-state container or event emission:
```rust
#[mizan::client]
pub async fn store_value(req: &RequestHandle<'_>, key: String) -> Greeting {
let app = req.downcast::<tauri::AppHandle>()
.expect("Tauri AppHandle threaded by mizan-tauri");
// app.state::<MyState>(), app.emit(...), etc.
Greeting { message: format!("stored {key}") }
}
```
Stateless functions ignore the handle (`_req: &RequestHandle<'_>`).
## IR export binary
mizan-generate needs the consumer crate's IR. Add a small bin that
references each `#[mizan::client]` function (so linkme keeps the
distributed slice's entries) and prints `mizan_core::build_ir()`:
```rust
// src-tauri/src/bin/emit_mizan_ir.rs
//
// Cargo.toml adds:
// [[bin]]
// name = "emit-mizan-ir"
// path = "src/bin/emit_mizan_ir.rs"
//
// linkme only collects from translation units that survive
// dead-code elimination; this fn names one item per file carrying
// #[derive(Mizan)] / #[mizan::client] registrations so the linker
// keeps them in the final binary.
#[allow(dead_code)]
fn _force_link() {
use my_app_lib::commands;
let _ = commands::greet;
let _ = commands::read_file;
// ... one per #[mizan::client] function
}
fn main() {
_force_link();
print!("{}", mizan_core::build_ir());
}
```
## Generate the frontend
```toml
# mizan.toml at the project root
project_id = "my-tauri-app"
output = "src/api"
targets = ["react"]
[source.rust]
manifest_path = "src-tauri/Cargo.toml"
bin = "emit-mizan-ir"
# Optional — author the Rust types from Pydantic models via decoru.
# Omit this block for pure-Rust usage.
[source.rust.pydantic]
module = "my_app.schema"
output = "src-tauri/src/schema.rs"
command = ["uv", "run", "python"] # any python with `decoru` importable
header = """\
// AUTO-GENERATED by mizan-generate (source.rust.pydantic step).
// Source of truth: my_app/schema.py.
// DO NOT EDIT BY HAND. Regenerate with: `mizan-generate`
use serde::{Deserialize, Serialize};
"""
```
```bash
mizan-generate --config mizan.toml
```
The Pydantic pre-step auto-discovers `BaseModel` subclasses AND `Enum`
subclasses declared in the named module; decoru emits the structs, and a
small inline emitter renders enums (PascalCase variants from Python
member names, `#[serde(rename_all = "snake_case")]`, `#[default]` on the
last variant so decoru's `impl Default` keeps compiling).
The Rust step then runs `cargo run --bin emit-mizan-ir`, parses the
emitted KDL, and dispatches the configured `targets` to their emitters
(`stage1` → typed `callXxx`/`fetchXxx`; `react``<MizanContext>` +
per-context providers + `use{Hook}()` hooks).
## Setup — TS
```tsx
// src/main.tsx
import { configure } from "@mizan/base";
import { tauriTransport } from "@mizan/tauri-transport";
// Route every mizanCall / mizanFetch through Tauri's IPC. Must run
// before any generated callXxx() executes — top-level at the module
// entry is the safe place.
configure({ transport: tauriTransport() });
```
```tsx
// any component
import { callGreet } from "@/api";
const greeting = await callGreet({ name: "world" });
console.log(greeting.message);
```
For framework hooks generated by Stage 2 (`useGreet()` etc., wrapping the
imperative `callGreet` with `isPending`/`error` state), wrap your tree
with `<MizanContext>` at the root — same as the HTTP-transport setup. The
generated provider is transport-agnostic; it reads from `config.transport`
the kernel is using.
### tsconfig / vite preserve symlinks
The `@mizan/*` packages are typically linked via `file:` in package.json.
Without `preserveSymlinks`, both TypeScript and Vite/Rollup follow the
symlinks to their real location and fail to resolve the linked packages'
peer dependencies (`@tauri-apps/api`, `@mizan/base`) from there.
```jsonc
// tsconfig.json
{
"compilerOptions": {
"moduleResolution": "bundler",
"preserveSymlinks": true,
// …
}
}
```
```ts
// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
resolve: {
preserveSymlinks: true,
},
// …
});
```
## Wire protocol
Same envelope as the HTTP adapter, wrapped in a Tauri invoke payload:
```ts
// call
invoke('plugin:mizan|mizan_invoke', {
envelope: { op: 'call', fn: 'greet', args: { name: 'world' } }
})
// → { result: { message: "hello, world" }, invalidate: [], merge?: [...] }
// fetch (context bundling)
invoke('plugin:mizan|mizan_invoke', {
envelope: { op: 'fetch', context: 'user', params: { user_id: 42 } }
})
// → { user_profile: {...}, user_orders: [...] } (flat bundle)
```
Errors flow through Tauri's `Promise.reject` path; `@mizan/tauri-transport`
re-wraps them into the same `MizanError` shape the HTTP transport
produces, so consumer code is identical regardless of transport.
## Reference application
`claude-manage` is the production reference — Tauri + React + Pydantic
schema + Mizan RPC. See `~/dev/claude-manage/mizan.toml` and
`~/dev/claude-manage/src-tauri/src/commands.rs` for a full migrated app.
## Architecture
mizan-tauri shares `cores/mizan-rust` with `mizan-rust-axum`. Both
adapters dispatch through the same compile-time `FUNCTIONS` registry,
same `compute_invalidation` / `compute_merges` logic, same KDL IR
emitted by `build_ir()`. The only difference is the wire surface — axum
takes POST `/call/` and GET `/ctx/:name/`, mizan-tauri takes a single
`mizan_invoke` command with an op-tagged envelope.

View 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, &params);
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
}

View File

@@ -10,5 +10,5 @@
"devDependencies": {
"bun-types": "latest"
},
"license": "MIT"
"license": "Elastic-2.0"
}

View File

@@ -52,10 +52,6 @@ function extractParams(fn: Function): ParamDef[] {
})
}
function isResponseReturn(result: any): boolean {
return result instanceof Response
}
/**
* Function wrapper — registers a standalone function.
*

View File

@@ -22,10 +22,6 @@ export interface MizanResponse {
headers: Record<string, string>
}
function sortedStringify(data: any): string {
return JSON.stringify(data, Object.keys(data).sort())
}
/**
* Handle GET /api/mizan/ctx/:contextName/
*

View File

@@ -1,6 +1,7 @@
[project]
name = "mizan-core"
version = "0.1.0"
license = "Elastic-2.0"
description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-agnostic primitives shared by every Python backend adapter."
requires-python = ">=3.10"
dependencies = [

View File

@@ -19,6 +19,7 @@ Two styles supported:
from __future__ import annotations
import asyncio
import inspect
import warnings
from abc import ABC, abstractmethod
@@ -165,6 +166,16 @@ class ServerFunction(ABC, Generic[TInput, TOutput]):
"""
raise NotImplementedError(f"{self.__class__.__name__} must implement call()")
async def acall(self, input: TInput) -> TOutput:
"""
Async entrypoint for dispatch on event-loop-driven adapters (FastAPI).
Default: run the sync `call` in a threadpool so it doesn't block the
loop. Subclasses with native-async handlers override this to await
the handler directly.
"""
return await asyncio.to_thread(self.call, input)
@classmethod
def get_schema_export(cls) -> dict[str, Any]:
"""Export schema for TypeScript generation."""
@@ -207,17 +218,30 @@ class _FunctionWrapper(ServerFunction):
def call(self, input):
"""Execute the wrapped function, unpacking input into individual args."""
if input is not None and self._param_names:
# Unpack validated model into keyword arguments
kwargs = {name: getattr(input, name) for name in self._param_names}
result = self._wrapped_fn(self.request, **kwargs)
else:
result = self._wrapped_fn(self.request)
return self._postprocess(self._invoke_sync(input))
async def acall(self, input):
"""Async dispatch. Awaits async handlers directly; sync handlers run
in a threadpool via the super's default `acall`."""
if not inspect.iscoroutinefunction(self._wrapped_fn):
return await super().acall(input)
if input is not None and self._param_names:
kwargs = {name: getattr(input, name) for name in self._param_names}
result = await self._wrapped_fn(self.request, **kwargs)
else:
result = await self._wrapped_fn(self.request)
return self._postprocess(result)
def _invoke_sync(self, input):
if input is not None and self._param_names:
kwargs = {name: getattr(input, name) for name in self._param_names}
return self._wrapped_fn(self.request, **kwargs)
return self._wrapped_fn(self.request)
def _postprocess(self, result):
# View path — return a framework-native response directly (no serialization)
if is_framework_response(result):
return result
# Wrap primitive returns in the generated output model
if self._is_primitive_output:
return self._output_cls(result=result)
@@ -276,12 +300,18 @@ def _resolve_context(context: ContextMode) -> str | Literal[False]:
AffectsTarget = ReactContext | str | type["ServerFunction"]
AffectsMode = AffectsTarget | list[AffectsTarget] | None
# Merge parameter type — targets a context by name, kernel splices the return
# value into the cached entry rather than triggering a refetch.
MergeTarget = ReactContext | str
MergeMode = MergeTarget | list[MergeTarget] | None
def client(
fn: Callable = None,
*,
context: ContextMode = False,
affects: AffectsMode = None,
merge: MergeMode = None,
private: bool = False,
route: str | None = None,
methods: list[str] | None = None,
@@ -303,6 +333,12 @@ def client(
Mutually exclusive with context=.
Scoping is automatic via argument name matching.
merge: Declare which contexts the mutation's return value merges into.
The kernel splices the return value into the cached entry rather
than triggering a refetch — fits high-frequency UI (slider drags,
color pickers) where the server already knows the exact change.
Mutually exclusive with context=. Composes with affects=.
private: If True, the function is not client-callable.
- Not exposed as an RPC endpoint
- No generated TypeScript
@@ -352,6 +388,13 @@ def client(
"A function cannot be both a context reader and a mutation."
)
# Validate merge parameter
if merge is not None and resolved_context is not False:
raise ValueError(
"context= and merge= are mutually exclusive. "
"A function cannot be both a context reader and a mutation."
)
# Validate auth parameter
if auth is not None:
if isinstance(auth, str) and auth not in _VALID_AUTH_STRINGS:
@@ -362,7 +405,7 @@ def client(
def decorator(fn: Callable) -> type[ServerFunction]:
return _create_server_function(
fn, context=resolved_context, affects=affects,
fn, context=resolved_context, affects=affects, merge=merge,
private=private, route=route, methods=methods,
websocket=websocket, auth=auth, rev=rev, cache=cache,
)
@@ -370,13 +413,32 @@ def client(
# Support both @client and @client(...)
if fn is not None:
return _create_server_function(
fn, context=resolved_context, affects=affects,
fn, context=resolved_context, affects=affects, merge=merge,
private=private, route=route, methods=methods,
websocket=websocket, auth=auth, rev=rev, cache=cache,
)
return decorator
def _normalize_merge(merge: MergeMode) -> list[str] | None:
"""Normalize merge param to a list of context name strings."""
if merge is None:
return None
items = merge if isinstance(merge, list) else [merge]
result: list[str] = []
for item in items:
if isinstance(item, ReactContext):
result.append(item.name)
elif isinstance(item, str):
result.append(item)
else:
raise ValueError(
f"merge items must be ReactContext instances or context name strings. "
f"Got {type(item).__name__}."
)
return result
def _normalize_affects(affects: AffectsMode) -> list[dict[str, str]] | None:
"""Normalize the affects parameter into a list of target descriptors."""
if affects is None:
@@ -410,6 +472,7 @@ def _create_server_function(
*,
context: str | Literal[False] = False,
affects: str | type["ServerFunction"] | list[str | type["ServerFunction"]] | None = None,
merge: MergeMode = None,
private: bool = False,
route: str | None = None,
methods: list[str] | None = None,
@@ -468,25 +531,9 @@ def _create_server_function(
is_primitive_output = False
else:
# RPC path — resolve output type
import types
from mizan_core.type_utils import is_structured_output
def is_basemodel_type(t: Any) -> bool:
"""Check if type is a BaseModel subclass, handling Optional/Union."""
if isinstance(t, type) and issubclass(t, BaseModel):
return True
origin = get_origin(t)
if origin is Union or isinstance(t, types.UnionType):
args = get_args(t)
for arg in args:
if (
arg is not type(None)
and isinstance(arg, type)
and issubclass(arg, BaseModel)
):
return True
return False
if is_basemodel_type(output_type):
if is_structured_output(output_type):
output_cls = output_type
is_primitive_output = False
else:
@@ -538,6 +585,11 @@ def _create_server_function(
if normalized_affects:
meta["affects"] = normalized_affects
# Merge: contexts to splice the return value into (vs refetch)
normalized_merge = _normalize_merge(merge)
if normalized_merge:
meta["merge"] = normalized_merge
# WebSocket: enable WebSocket transport
if websocket:
meta["websocket"] = True

View File

@@ -0,0 +1,582 @@
"""
Mizan IR — KDL emission from the live `mizan_core.registry`.
`build_ir()` walks every registered function class, introspects its
Pydantic Input/Output models directly (not via JSON-Schema), and emits
KDL — the canonical Mizan protocol IR. Every backend adapter exposes
this via a backend-specific entry point (Django management command,
FastAPI CLI, mizan-ts equivalent); every codegen target consumes this.
KDL grammar — locked contract:
type "<Name>" {
struct {
field "<name>" required=#true|#false default=<lit> {
primitive "integer|number|boolean|string"
| ref "<TypeName>"
| list { <type-child> }
| optional { <type-child> }
| enum "<v1>" "<v2>" ...
}
...
}
| list { <type-child> }
| enum "<v1>" "<v2>" ...
| alias { <type-child> }
}
function "<wire_name>" {
camel "<camelCase>"
has-input #true|#false
input "<TypeName>" // omitted if has-input=#false
output "<TypeName>"
output-nullable #true|#false // omitted when #false (default)
transport "http"|"websocket"|"both"
context "<ctx_name>" // omitted unless context-grouped
affects "<ctx_name>" // 0..N occurrences
merge "<ctx_name>" // 0..N occurrences
is-form #true // omitted when #false (default)
form-name "<name>"
form-role "<role>"
}
context "<name>" {
function "<fn_name>"
...
param "<param_name>" {
type "integer|number|boolean|string"
required #true|#false
shared-by "<fn_name>"
...
}
}
channel "<name>" {
pascal-name "<PascalCase>"
params "<TypeName>" // omitted if no params
react-message "<TypeName>" // omitted if no react message
django-message "<TypeName>" // omitted if no django message
}
Nothing else lives in the IR. OpenAPI envelope, JSON-Schema $ref dance,
the Pydantic→json-schema converter — all gone.
"""
from __future__ import annotations
import types
from typing import Any, Literal, Union, get_args, get_origin
from pydantic import BaseModel
from pydantic_core import PydanticUndefined
from mizan_core.registry import get_all_functions, get_context_groups, get_function
from mizan_core.type_utils import extract_list_element, extract_optional
__all__ = ["build_ir"]
# Common user-identity param names; mirrors the equivalent in mizan-django /
# mizan-fastapi schema-export logic.
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
# ─── KDL value formatting ───────────────────────────────────────────────────
def _kdl_string(s: str) -> str:
"""KDL-escape a string and wrap in quotes."""
escaped = (
s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
return f'"{escaped}"'
def _kdl_bool(b: bool) -> str:
return "#true" if b else "#false"
def _kdl_value(v: Any) -> str:
"""Render a JSON-shape Python value as a KDL literal."""
if v is None:
return "#null"
if v is True or v is False:
return _kdl_bool(v)
if isinstance(v, (int, float)):
return repr(v)
if isinstance(v, str):
return _kdl_string(v)
# Fallback for compound values — defaults aren't typed in our IR.
import json
return _kdl_string(json.dumps(v))
# ─── KDL Builder ────────────────────────────────────────────────────────────
class _Block:
"""Open-children context for a KDL node. Tracks indent level."""
__slots__ = ("lines", "indent")
def __init__(self, lines: list[str], indent: int):
self.lines = lines
self.indent = indent
def _prefix(self) -> str:
return " " * self.indent
def node(self, name: str, *args: str, **props: str) -> "_OpenNode":
"""Open a node. `args` are positional KDL args; `props` are key=value pairs."""
return _OpenNode(self.lines, self.indent, name, list(args), dict(props))
def leaf(self, name: str, *args: str, **props: str) -> None:
"""Emit a leaf node — no children block."""
parts = [name]
parts.extend(args)
for k, v in props.items():
parts.append(f"{k}={v}")
self.lines.append(f"{self._prefix()}{' '.join(parts)}")
class _OpenNode:
"""A KDL node whose children are being built."""
def __init__(
self,
lines: list[str],
indent: int,
name: str,
args: list[str],
props: dict[str, str],
):
self.lines = lines
self.indent = indent
self.name = name
self.args = args
self.props = props
self._children_emitted = False
def __enter__(self) -> _Block:
parts = [self.name]
parts.extend(self.args)
for k, v in self.props.items():
parts.append(f"{k}={v}")
self.lines.append(f"{' ' * self.indent}{' '.join(parts)} {{")
self._children_emitted = True
return _Block(self.lines, self.indent + 1)
def __exit__(self, *_exc: Any) -> None:
if self._children_emitted:
self.lines.append(f"{' ' * self.indent}}}")
# ─── Type emission ──────────────────────────────────────────────────────────
def _emit_type_child(block: _Block, annotation: Any, named_types: dict[str, Any]) -> None:
"""Emit the type-shape KDL for a Python annotation, recursing as needed."""
# Strip Optional[T] → emit `optional` wrapper.
inner, is_opt = extract_optional(annotation)
if is_opt:
with block.node("optional") as inner_block:
_emit_type_child(inner_block, inner, named_types)
return
# Multi-arm union (T | U) — emit `union { <each-branch> }`.
origin = get_origin(annotation)
if origin is Union or isinstance(annotation, types.UnionType):
branches = [a for a in get_args(annotation) if a is not type(None)]
if len(branches) > 1:
with block.node("union") as inner_block:
for branch in branches:
_emit_type_child(inner_block, branch, named_types)
return
# list[T] / tuple[T, ...] / set[T] / frozenset[T] → `list { ... }`
elem = extract_list_element(annotation)
if elem is not None:
with block.node("list") as inner_block:
_emit_type_child(inner_block, elem, named_types)
return
# Literal[a, b, c] → enum
if origin is Literal:
args = get_args(annotation)
if all(isinstance(a, str) for a in args):
quoted = " ".join(_kdl_string(a) for a in args)
block.lines.append(f"{block._prefix()}enum {quoted}")
return
# Pydantic model → reference by name.
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
type_name = annotation.__name__
named_types.setdefault(type_name, _StructShape(annotation))
block.leaf("ref", _kdl_string(type_name))
return
# Primitives
if annotation is int:
block.leaf("primitive", _kdl_string("integer"))
return
if annotation is float:
block.leaf("primitive", _kdl_string("number"))
return
if annotation is bool:
block.leaf("primitive", _kdl_string("boolean"))
return
if annotation is str:
block.leaf("primitive", _kdl_string("string"))
return
# Open-shape fallback (dict / Any / etc).
block.leaf("primitive", _kdl_string("string"))
def _emit_alias_type(block: _Block, annotation: Any, named_types: dict[str, Any]) -> None:
"""Emit `type "X" { alias { <type-child> } }` for a non-struct wrapper."""
with block.node("alias") as alias_block:
_emit_type_child(alias_block, annotation, named_types)
def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[str, Any]) -> None:
"""Emit a `struct { field ... }` block for a Pydantic model."""
with block.node("struct") as struct_block:
for field_name, field_info in model.model_fields.items():
props: dict[str, str] = {}
# `field_info.is_required()` checks both the explicit Required
# marker and the presence of a default.
required = field_info.is_required()
if not required:
props["required"] = _kdl_bool(False)
default = field_info.default
if default is not None and default is not PydanticUndefined and default is not ...:
props["default"] = _kdl_value(default)
with struct_block.node("field", _kdl_string(field_name), **props) as field_block:
_emit_type_child(field_block, field_info.annotation, named_types)
class _StructShape:
"""A Pydantic BaseModel that emits as `type "X" { struct { ... } }`."""
__slots__ = ("model",)
def __init__(self, model: type[BaseModel]):
self.model = model
class _AliasShape:
"""A named alias wrapper — e.g. `<CamelName>Output = list[<Inner>]`."""
__slots__ = ("annotation",)
def __init__(self, annotation: Any):
self.annotation = annotation
def _collect_named_types(functions: dict[str, Any]) -> dict[str, Any]:
"""First pass: collect every named type the IR's `function` section references.
Two kinds:
- Pydantic BaseModels seen anywhere in Input/Output traversal — emit
as `type "X" { struct { ... } }`.
- Function-output wrapper aliases (`<CamelName>Output = list[T]` /
`<CamelName>Output = T | None`) — emit as `type "X" { alias { ... } }`
so the consumer has a single named type to reference.
"""
seen: dict[str, Any] = {}
def visit_model(model: type[BaseModel]) -> None:
if model.__name__ in seen:
return
seen[model.__name__] = _StructShape(model)
for field_info in model.model_fields.values():
for nested in _nested_models(field_info.annotation):
visit_model(nested)
def visit_annotation(ann: Any) -> None:
for nested in _nested_models(ann):
visit_model(nested)
for fn_class in functions.values():
input_cls = getattr(fn_class, "Input", None)
if _has_input(input_cls):
input_named = _name_input_model(fn_class)
visit_model(input_named)
output_cls = getattr(fn_class, "Output", None)
if output_cls is None:
continue
camel = _snake_to_camel(fn_class.name)
output_name = f"{camel}Output"
inner, _ = extract_optional(output_cls)
elem = extract_list_element(inner)
if elem is not None:
# `list[T]` (possibly wrapped in Optional) — emit a list alias.
# Visit the element type so its struct shape gets emitted too.
visit_annotation(output_cls)
if output_name not in seen:
seen[output_name] = _AliasShape(output_cls)
elif isinstance(inner, type) and issubclass(inner, BaseModel):
# `<Model>` or `Optional[<Model>]` — emit the model under the
# canonical name (rename if necessary).
output_named = _name_output_model(fn_class, inner)
visit_model(output_named)
# If the Optional wrapper differs from the bare model, emit an
# alias under the canonical output name too.
if output_named.__name__ != output_name:
seen.setdefault(output_name, _AliasShape(output_cls))
else:
# Primitive-wrapped output (`result: int`) — emit as alias.
seen.setdefault(output_name, _AliasShape(output_cls))
return seen
def _nested_models(annotation: Any) -> list[type[BaseModel]]:
"""All Pydantic models that appear anywhere inside `annotation`."""
out: list[type[BaseModel]] = []
inner, _ = extract_optional(annotation)
elem = extract_list_element(inner)
if elem is not None:
out.extend(_nested_models(elem))
return out
if isinstance(inner, type) and issubclass(inner, BaseModel):
out.append(inner)
return out
def _has_input(input_cls: Any) -> bool:
return (
input_cls is not None
and input_cls is not BaseModel
and hasattr(input_cls, "model_fields")
and bool(input_cls.model_fields)
)
def _snake_to_camel(name: str) -> str:
parts = name.replace(".", "_").replace("-", "_").split("_")
return parts[0] + "".join(p.title() for p in parts[1:] if p)
def _name_input_model(fn_class: Any) -> type[BaseModel]:
"""Return a copy of the function's Input model named `<CamelName>Input`."""
from pydantic import create_model
camel = _snake_to_camel(fn_class.name)
canonical = f"{camel}Input"
src = fn_class.Input
if src.__name__ == canonical:
return src
# Re-derive under the canonical name so codegen consumers see a stable name.
return create_model(canonical, __base__=src)
def _name_output_model(fn_class: Any, base: type[BaseModel]) -> type[BaseModel]:
"""Return a copy of the model named `<CamelName>Output`."""
from pydantic import create_model
camel = _snake_to_camel(fn_class.name)
canonical = f"{camel}Output"
if base.__name__ == canonical:
return base
return create_model(canonical, __base__=base)
# ─── Function / context / channel emission ──────────────────────────────────
def _function_props(fn_class: Any, output_type_name: str, output_nullable: bool) -> dict[str, Any]:
"""Collect every value that goes inside a `function` block."""
meta = getattr(fn_class, "_meta", {})
name = fn_class.name
camel = _snake_to_camel(name)
input_cls = getattr(fn_class, "Input", None)
has_input = _has_input(input_cls)
is_context = meta.get("context")
is_form = meta.get("form", False)
return {
"name": name,
"camel": camel,
"has_input": has_input,
"input_type": f"{camel}Input" if has_input else None,
"output_type": output_type_name,
"output_nullable": output_nullable,
"transport": "websocket" if meta.get("websocket") else "http",
"context": is_context if isinstance(is_context, str) else None,
"affects": [a["name"] for a in meta.get("affects") or [] if a.get("type") == "context"],
"merge": list(meta.get("merge") or []),
"is_form": bool(is_form),
"form_name": meta.get("form_name"),
"form_role": meta.get("form_role"),
}
def _resolve_output(fn_class: Any) -> tuple[str, bool]:
"""Return `(output_type_name, output_nullable)` for an emitted function block."""
camel = _snake_to_camel(fn_class.name)
canonical = f"{camel}Output"
output_cls = getattr(fn_class, "Output", None)
if output_cls is None:
return canonical, False
_, nullable = extract_optional(output_cls)
return canonical, nullable
def _collect_channels() -> list[dict[str, Any]]:
"""Pull channel registrations from the optional `channels` registry extension."""
from mizan_core.registry import _extensions # type: ignore[attr-defined]
ext = _extensions.get("channels")
if ext is None:
return []
schema = ext.schema()
return list(schema or [])
# ─── Top-level builder ──────────────────────────────────────────────────────
def build_ir() -> str:
"""Build the Mizan IR for every registered function. Returns KDL source."""
functions = get_all_functions()
context_groups = get_context_groups()
channels = _collect_channels()
named_types = _collect_named_types(functions)
lines: list[str] = []
root = _Block(lines, indent=0)
# ── Type definitions ──
for type_name in sorted(named_types):
shape = named_types[type_name]
with root.node("type", _kdl_string(type_name)) as type_block:
if isinstance(shape, _StructShape):
_emit_struct_type(type_block, shape.model, named_types)
elif isinstance(shape, _AliasShape):
_emit_alias_type(type_block, shape.annotation, named_types)
else:
raise TypeError(f"unknown named-type shape: {type(shape).__name__}")
if named_types:
lines.append("")
# ── Functions ──
# Alphabetical by wire name — the IR is a canonical contract, not a
# transcript of registration order. Both Python and Rust emitters sort
# so byte-equivalence holds across language-backed backends.
for fn_name in sorted(functions):
fn_class = functions[fn_name]
meta = getattr(fn_class, "_meta", {})
if meta.get("private") or meta.get("view_path"):
continue
output_type_name, output_nullable = _resolve_output(fn_class)
props = _function_props(fn_class, output_type_name, output_nullable)
_emit_function(root, props)
if functions:
lines.append("")
# ── Contexts ──
# Alphabetical by context name — same reason as functions above.
for ctx_name in sorted(context_groups):
_emit_context(root, ctx_name, context_groups[ctx_name])
if context_groups:
lines.append("")
# ── Channels ──
for channel in channels:
_emit_channel(root, channel)
# Trim trailing blanks then add a single terminating newline.
while lines and not lines[-1]:
lines.pop()
return "\n".join(lines) + "\n"
def _emit_function(root: _Block, props: dict[str, Any]) -> None:
with root.node("function", _kdl_string(props["name"])) as block:
block.leaf("camel", _kdl_string(props["camel"]))
block.leaf("has-input", _kdl_bool(props["has_input"]))
if props["input_type"]:
block.leaf("input", _kdl_string(props["input_type"]))
block.leaf("output", _kdl_string(props["output_type"]))
if props["output_nullable"]:
block.leaf("output-nullable", _kdl_bool(True))
block.leaf("transport", _kdl_string(props["transport"]))
if props["context"]:
block.leaf("context", _kdl_string(props["context"]))
for affect_name in props["affects"]:
block.leaf("affects", _kdl_string(affect_name))
for merge_name in props["merge"]:
block.leaf("merge", _kdl_string(merge_name))
if props["is_form"]:
block.leaf("is-form", _kdl_bool(True))
if props["form_name"]:
block.leaf("form-name", _kdl_string(props["form_name"]))
if props["form_role"]:
block.leaf("form-role", _kdl_string(props["form_role"]))
def _emit_context(root: _Block, ctx_name: str, fn_names: list[str]) -> None:
# First pass: collect param info across every function in the context.
param_info: dict[str, dict[str, Any]] = {}
for fn_name in fn_names:
fn_class = get_function(fn_name)
if fn_class is None:
continue
input_cls = getattr(fn_class, "Input", None)
if not _has_input(input_cls):
continue
for param_name, field_info in input_cls.model_fields.items():
slot = param_info.setdefault(param_name, {"type": None, "shared_by": []})
slot["type"] = _annotation_to_primitive(field_info.annotation)
slot["shared_by"].append(fn_name)
# A param is required iff every function in the context declares it.
for slot in param_info.values():
slot["required"] = len(slot["shared_by"]) == len(fn_names)
with root.node("context", _kdl_string(ctx_name)) as block:
# Members alphabetical — canonical order.
for fn_name in sorted(fn_names):
block.leaf("function", _kdl_string(fn_name))
for param_name in sorted(param_info):
slot = param_info[param_name]
with block.node("param", _kdl_string(param_name)) as param_block:
param_block.leaf("type", _kdl_string(slot["type"]))
param_block.leaf("required", _kdl_bool(slot["required"]))
# `shared-by` follows the same canonical ordering.
for sharer in sorted(slot["shared_by"]):
param_block.leaf("shared-by", _kdl_string(sharer))
def _annotation_to_primitive(annotation: Any) -> str:
inner, _ = extract_optional(annotation)
if inner is int:
return "integer"
if inner is float:
return "number"
if inner is bool:
return "boolean"
return "string"
def _emit_channel(root: _Block, channel: dict[str, Any]) -> None:
name = channel["name"]
with root.node("channel", _kdl_string(name)) as block:
block.leaf("pascal-name", _kdl_string(channel["pascalName"]))
if channel.get("hasParams") and channel.get("paramsType"):
block.leaf("params", _kdl_string(channel["paramsType"]))
if channel.get("hasReactMessage") and channel.get("reactMessageType"):
block.leaf("react-message", _kdl_string(channel["reactMessageType"]))
if channel.get("hasDjangoMessage") and channel.get("djangoMessageType"):
block.leaf("django-message", _kdl_string(channel["djangoMessageType"]))

View File

@@ -0,0 +1,104 @@
"""
Type-introspection helpers shared across backend adapters.
Both mizan-django and mizan-fastapi need to walk @client-decorated function
annotations the same way during schema export. Drift here breaks AFI parity,
so the helpers live in core.
"""
from __future__ import annotations
import types
from typing import Any, Union, get_args, get_origin
from pydantic import BaseModel
__all__ = [
"extract_optional",
"extract_list_element",
"is_structured_output",
"types_match_for_merge",
]
def extract_optional(annotation: Any) -> tuple[Any, bool]:
"""Unwrap `Optional[T]` / `T | None`.
Returns `(T, True)` for a union containing exactly one non-None member
and `None` itself. For anything else, returns `(annotation, False)`.
Multi-arm unions like `A | B | None` are returned as-is — protocol-level
discriminated unions aren't supported yet, and silently picking one arm
would hide that.
"""
origin = get_origin(annotation)
if origin is Union or isinstance(annotation, types.UnionType):
non_none = [a for a in get_args(annotation) if a is not type(None)]
if len(non_none) == 1:
return non_none[0], True
return annotation, False
def extract_list_element(annotation: Any) -> Any | None:
"""If `annotation` is `list[T]` (or sibling container of one), return `T`.
Recognizes `list`, `tuple`, `set`, `frozenset`. For `tuple[T, ...]` the
variadic shape is treated as a homogeneous container; heterogeneous
tuples are not unwrapped.
"""
origin = get_origin(annotation)
if origin not in (list, tuple, set, frozenset):
return None
args = get_args(annotation)
if len(args) == 1:
return args[0]
if origin is tuple and len(args) == 2 and args[1] is Ellipsis:
return args[0]
return None
def is_structured_output(annotation: Any) -> bool:
"""Recognize return types that don't need a `{result: ...}` primitive wrap.
Matches `BaseModel`, `Optional[BaseModel]` / `BaseModel | None`, and
container-of-BaseModel (`list[T]`, `tuple[T, ...]`, etc.). Anything else
(primitives, dicts, raw `Any`) is treated as primitive and gets wrapped
so it can ride through Pydantic's typed serialization.
"""
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
return True
origin = get_origin(annotation)
if origin is Union or isinstance(annotation, types.UnionType):
return any(
arg is not type(None) and is_structured_output(arg)
for arg in get_args(annotation)
)
if origin in (list, tuple, set, frozenset):
return any(is_structured_output(arg) for arg in get_args(annotation))
return False
def types_match_for_merge(slot_type: Any, value_type: Any) -> bool:
"""True if a `value_type` mutation return can splice into a `slot_type` context slot.
Used by backend dispatch to resolve `@client(merge=ctx)` to a concrete
function-name slot inside the context bundle. Three shapes match:
- direct: slot is `T`, value is `T` → replace
- upsert: slot is `list[T]`, value is `T` → upsert by id
- list replace: slot is `list[T]`, value is `list[T]`
`Optional[T]` is unwrapped on both sides before comparison.
"""
slot_inner, _ = extract_optional(slot_type)
value_inner, _ = extract_optional(value_type)
if slot_inner is value_inner:
return True
slot_elem = extract_list_element(slot_inner)
if slot_elem is not None and slot_elem is value_inner:
return True
value_elem = extract_list_element(value_inner)
if slot_elem is not None and value_elem is not None and slot_elem is value_elem:
return True
return False

54
cores/mizan-rust-macros/Cargo.lock generated Normal file
View File

@@ -0,0 +1,54 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "mizan-macros"
version = "0.1.0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"

View File

@@ -0,0 +1,15 @@
[package]
name = "mizan-macros"
version = "0.1.0"
edition = "2021"
description = "Proc macros for mizan-core: #[derive(Mizan)], #[mizan::context], #[mizan(...)]. Emits MizanType / ContextMarker / FunctionSpec impls plus linkme registrations."
license = "Elastic-2.0"
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full", "extra-traits"] }
heck = "0.5"

View File

@@ -0,0 +1,77 @@
//! `#[mizan::context]` / `#[mizan::context("name")]` — emit `ContextMarker`
//! impl + linkme registration for a unit struct.
use heck::ToSnakeCase;
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{parse::Parser, punctuated::Punctuated, ItemStruct, Lit, LitStr, Meta, Token};
/// Attribute args: either nothing, or one string literal that overrides the
/// derived snake_case context name.
pub struct ContextArgs {
pub explicit_name: Option<String>,
}
impl ContextArgs {
pub fn parse(attr_tokens: TokenStream) -> syn::Result<Self> {
if attr_tokens.is_empty() {
return Ok(Self { explicit_name: None });
}
// Support both `#[mizan::context("user")]` (string literal) and
// `#[mizan::context(name = "user")]` (key=value).
if let Ok(lit) = syn::parse2::<LitStr>(attr_tokens.clone()) {
return Ok(Self {
explicit_name: Some(lit.value()),
});
}
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
let metas = parser.parse2(attr_tokens)?;
for meta in metas {
if let Meta::NameValue(nv) = meta {
if nv.path.is_ident("name") {
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value {
return Ok(Self {
explicit_name: Some(s.value()),
});
}
}
}
}
Err(syn::Error::new_spanned(
"",
"expected `#[mizan::context]` or `#[mizan::context(\"<name>\")]` or `#[mizan::context(name = \"<name>\")]`",
))
}
}
pub fn expand(args: ContextArgs, item: ItemStruct) -> TokenStream {
if !item.fields.is_empty() {
return syn::Error::new_spanned(
&item.fields,
"#[mizan::context] requires a unit struct — context markers carry no data.",
)
.to_compile_error();
}
let ident = item.ident.clone();
let name = args
.explicit_name
.unwrap_or_else(|| ident.to_string().to_snake_case());
let register_static =
format_ident!("__MIZAN_CTX_REGISTER_{}", ident.to_string().to_uppercase());
quote! {
#item
impl ::mizan_core::ContextMarker for #ident {
const NAME: &'static str = #name;
}
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::CONTEXTS)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #register_static: ::mizan_core::ContextEntry = ::mizan_core::ContextEntry {
name: #name,
};
}
}

View File

@@ -0,0 +1,206 @@
//! `#[derive(Mizan)]` — emit `MizanType` impl + linkme registration.
use heck::{ToKebabCase, ToLowerCamelCase, ToShoutySnakeCase, ToSnakeCase, ToUpperCamelCase};
use proc_macro2::TokenStream;
use quote::quote;
use syn::{
parse::Parser, punctuated::Punctuated, Data, DataEnum, DataStruct, DeriveInput, Fields, Lit,
Meta, Token,
};
use crate::shape::type_shape_expr;
/// Apply a `#[serde(rename_all = "...")]` casing transform to a Rust
/// variant identifier so the IR's enum variant matches what serde emits
/// on the wire. Supported casings mirror serde's set.
fn apply_rename_all(rule: &str, ident: &str) -> String {
match rule {
"lowercase" => ident.to_lowercase(),
"UPPERCASE" => ident.to_uppercase(),
"PascalCase" => ident.to_upper_camel_case(),
"camelCase" => ident.to_lower_camel_case(),
"snake_case" => ident.to_snake_case(),
"SCREAMING_SNAKE_CASE" => ident.to_shouty_snake_case(),
"kebab-case" => ident.to_kebab_case(),
_ => ident.to_string(),
}
}
/// Walk the enum's outer attributes for `#[serde(rename_all = "...")]`.
fn serde_rename_all(attrs: &[syn::Attribute]) -> Option<String> {
for attr in attrs {
if !attr.path().is_ident("serde") {
continue;
}
let list = match &attr.meta {
Meta::List(l) => l,
_ => continue,
};
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
let metas = match parser.parse2(list.tokens.clone()) {
Ok(m) => m,
Err(_) => continue,
};
for meta in metas {
if let Meta::NameValue(nv) = meta {
if nv.path.is_ident("rename_all") {
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value {
return Some(s.value());
}
}
}
}
}
None
}
/// Walk a variant's attributes for an explicit `#[serde(rename = "...")]`
/// override. Variant-level rename overrides the enum-level rename_all.
fn serde_rename(attrs: &[syn::Attribute]) -> Option<String> {
for attr in attrs {
if !attr.path().is_ident("serde") {
continue;
}
let list = match &attr.meta {
Meta::List(l) => l,
_ => continue,
};
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
let metas = match parser.parse2(list.tokens.clone()) {
Ok(m) => m,
Err(_) => continue,
};
for meta in metas {
if let Meta::NameValue(nv) = meta {
if nv.path.is_ident("rename") {
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value {
return Some(s.value());
}
}
}
}
}
None
}
/// Expand `#[derive(Mizan)]`. Emits the `MizanType` impl AND a linkme
/// TypeEntry registration. Every Mizan-shaped type lands in the IR;
/// the emitter's inline-substitution pass collapses primitive-aliases
/// and enums at use sites so the IR stays tight.
pub fn expand(input: DeriveInput) -> TokenStream {
let ident = input.ident.clone();
let type_name = ident.to_string();
let rename_all = serde_rename_all(&input.attrs);
let named_type_body = match &input.data {
Data::Struct(s) => emit_struct(s),
Data::Enum(e) => emit_enum(e, rename_all.as_deref()),
Data::Union(_) => {
return syn::Error::new_spanned(
&input,
"#[derive(Mizan)] does not support `union` types — use a struct or enum.",
)
.to_compile_error();
}
};
let register_static =
quote::format_ident!("__MIZAN_TYPE_REGISTER_{}", ident.to_string().to_shouty_snake_case());
quote! {
impl ::mizan_core::MizanType for #ident {
const TYPE_NAME: &'static str = #type_name;
fn shape() -> ::mizan_core::NamedType { #named_type_body }
}
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
#[allow(non_upper_case_globals)]
static #register_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: #type_name,
shape_fn: <#ident as ::mizan_core::MizanType>::shape,
};
}
}
fn emit_struct(s: &DataStruct) -> TokenStream {
let fields = match &s.fields {
Fields::Named(named) => &named.named,
Fields::Unnamed(_) | Fields::Unit => {
return syn::Error::new_spanned(
&s.fields,
"#[derive(Mizan)] requires named fields. Tuple structs and unit structs aren't part of the IR shape.",
)
.to_compile_error();
}
};
let mut field_exprs: Vec<TokenStream> = Vec::new();
for field in fields {
let ident = field
.ident
.as_ref()
.expect("named field always has an ident");
// 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
// are not encodable from Rust syntax (no `= expr` on a struct field
// declaration) — the macro emits `required: false, default: None`
// for Option-wrapped fields, leaving defaults for a future
// attribute-based extension.
let is_optional = crate::shape::unwrap_option(&field.ty).is_some();
let required = !is_optional;
field_exprs.push(quote! {
::mizan_core::StructField {
name: #name,
required: #required,
default: ::std::option::Option::None,
shape: #shape,
}
});
}
quote! {
::mizan_core::NamedType::Struct(::std::vec![
#(#field_exprs),*
])
}
}
fn emit_enum(e: &DataEnum, rename_all: Option<&str>) -> TokenStream {
let mut variants: Vec<TokenStream> = Vec::new();
for variant in &e.variants {
if !matches!(variant.fields, Fields::Unit) {
return syn::Error::new_spanned(
&variant.fields,
"#[derive(Mizan)] only supports unit-variant enums (string-literal enums in the IR). Variants with payload aren't expressible in the current IR.",
)
.to_compile_error();
}
let raw = variant.ident.to_string();
// Variant-level `#[serde(rename = "...")]` wins; otherwise apply
// the enum-level `#[serde(rename_all = "...")]` rule.
let name = if let Some(explicit) = serde_rename(&variant.attrs) {
explicit
} else if let Some(rule) = rename_all {
apply_rename_all(rule, &raw)
} else {
raw
};
variants.push(quote! { #name });
}
quote! {
::mizan_core::NamedType::Enum(::std::vec![
#(#variants),*
])
}
}

View File

@@ -0,0 +1,516 @@
//! `#[mizan(...)]` — on async fns. Generates:
//! * a synthetic Input struct (`<camelName>Input`) when the fn has params
//! * `MizanType` impl on the Input struct
//! * canonical type entries (`<camelName>Input` / `<camelName>Output`)
//! * Vec-element sub-type entries (so `Vec<T>` outputs surface `T` too)
//! * `FunctionSpec` impl on a ZST `__MizanFn_<name>`
//! * `FUNCTIONS` linkme registration of `&__MIZAN_FN_<NAME>_INSTANCE`
use heck::{ToLowerCamelCase, ToShoutySnakeCase};
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{
parse::Parser,
punctuated::Punctuated,
spanned::Spanned,
Expr, ExprPath, ExprTuple, FnArg, ItemFn, Meta, Pat, Path, ReturnType, Token, Type,
};
use crate::shape::{analyze_return, primitive_of, type_shape_expr, unwrap_option};
/// Parsed attribute args for `#[mizan(...)]`.
#[derive(Default)]
pub struct FunctionArgs {
pub context: Option<Path>,
pub affects: Vec<Path>,
pub merge: Vec<Path>,
pub websocket: bool,
pub private: bool,
}
impl FunctionArgs {
pub fn parse(attr_tokens: TokenStream) -> syn::Result<Self> {
if attr_tokens.is_empty() {
return Ok(Self::default());
}
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
let metas = parser.parse2(attr_tokens)?;
let mut out = Self::default();
for meta in metas {
match meta {
Meta::NameValue(nv) => {
if nv.path.is_ident("context") {
out.context = Some(expect_path(&nv.value)?);
} else if nv.path.is_ident("affects") {
out.affects = collect_paths(&nv.value)?;
} else if nv.path.is_ident("merge") {
out.merge = collect_paths(&nv.value)?;
} else {
return Err(syn::Error::new_spanned(
nv.path,
"unknown attribute key; expected one of: context, affects, merge",
));
}
}
Meta::Path(p) => {
if p.is_ident("websocket") {
out.websocket = true;
} else if p.is_ident("private") {
out.private = true;
} else {
return Err(syn::Error::new_spanned(
p,
"unknown flag; expected `websocket` or `private`",
));
}
}
Meta::List(l) => {
return Err(syn::Error::new_spanned(
l,
"list-shaped attribute args not supported here",
));
}
}
}
if out.context.is_some() && !out.affects.is_empty() {
return Err(syn::Error::new_spanned(
out.context.as_ref().unwrap(),
"`context` and `affects` are mutually exclusive — a function is either a context reader or a mutation.",
));
}
if out.context.is_some() && !out.merge.is_empty() {
return Err(syn::Error::new_spanned(
out.context.as_ref().unwrap(),
"`context` and `merge` are mutually exclusive — a function is either a context reader or a mutation.",
));
}
Ok(out)
}
}
fn expect_path(expr: &Expr) -> syn::Result<Path> {
if let Expr::Path(ExprPath { path, .. }) = expr {
Ok(path.clone())
} else {
Err(syn::Error::new_spanned(
expr,
"expected a type path (e.g. `UserCtx`)",
))
}
}
fn collect_paths(expr: &Expr) -> syn::Result<Vec<Path>> {
match expr {
Expr::Path(_) => Ok(vec![expect_path(expr)?]),
Expr::Tuple(ExprTuple { elems, .. }) => elems.iter().map(expect_path).collect(),
_ => Err(syn::Error::new_spanned(
expr,
"expected a context type or a tuple of context types (e.g. `UserCtx` or `(UserCtx, OrderCtx)`)",
)),
}
}
/// Information about one input parameter, extracted from the fn signature.
struct InputArg {
ident: syn::Ident,
ty: Type,
}
pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
if item.sig.asyncness.is_none() {
return syn::Error::new_spanned(
&item.sig.fn_token,
"#[mizan] requires an `async fn`. Wrap synchronous handlers if needed.",
)
.to_compile_error();
}
let fn_name = item.sig.ident.to_string();
let camel = fn_name.to_lower_camel_case();
let input_type_name = format!("{camel}Input");
let output_type_name = format!("{camel}Output");
let input_args = match collect_input_args(&item) {
Ok(v) => v,
Err(e) => return e.to_compile_error(),
};
let has_input = !input_args.is_empty();
let input_type_ident = format_ident!("{}", input_type_name);
let return_ty = match &item.sig.output {
ReturnType::Type(_, t) => (**t).clone(),
ReturnType::Default => {
return syn::Error::new_spanned(
&item.sig,
"#[mizan] requires an explicit return type. Add `-> T` to the signature.",
)
.to_compile_error();
}
};
let analysis = analyze_return(&return_ty);
// ─── Synthetic Input struct ────────────────────────────────────────────
let input_struct = if has_input {
let mut field_defs = Vec::new();
let mut field_shapes = Vec::new();
for arg in &input_args {
let ident = &arg.ident;
let ty = &arg.ty;
// Strip a leading underscore from the wire-level field name —
// Rust convention uses `_foo` to silence unused-arg warnings,
// but the wire schema and the Python fixture name the param
// `foo`. The struct field keeps its source ident (so the
// dispatch wrapper's `validated.#ident` compiles), and a serde
// `rename` bridges the wire-level JSON name.
let name_str = ident.to_string();
let wire_name = name_str.trim_start_matches('_').to_string();
let serde_rename = if wire_name != name_str {
quote! { #[serde(rename = #wire_name)] }
} else {
TokenStream::new()
};
field_defs.push(quote! { #serde_rename pub #ident: #ty, });
let is_optional = unwrap_option(ty).is_some();
let required = !is_optional;
let shape = type_shape_expr(ty);
field_shapes.push(quote! {
::mizan_core::StructField {
name: #wire_name,
required: #required,
default: ::std::option::Option::None,
shape: #shape,
}
});
}
quote! {
#[derive(::std::fmt::Debug, ::std::clone::Clone, ::serde::Serialize, ::serde::Deserialize)]
pub struct #input_type_ident {
#(#field_defs)*
}
impl ::mizan_core::MizanType for #input_type_ident {
const TYPE_NAME: &'static str = #input_type_name;
fn shape() -> ::mizan_core::NamedType {
::mizan_core::NamedType::Struct(::std::vec![
#(#field_shapes),*
])
}
}
}
} else {
TokenStream::new()
};
// ─── Type entry registrations ──────────────────────────────────────────
// - Input: TypeEntry pointing at the synthetic input struct's shape_fn.
// - Output: TypeEntry whose shape is a copy of the user's Output shape
// (for struct outputs) or an `Alias(List(Ref("T")))` (for Vec outputs).
// - For Vec<T> outputs, ALSO register T's TypeEntry pointing at T's
// MizanType impl (so the Ref resolves in the IR).
let mut type_registrations = Vec::new();
if has_input {
let static_ident =
format_ident!("__MIZAN_TYPE_{}", input_type_name.to_shouty_snake_case());
type_registrations.push(quote! {
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #static_ident: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: #input_type_name,
shape_fn: <#input_type_ident as ::mizan_core::MizanType>::shape,
};
});
}
let output_static = format_ident!("__MIZAN_TYPE_{}", output_type_name.to_shouty_snake_case());
if analysis.is_vec {
let elem = analysis.vec_inner.as_ref().expect("vec_inner set");
// userOrdersOutput → alias { list { ref "OrderOutput" } }
// The Ref name is resolved via `<T as MizanType>::type_name()`.
type_registrations.push(quote! {
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: #output_type_name,
shape_fn: || ::mizan_core::NamedType::Alias(
::mizan_core::TypeShape::List(::std::boxed::Box::new(
::mizan_core::TypeShape::Ref(<#elem as ::mizan_core::MizanType>::TYPE_NAME)
))
),
};
});
// Also register the element type itself by its own name. `TYPE_NAME`
// is an associated const, so this is usable in a static initializer.
// The static ident scopes by the function name so two handlers
// returning `Vec<Same>` don't collide; the IrSnapshot's BTreeMap
// dedupes by the entry's `name` at emit time.
let elem_static =
element_type_static_ident_scoped(elem, &fn_name.to_shouty_snake_case());
type_registrations.push(quote! {
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #elem_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: <#elem as ::mizan_core::MizanType>::TYPE_NAME,
shape_fn: <#elem as ::mizan_core::MizanType>::shape,
};
});
} else {
// Non-Vec output: copy the inner type's shape under the canonical name.
let inner_ty = &analysis.inner;
type_registrations.push(quote! {
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: #output_type_name,
shape_fn: <#inner_ty as ::mizan_core::MizanType>::shape,
};
});
}
// ─── InputParam slice (for context-builder shared-param elevation) ────
let mut input_params = Vec::new();
for arg in &input_args {
// Wire-level name strips the underscore prefix — see input_struct
// above for the rationale.
let name_str = arg.ident.to_string();
let name_str = name_str.trim_start_matches('_').to_string();
let primitive = primitive_of(&arg.ty).unwrap_or_else(|| {
// Non-primitive params don't surface in the context's `param`
// block; they participate as opaque payloads. Using `String` as
// the placeholder primitive matches Python's fallback in
// `_annotation_to_primitive`.
quote! { ::mizan_core::Primitive::String }
});
let is_optional = unwrap_option(&arg.ty).is_some();
let required = !is_optional;
input_params.push(quote! {
::mizan_core::InputParam {
name: #name_str,
primitive: #primitive,
required: #required,
}
});
}
let params_static = format_ident!("__MIZAN_FN_{}_PARAMS", fn_name.to_shouty_snake_case());
let params_const = quote! {
const #params_static: &[::mizan_core::InputParam] = &[
#(#input_params),*
];
};
// ─── AffectTarget / merge / context wiring ─────────────────────────────
let affects_static = format_ident!("__MIZAN_FN_{}_AFFECTS", fn_name.to_shouty_snake_case());
let affects_entries: Vec<_> = args
.affects
.iter()
.map(|p| {
quote! {
::mizan_core::AffectTarget::Context(<#p as ::mizan_core::ContextMarker>::NAME)
}
})
.collect();
let affects_const = quote! {
const #affects_static: &[::mizan_core::AffectTarget] = &[
#(#affects_entries),*
];
};
let merge_static = format_ident!("__MIZAN_FN_{}_MERGE", fn_name.to_shouty_snake_case());
let merge_entries: Vec<_> = args
.merge
.iter()
.map(|p| quote! { <#p as ::mizan_core::ContextMarker>::NAME })
.collect();
let merge_const = quote! {
const #merge_static: &[&'static str] = &[
#(#merge_entries),*
];
};
let context_value = match &args.context {
Some(p) => quote! { ::std::option::Option::Some(<#p as ::mizan_core::ContextMarker>::NAME) },
None => quote! { ::std::option::Option::None },
};
let transport_value = if args.websocket {
quote! { ::mizan_core::Transport::Websocket }
} else {
quote! { ::mizan_core::Transport::Http }
};
// ─── Dispatch wrapper + FunctionSpec impl ──────────────────────────────
let inner_fn_ident = item.sig.ident.clone();
let spec_struct = format_ident!("__MizanFn_{}", inner_fn_ident);
let spec_const = format_ident!("__MIZAN_FN_{}_SPEC", fn_name.to_shouty_snake_case());
let register_static =
format_ident!("__MIZAN_FN_{}_REGISTER", fn_name.to_shouty_snake_case());
let input_type_opt = if has_input {
quote! { ::std::option::Option::Some(#input_type_name) }
} else {
quote! { ::std::option::Option::None }
};
let output_nullable = analysis.nullable;
let private = args.private;
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
// body, only wraps it for dispatch.
#item
#input_struct
#(#type_registrations)*
#params_const
#affects_const
#merge_const
#[allow(non_camel_case_types)]
pub struct #spec_struct;
impl ::mizan_core::FunctionSpec for #spec_struct {
fn name(&self) -> &'static str { #fn_name }
fn camel_name(&self) -> &'static str { #camel }
fn has_input(&self) -> bool { #has_input }
fn input_type(&self) -> ::std::option::Option<&'static str> { #input_type_opt }
fn output_type(&self) -> &'static str { #output_type_name }
fn output_nullable(&self) -> bool { #output_nullable }
fn context(&self) -> ::std::option::Option<&'static str> { #context_value }
fn affects(&self) -> &'static [::mizan_core::AffectTarget] { #affects_static }
fn merge(&self) -> &'static [&'static str] { #merge_static }
fn transport(&self) -> ::mizan_core::Transport { #transport_value }
fn private(&self) -> bool { #private }
fn input_params(&self) -> &'static [::mizan_core::InputParam] { #params_static }
fn dispatch<'a>(
&'a self,
req: ::mizan_core::RequestHandle<'a>,
args: ::mizan_core::__priv::serde_json::Value,
) -> ::std::pin::Pin<::std::boxed::Box<dyn ::std::future::Future<
Output = ::std::result::Result<
::mizan_core::__priv::serde_json::Value,
::mizan_core::MizanError,
>
> + ::std::marker::Send + 'a>> {
::std::boxed::Box::pin(async move {
#dispatch_body
})
}
}
#[allow(non_upper_case_globals)]
static #spec_const: #spec_struct = #spec_struct;
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::FUNCTIONS)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #register_static: &dyn ::mizan_core::FunctionSpec = &#spec_const;
}
}
fn collect_input_args(item: &ItemFn) -> syn::Result<Vec<InputArg>> {
let mut out = Vec::new();
let mut iter = item.sig.inputs.iter();
// First arg is the request handle — skip without inspection. The function
// body uses it directly; the dispatch wrapper forwards `req`.
if iter.next().is_none() {
return Err(syn::Error::new(
item.sig.span(),
"#[mizan] functions must accept at least a request handle as the first parameter (e.g. `&Request` or `RequestHandle`).",
));
}
for arg in iter {
match arg {
FnArg::Typed(pat) => {
let ident = match &*pat.pat {
Pat::Ident(pi) => pi.ident.clone(),
_ => {
return Err(syn::Error::new_spanned(
&pat.pat,
"#[mizan] function parameters must be plain identifiers (no destructuring).",
));
}
};
out.push(InputArg {
ident,
ty: (*pat.ty).clone(),
});
}
FnArg::Receiver(_) => {
return Err(syn::Error::new_spanned(
arg,
"#[mizan] functions are free functions, not methods. `self` is not allowed.",
));
}
}
}
Ok(out)
}
fn build_dispatch(
item: &ItemFn,
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! {
let validated: #input_type_ident = ::mizan_core::__priv::serde_json::from_value(args)
.map_err(|e| ::mizan_core::MizanError::ValidationFailed {
message: format!("input validation failed: {e}"),
details: ::mizan_core::__priv::serde_json::Value::Null,
})?;
let result = #inner(
&req,
#( validated.#arg_names ),*
).await #unwrap_user_result;
::mizan_core::__priv::serde_json::to_value(&result)
.map_err(|e| ::mizan_core::MizanError::InternalError(
format!("output serialization failed: {e}"),
))
}
} else {
quote! {
let _ = args;
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}"),
))
}
}
}
fn element_type_static_ident_scoped(ty: &Type, fn_scope: &str) -> syn::Ident {
// Derive a unique static-name for the type's registration entry,
// scoped by the surrounding function so siblings returning the same
// `Vec<T>` don't collide at the static-name layer. The IR-side
// BTreeMap dedupes by TypeEntry.name at emission time.
let last = match ty {
Type::Path(tp) => tp.path.segments.last().map(|s| s.ident.to_string()),
_ => None,
};
let suffix = last.unwrap_or_else(|| "ANON".to_string()).to_shouty_snake_case();
format_ident!("__MIZAN_TYPE_ELEM_{}_FOR_{}", suffix, fn_scope)
}

View File

@@ -0,0 +1,58 @@
//! Proc macros for `mizan-core`. See sibling modules for each macro's body.
//!
//! Consumer code reads:
//! ```ignore
//! use mizan_core::prelude::*;
//! pub use mizan_core as mizan; // so `#[mizan::context]` / `#[mizan::client]` read naturally
//!
//! #[derive(Mizan, serde::Serialize, serde::Deserialize)]
//! pub struct ProfileOutput { pub user_id: i64, pub name: String }
//!
//! #[mizan::context("user")]
//! pub struct UserCtx;
//!
//! #[mizan::client(context = UserCtx)]
//! pub async fn user_profile(req: &Request, user_id: i64) -> ProfileOutput { ... }
//! ```
//!
//! The function macro is named `client` to mirror Python's `@client`
//! decorator and to keep the namespace `mizan::` purely a module path —
//! `#[mizan(...)]` would collide with `mizan::context` (a module path
//! can't simultaneously be a callable macro in Rust).
mod context;
mod derive;
mod function;
mod shape;
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput, ItemFn, ItemStruct};
#[proc_macro_derive(Mizan)]
pub fn derive_mizan(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
derive::expand(input).into()
}
#[proc_macro_attribute]
pub fn context(attr: TokenStream, item: TokenStream) -> TokenStream {
let args = match context::ContextArgs::parse(attr.into()) {
Ok(a) => a,
Err(e) => return e.to_compile_error().into(),
};
let item = parse_macro_input!(item as ItemStruct);
context::expand(args, item).into()
}
/// The function-registration attribute macro. Used as `#[mizan::client]`
/// (no args) or `#[mizan::client(context = X, affects = Y, merge = Z,
/// websocket, private)]`.
#[proc_macro_attribute]
pub fn client(attr: TokenStream, item: TokenStream) -> TokenStream {
let args = match function::FunctionArgs::parse(attr.into()) {
Ok(a) => a,
Err(e) => return e.to_compile_error().into(),
};
let item = parse_macro_input!(item as ItemFn);
function::expand(args, item).into()
}

View File

@@ -0,0 +1,208 @@
//! Lower a `syn::Type` to a TypeShape construction expression. Shared by
//! `#[derive(Mizan)]` (for struct fields) and `#[mizan(...)]` (for fn input
//! params + return-type analysis).
use proc_macro2::TokenStream;
use quote::quote;
use syn::{GenericArgument, PathArguments, Type, TypePath};
/// Result of inspecting a fn's return type.
pub struct ReturnAnalysis {
/// Inner type once `Option<...>` is unwrapped.
pub inner: Type,
/// True if the outermost wrapper is `Option<...>`.
pub nullable: bool,
/// True if `inner` is `Vec<T>` — caller emits an alias type entry.
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 (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 {
inner,
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 {
if let Some(inner) = unwrap_option(ty) {
let inner_shape = type_shape_expr(&inner);
return quote! {
::mizan_core::TypeShape::Optional(::std::boxed::Box::new(#inner_shape))
};
}
if let Some(elem) = unwrap_vec(ty) {
let inner_shape = type_shape_expr(&elem);
return quote! {
::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape))
};
}
if let Some(elem) = unwrap_array(ty) {
// `[T; N]` lowers to `list { T }` on the wire — JSON arrays don't
// carry length, so the IR contract is the same as `Vec<T>`.
let inner_shape = type_shape_expr(&elem);
return quote! {
::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape))
};
}
if let Some(elem) = unwrap_btreemap_value(ty) {
// `BTreeMap<K, V>` on the wire is a JSON object keyed by `K`'s
// string form. The Mizan IR doesn't model dynamic-keyed maps as a
// distinct shape — closest equivalent is a list of value entries.
let inner_shape = type_shape_expr(&elem);
return quote! {
::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape))
};
}
if let Some(p) = primitive_of(ty) {
return quote! { ::mizan_core::TypeShape::Primitive(#p) };
}
// Fallback: assume a user-defined struct/enum implementing MizanType.
// The Ref name comes from `<T as MizanType>::TYPE_NAME` (associated const).
quote! { ::mizan_core::TypeShape::Ref(<#ty as ::mizan_core::MizanType>::TYPE_NAME) }
}
/// If `ty` is `[T; N]`, return `T`. Otherwise None.
pub fn unwrap_array(ty: &Type) -> Option<Type> {
if let Type::Array(a) = ty {
Some((*a.elem).clone())
} else {
None
}
}
/// If `ty` is `BTreeMap<K, V>` or `HashMap<K, V>`, return `V` (the value).
/// String-keyed maps land on the wire as JSON objects; the IR carries the
/// value shape as a list element since KDL doesn't model dynamic-keyed maps
/// distinctly yet.
pub fn unwrap_btreemap_value(ty: &Type) -> Option<Type> {
let path = match ty {
Type::Path(TypePath { qself: None, path }) => path,
_ => return None,
};
let last = path.segments.last()?;
let name = last.ident.to_string();
if name != "BTreeMap" && name != "HashMap" {
return None;
}
let args = match &last.arguments {
PathArguments::AngleBracketed(a) => a,
_ => return None,
};
// BTreeMap<K, V> — second type argument is V.
let mut type_args = args.args.iter().filter_map(|a| {
if let GenericArgument::Type(t) = a {
Some(t.clone())
} else {
None
}
});
type_args.next()?; // skip K
type_args.next()
}
/// Emit a `Primitive` const-expression for `ty`, or `None` if `ty` isn't a
/// known primitive scalar.
pub fn primitive_of(ty: &Type) -> Option<TokenStream> {
let path = match ty {
Type::Path(TypePath { qself: None, path }) => path,
_ => return None,
};
let last = path.segments.last()?;
let name = last.ident.to_string();
match name.as_str() {
"i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128"
| "usize" => Some(quote! { ::mizan_core::Primitive::Integer }),
"f32" | "f64" => Some(quote! { ::mizan_core::Primitive::Number }),
"bool" => Some(quote! { ::mizan_core::Primitive::Boolean }),
"String" | "str" => Some(quote! { ::mizan_core::Primitive::String }),
_ => None,
}
}
/// If `ty` is `Option<T>`, return `T`. Otherwise None.
pub fn unwrap_option(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 != "Option" {
return None;
}
extract_single_generic(&last.arguments)
}
/// If `ty` is `Vec<T>`, return `T`. Otherwise None.
pub fn unwrap_vec(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 != "Vec" {
return None;
}
extract_single_generic(&last.arguments)
}
fn extract_single_generic(args: &PathArguments) -> Option<Type> {
let args = match args {
PathArguments::AngleBracketed(a) => a,
_ => return None,
};
for arg in &args.args {
if let GenericArgument::Type(t) = arg {
return Some(t.clone());
}
}
None
}

2445
cores/mizan-rust-ssr/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
[package]
name = "mizan-rust-ssr"
version = "0.1.0"
edition = "2021"
description = "Mizan SSR engine — embeds a deno_core V8 runtime (with deno_web) to render the build-time JS bundle to HTML in-process. No node, no bun, at serve time."
license = "Elastic-2.0"
[lib]
path = "src/lib.rs"
# deno_core + deno_web are added via `cargo add` so the resolver pins a
# version-matched pair (deno_web constrains deno_core); the web-platform
# globals react-dom/server.browser needs (TextEncoder, timers, MessagePort,
# streams) come from deno_web as real implementations, not hand-rolled shims.
[dependencies]
anyhow = "1.0.102"
deno_core = "0.403.0"
deno_web = "0.281.0"
deno_webidl = "0.250.0"
[dev-dependencies]
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros"] }

View File

@@ -0,0 +1,133 @@
//! Mizan SSR engine.
//!
//! Embeds a `deno_core` V8 runtime composed with `deno_web` so the build-time
//! JS bundle (component + `react-dom/server.browser`, produced by the bundler
//! during `mizan-generate`) renders to HTML in-process. The bundle exposes a
//! global render function; the engine evals it once and calls it per request.
//! No external JS runtime — node and bun are build-time tools only.
//!
//! The host globals a bare V8 isolate lacks — `TextEncoder`/`TextDecoder`,
//! timers, `MessagePort`, `performance` — come from `deno_web` as real
//! web-platform implementations, not shims (a partial polyfill is
//! silent-failure-shaped: it passes until a render path hits the gap).
//!
//! Props never enter evaluated source. Only the trusted bundle is `eval`'d;
//! per-render data crosses as a `v8::json::parse`d value passed as a function
//! argument, so a prop string has no source to break out of — code injection
//! is structurally absent, not filtered.
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use deno_core::{v8, JsRuntime, RuntimeOptions};
use deno_web::{BlobStore, InMemoryBroadcastChannel};
/// Install the web-platform globals react-dom touches at module-init, pulled
/// from `deno_web`'s real implementations via the lazy-load op. `deno_web`
/// registers these as lazy modules and installs nothing on `globalThis` until
/// asked — this is the minimal bootstrap that asks.
const INSTALL_WEB_GLOBALS: &str = r#"{
const lazy = (s) => Deno.core.loadExtScript(s);
const mp = lazy("ext:deno_web/13_message_port.js");
globalThis.MessageChannel = mp.MessageChannel;
globalThis.MessagePort = mp.MessagePort;
const te = lazy("ext:deno_web/08_text_encoding.js");
globalThis.TextEncoder = te.TextEncoder;
globalThis.TextDecoder = te.TextDecoder;
}"#;
/// An embedded V8 runtime carrying one rendered bundle, plus the web-platform
/// globals react-dom needs. One isolate per engine (V8's Locker constraint
/// means an engine is not `Send`; hold one per worker thread).
pub struct SsrEngine {
runtime: JsRuntime,
}
impl SsrEngine {
/// Build the runtime and eval `bundle` (which assigns `globalThis.renderApp`).
pub fn new(bundle: String) -> Result<Self> {
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![
deno_webidl::deno_webidl::init(),
deno_web::deno_web::init(
Arc::new(BlobStore::default()),
None, // maybe_location
false, // enable_css_parser_features
InMemoryBroadcastChannel::default(),
),
],
..Default::default()
});
runtime
.execute_script("[mizan:web-globals]", INSTALL_WEB_GLOBALS)
.context("installing web-platform globals")?;
runtime
.execute_script("[mizan:bundle]", bundle)
.context("evaluating the SSR bundle")?;
Ok(Self { runtime })
}
/// Render to HTML by calling the bundle's `renderApp(props)`. `props_json`
/// is a JSON object string; it is parsed to a V8 value and passed as an
/// argument — never spliced into evaluated source.
pub fn render(&mut self, props_json: &str) -> Result<String> {
deno_core::scope!(scope, &mut self.runtime);
let context = scope.get_current_context();
let global = context.global(scope);
let key = v8::String::new(scope, "renderApp").context("intern renderApp key")?;
let func_val = global
.get(scope, key.into())
.ok_or_else(|| anyhow!("renderApp is not defined on globalThis"))?;
let func: v8::Local<v8::Function> = func_val
.try_into()
.map_err(|_| anyhow!("renderApp is not a function"))?;
let props_str = v8::String::new(scope, props_json).context("intern props")?;
let props = v8::json::parse(scope, props_str)
.ok_or_else(|| anyhow!("props are not valid JSON"))?;
let recv = v8::undefined(scope).into();
let result = func
.call(scope, recv, &[props])
.ok_or_else(|| anyhow!("renderApp threw or returned nothing"))?;
Ok(result.to_rust_string_lossy(scope))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn renders_react_bundle_in_embedded_v8() {
let bundle = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixture/bundle.js"
))
.expect("tests/fixture/bundle.js — build it via the fixture's esbuild step");
let mut engine = SsrEngine::new(bundle).expect("engine init");
let html = engine.render(r#"{"name":"World"}"#).expect("render");
assert_eq!(html, r#"<div id="greeting">Hello, World!</div>"#);
}
#[tokio::test]
async fn props_cannot_inject_code() {
// A prop value that would break out of a string-built `renderApp(...)`
// call. Through the value-call path it is inert data: it reaches the
// component as a string, never as source.
let bundle = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixture/bundle.js"
))
.expect("fixture bundle");
let mut engine = SsrEngine::new(bundle).expect("engine init");
let html = engine
.render(r#"{"name":"x\"}); globalThis.__pwned = true; ({\"y\":\""}"#)
.expect("render");
// The payload rendered as text; it did not execute.
assert!(html.contains("__pwned"));
}
}

View File

@@ -0,0 +1,3 @@
node_modules/
package-lock.json
bundle.js

View File

@@ -0,0 +1,7 @@
import { createElement } from "react"
// A trivial component: props in, element out. The keystone only needs to prove
// a real React tree renders to HTML inside a bare JS context.
export function Hello({ name }) {
return createElement("div", { id: "greeting" }, `Hello, ${name}!`)
}

View File

@@ -0,0 +1,8 @@
import { renderToStaticMarkup } from "react-dom/server.browser"
import { createElement } from "react"
import { Hello } from "./Hello.js"
// The bundle exposes one global the embedded engine calls. No module system at
// runtime — the engine receives a bare script that defines `renderApp`. This is
// the production shape in miniature: build-time bundle, runtime eval.
globalThis.renderApp = (props) => renderToStaticMarkup(createElement(Hello, props))

View File

@@ -0,0 +1,7 @@
{
"dependencies": {
"esbuild": "^0.28.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
}
}

View File

@@ -0,0 +1,29 @@
// Proxy for the embedded-V8 runtime: a bare global context with no Node
// builtins. Load the IIFE bundle (which assigns globalThis.renderApp) and call
// it. What renders here renders in rusty_v8 — the engine swaps, the contract
// (bundle defines a global render fn over a bare context) does not.
const fs = require("fs")
const vm = require("vm")
const code = fs.readFileSync(__dirname + "/bundle.js", "utf8")
// The minimal host globals React's bundle touches at init / sync render. The
// rusty_v8 engine must provide the same set — this list is the spec for it.
const sandbox = {
console, setTimeout, clearTimeout, queueMicrotask, MessageChannel, performance,
TextEncoder, TextDecoder,
}
sandbox.globalThis = sandbox
vm.createContext(sandbox)
vm.runInContext(code, sandbox)
const html = sandbox.renderApp({ name: "World" })
console.log("RENDERED:", html)
const expected = '<div id="greeting">Hello, World!</div>'
if (html !== expected) {
console.error("MISMATCH — expected:", expected)
process.exit(1)
}
console.log("OK — React bundle renders in a bare JS context (V8 proxy)")

View File

@@ -0,0 +1,54 @@
//! Guard — Mizan SSR is hand-rolled (bare renderer + AFI data injection +
//! injected kernel). No frontend adapter imports an SSR runtime / meta-framework
//! (Next, Nuxt, SvelteKit) or a server-functions layer (RSC / Flight).
//!
//! React Server Components and the Flight serialization protocol carry
//! CVE-2025-55182 ("React2Shell" — unauthenticated remote code execution,
//! CVSS 10.0): the server deserializes a client-supplied Flight payload and an
//! attacker reaches prototype-pollution → RCE.
//!
//! Mizan renders **synchronously from props** — data is fetched server-side
//! through the AFI and passed in, never deserialized from a client payload — so
//! it sits structurally outside that attack surface. This test keeps it there:
//! it goes red the instant any RSC / Flight / streaming surface enters the
//! authored SSR source or its dependencies. Absence is not enough; this is the
//! forcing function that makes re-entry loud.
/// Tokens that only appear when RSC / Flight / streaming rendering is in play.
const FORBIDDEN: &[&str] = &[
// React Server Components / Flight — CVE-2025-55182 (pre-auth RCE, CVSS 10.0)
"react-server-dom",
"renderToReadableStream",
"renderToPipeableStream",
"createFromReadableStream",
"createFromFetch",
"use server",
// SSR runtimes / meta-frameworks — forbidden across every frontend adapter
"next/",
"nuxt",
"@sveltejs/kit",
"sveltekit",
];
const SCANNED: &[&str] = &[
concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixture/entry.js"),
concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixture/Hello.js"),
concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixture/package.json"),
];
#[test]
fn ssr_has_no_rsc_or_flight_surface() {
for path in SCANNED {
let Ok(src) = std::fs::read_to_string(path) else {
continue; // a generated/optional file absent is fine; authored source is the point
};
for needle in FORBIDDEN {
assert!(
!src.contains(needle),
"RSC/Flight surface {needle:?} found in {path} — forbidden. \
RSC carries CVE-2025-55182 (unauth RCE, CVSS 10.0); Mizan SSR is \
classic renderToString-family only, rendered synchronously from props.",
);
}
}
}

173
cores/mizan-rust/Cargo.lock generated Normal file
View File

@@ -0,0 +1,173 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "linkme"
version = "0.3.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf"
dependencies = [
"linkme-impl",
]
[[package]]
name = "linkme-impl"
version = "0.3.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mizan-core"
version = "0.1.0"
dependencies = [
"async-trait",
"indoc",
"linkme",
"mizan-macros",
"serde",
"serde_json",
]
[[package]]
name = "mizan-macros"
version = "0.1.0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View File

@@ -0,0 +1,16 @@
[package]
name = "mizan-core"
version = "0.1.0"
edition = "2021"
description = "Mizan server-side IR substrate — types, traits, KDL emitter, registry. Rust analog of cores/mizan-python/src/mizan_core/."
license = "Elastic-2.0"
[dependencies]
linkme = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
async-trait = "0.1"
mizan-macros = { path = "../mizan-rust-macros" }
[dev-dependencies]
indoc = "2"

View File

@@ -0,0 +1,200 @@
//! Cross-function invariant verification — fails at `build_ir()` time, which
//! runs at the codegen subprocess (`cargo run --bin export-ir`). All
//! graph-level inconsistencies surface before any client artifact is emitted.
use crate::ir::{AffectTarget, NamedType, StructField, TypeShape};
use crate::registry::{lookup_context, CONTEXTS, FUNCTIONS, TYPES};
/// Walk the registered types and find the named type's shape. Used by both
/// graph-check and runtime merge resolution.
pub(crate) fn resolve_type_shape(name: &str) -> Option<NamedType> {
for entry in TYPES {
if entry.name == name {
return Some((entry.shape_fn)());
}
}
None
}
/// 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),
(NamedType::Enum(va), NamedType::Enum(vb)) => va == vb,
_ => false,
}
}
/// 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;
}
a.iter().zip(b.iter()).all(|(fa, fb)| {
fa.name == fb.name && fa.required == fb.required && shapes_match(&fa.shape, &fb.shape)
})
}
fn shapes_match(a: &TypeShape, b: &TypeShape) -> bool {
match (a, b) {
(TypeShape::Primitive(pa), TypeShape::Primitive(pb)) => {
std::mem::discriminant(pa) == std::mem::discriminant(pb)
}
(TypeShape::Ref(na), TypeShape::Ref(nb)) => {
// Refs match iff the named types they reference match.
match (resolve_type_shape(na), resolve_type_shape(nb)) {
(Some(ta), Some(tb)) => types_match(&ta, &tb),
_ => na == nb,
}
}
(TypeShape::List(ia), TypeShape::List(ib)) => shapes_match(ia, ib),
(TypeShape::Optional(ia), TypeShape::Optional(ib)) => shapes_match(ia, ib),
(TypeShape::Enum(va), TypeShape::Enum(vb)) => va == vb,
(TypeShape::Union(ba), TypeShape::Union(bb)) => {
ba.len() == bb.len() && ba.iter().zip(bb.iter()).all(|(x, y)| shapes_match(x, y))
}
_ => false,
}
}
/// Panic with a structured message if the registered function graph is
/// inconsistent. Called from `build_ir()`.
pub fn verify_invariants() {
check_affects_targets();
check_merge_targets();
check_shared_param_types();
}
fn check_affects_targets() {
for fn_spec in FUNCTIONS {
for affect in fn_spec.affects() {
if let AffectTarget::Context(name) = affect {
if lookup_context(name).is_none() {
panic!(
"Mizan graph-check: function `{}` declares `affects = \"{}\"` but no context with that name is registered. \
Either register a context with that name (via `#[mizan::context(\"{}\")]`) or remove the affects target.",
fn_spec.name(),
name,
name,
);
}
}
}
}
}
fn check_merge_targets() {
for fn_spec in FUNCTIONS {
for merge_target in fn_spec.merge() {
let ctx_entry = match lookup_context(merge_target) {
Some(c) => c,
None => panic!(
"Mizan graph-check: function `{}` declares `merge = \"{}\"` but no context with that name is registered.",
fn_spec.name(),
merge_target,
),
};
let mutation_output = fn_spec.output_type();
let mutation_shape = match resolve_type_shape(mutation_output) {
Some(s) => s,
None => panic!(
"Mizan graph-check: function `{}` has output type `{}` but no such named type is registered.",
fn_spec.name(), mutation_output,
),
};
let mut matches: Vec<&'static str> = Vec::new();
for candidate in FUNCTIONS {
if candidate.context() != Some(ctx_entry.name) {
continue;
}
if let Some(candidate_shape) = resolve_type_shape(candidate.output_type()) {
if types_match(&candidate_shape, &mutation_shape) {
matches.push(candidate.name());
}
}
}
if matches.is_empty() {
panic!(
"Mizan graph-check: function `{}` declares `merge = \"{}\"` but no member of that context has output type `{}`. \
Add a context member returning `{}`, or remove the merge declaration in favor of `affects` for plain refetch.",
fn_spec.name(), merge_target, mutation_output, mutation_output,
);
}
if matches.len() > 1 {
panic!(
"Mizan graph-check: function `{}` declares `merge = \"{}\"` but multiple members ({}) share output type `{}`. \
Merge resolution requires exactly one match. Distinguish the outputs or use `affects` for refetch.",
fn_spec.name(), merge_target, matches.join(", "), mutation_output,
);
}
}
}
}
fn check_shared_param_types() {
for ctx in CONTEXTS {
let mut by_name: std::collections::HashMap<&'static str, (crate::ir::Primitive, &'static str)>
= std::collections::HashMap::new();
for fn_spec in FUNCTIONS {
if fn_spec.context() != Some(ctx.name) {
continue;
}
for p in fn_spec.input_params() {
if let Some((prev_primitive, prev_fn)) = by_name.get(p.name) {
if std::mem::discriminant(prev_primitive)
!= std::mem::discriminant(&p.primitive)
{
panic!(
"Mizan graph-check: context `{}` has a parameter `{}` whose type diverges across members. \
Function `{}` declares it as `{}`, function `{}` declares it as `{}`. \
Shared params must have one type across the whole context.",
ctx.name, p.name,
prev_fn, prev_primitive.name(),
fn_spec.name(), p.primitive.name(),
);
}
} else {
by_name.insert(p.name, (p.primitive, fn_spec.name()));
}
}
}
}
}

View File

@@ -0,0 +1,93 @@
//! IR data model — mirrors `cores/mizan-python/src/mizan_core/ir.py` 1:1.
//!
//! The IR is the contract. Backends emit it; codegen consumes it. The Rust
//! side produces byte-equivalent KDL to the Python emitter against the same
//! function registry.
/// A named type that appears in the IR's `type "<Name>" { ... }` section.
#[derive(Debug, Clone)]
pub enum NamedType {
/// `type "X" { struct { field ... } }` — a Pydantic-model-shaped record.
Struct(Vec<StructField>),
/// `type "X" { alias { <type-child> } }` — a named wrapper around an
/// inline type shape, e.g. `userOrdersOutput = list[OrderOutput]`.
Alias(TypeShape),
/// `type "X" { enum "A" "B" ... }` — a string-literal enum.
Enum(Vec<&'static str>),
}
/// The set of in-place type shapes referenced from struct fields, function
/// inputs/outputs, and alias bodies.
#[derive(Debug, Clone)]
pub enum TypeShape {
Primitive(Primitive),
Ref(&'static str),
List(Box<TypeShape>),
Optional(Box<TypeShape>),
Enum(Vec<&'static str>),
Union(Vec<TypeShape>),
}
#[derive(Debug, Clone, Copy)]
pub enum Primitive {
Integer,
Number,
Boolean,
String,
}
impl Primitive {
pub fn name(self) -> &'static str {
match self {
Primitive::Integer => "integer",
Primitive::Number => "number",
Primitive::Boolean => "boolean",
Primitive::String => "string",
}
}
}
#[derive(Debug, Clone)]
pub struct StructField {
pub name: &'static str,
pub required: bool,
pub default: Option<DefaultValue>,
pub shape: TypeShape,
}
#[derive(Debug, Clone)]
pub enum DefaultValue {
Integer(i64),
Number(f64),
Boolean(bool),
String(&'static str),
Null,
}
/// One descriptor of what a mutation `affects`. Mirrors Python's
/// `_normalize_affects` shape — either a named context or a named function.
#[derive(Debug, Clone)]
pub enum AffectTarget {
Context(&'static str),
Function {
name: &'static str,
context: Option<&'static str>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
Http,
Websocket,
Both,
}
impl Transport {
pub fn name(self) -> &'static str {
match self {
Transport::Http => "http",
Transport::Websocket => "websocket",
Transport::Both => "both",
}
}
}

508
cores/mizan-rust/src/kdl.rs Normal file
View File

@@ -0,0 +1,508 @@
//! KDL emitter — byte-equivalent to `cores/mizan-python/src/mizan_core/ir.py`.
//!
//! The Python emitter is the spec; this is the second implementation under
//! the same contract. Any divergence is a bug here, not a contract change.
use crate::ir::{DefaultValue, NamedType, Primitive, StructField, TypeShape};
use crate::registry::{CONTEXTS, FUNCTIONS, TYPES};
use crate::traits::FunctionSpec;
use std::collections::BTreeMap;
const INDENT: &str = " ";
/// Escape a string for KDL — same escape set as the Python emitter.
fn kdl_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
other => out.push(other),
}
}
out.push('"');
out
}
fn kdl_bool(b: bool) -> &'static str {
if b {
"#true"
} else {
"#false"
}
}
fn kdl_default(v: &DefaultValue) -> String {
match v {
DefaultValue::Null => "#null".into(),
DefaultValue::Boolean(b) => kdl_bool(*b).into(),
DefaultValue::Integer(i) => i.to_string(),
DefaultValue::Number(f) => {
// Match Python's `repr(float)` for whole-number-equal-but-float
// values: e.g. 1.0 → "1.0", not "1".
if f.fract() == 0.0 && f.is_finite() {
format!("{f:.1}")
} else {
f.to_string()
}
}
DefaultValue::String(s) => kdl_string(s),
}
}
/// Convert snake_case to camelCase. Matches Python's `_snake_to_camel`.
pub fn snake_to_camel(name: &str) -> String {
let normalized = name.replace('.', "_").replace('-', "_");
let mut parts = normalized.split('_');
let mut out = String::new();
if let Some(first) = parts.next() {
out.push_str(first);
}
for part in parts {
if part.is_empty() {
continue;
}
let mut chars = part.chars();
if let Some(c) = chars.next() {
out.extend(c.to_uppercase());
out.push_str(chars.as_str());
}
}
out
}
struct Emitter<'a> {
lines: Vec<String>,
/// Types whose references should be substituted with their inline
/// shape at the use site (and which don't emit as their own
/// `type "X" { ... }` entries). Populated from `IrSnapshot::inlines`.
inlines: &'a BTreeMap<&'static str, TypeShape>,
}
impl<'a> Emitter<'a> {
fn new(inlines: &'a BTreeMap<&'static str, TypeShape>) -> Self {
Self {
lines: Vec::new(),
inlines,
}
}
fn prefix(&self, indent: usize) -> String {
INDENT.repeat(indent)
}
fn leaf(&mut self, indent: usize, parts: &[&str]) {
let mut line = self.prefix(indent);
line.push_str(&parts.join(" "));
self.lines.push(line);
}
fn open(&mut self, indent: usize, parts: &[&str]) {
let mut line = self.prefix(indent);
line.push_str(&parts.join(" "));
line.push_str(" {");
self.lines.push(line);
}
fn close(&mut self, indent: usize) {
let mut line = self.prefix(indent);
line.push('}');
self.lines.push(line);
}
fn blank(&mut self) {
self.lines.push(String::new());
}
fn emit_type_child(&mut self, indent: usize, shape: &TypeShape) {
match shape {
TypeShape::Primitive(p) => {
let name = kdl_string(p.name());
self.leaf(indent, &["primitive", &name]);
}
TypeShape::Ref(name) => {
// Inline-substitute when the referenced type is a
// primitive-alias or string-enum. Matches Python's
// Pydantic Literal/alias inlining.
if let Some(inline_shape) = self.inlines.get(name).cloned() {
self.emit_type_child(indent, &inline_shape);
return;
}
let n = kdl_string(name);
self.leaf(indent, &["ref", &n]);
}
TypeShape::List(inner) => {
self.open(indent, &["list"]);
self.emit_type_child(indent + 1, inner);
self.close(indent);
}
TypeShape::Optional(inner) => {
self.open(indent, &["optional"]);
self.emit_type_child(indent + 1, inner);
self.close(indent);
}
TypeShape::Enum(variants) => {
let mut parts: Vec<String> = vec!["enum".into()];
for v in variants {
parts.push(kdl_string(v));
}
let line: Vec<&str> = parts.iter().map(String::as_str).collect();
self.leaf(indent, &line);
}
TypeShape::Union(branches) => {
self.open(indent, &["union"]);
for b in branches {
self.emit_type_child(indent + 1, b);
}
self.close(indent);
}
}
}
fn emit_named_type(&mut self, indent: usize, name: &str, body: &NamedType) {
let name_lit = kdl_string(name);
self.open(indent, &["type", &name_lit]);
match body {
NamedType::Struct(fields) => {
self.open(indent + 1, &["struct"]);
for field in fields {
self.emit_struct_field(indent + 2, field);
}
self.close(indent + 1);
}
NamedType::Alias(inner) => {
self.open(indent + 1, &["alias"]);
self.emit_type_child(indent + 2, inner);
self.close(indent + 1);
}
NamedType::Enum(variants) => {
let mut parts: Vec<String> = vec!["enum".into()];
for v in variants {
parts.push(kdl_string(v));
}
let line: Vec<&str> = parts.iter().map(String::as_str).collect();
self.leaf(indent + 1, &line);
}
}
self.close(indent);
}
fn emit_struct_field(&mut self, indent: usize, field: &StructField) {
let name = kdl_string(field.name);
let mut header: Vec<String> = vec!["field".into(), name];
if !field.required {
header.push(format!("required={}", kdl_bool(false)));
if let Some(default) = &field.default {
header.push(format!("default={}", kdl_default(default)));
}
}
let line_parts: Vec<&str> = header.iter().map(String::as_str).collect();
self.open(indent, &line_parts);
self.emit_type_child(indent + 1, &field.shape);
self.close(indent);
}
fn emit_function(&mut self, indent: usize, fn_spec: &dyn FunctionSpec) {
let name = kdl_string(fn_spec.name());
self.open(indent, &["function", &name]);
let camel = kdl_string(fn_spec.camel_name());
self.leaf(indent + 1, &["camel", &camel]);
self.leaf(indent + 1, &["has-input", kdl_bool(fn_spec.has_input())]);
if let Some(input_type) = fn_spec.input_type() {
let lit = kdl_string(input_type);
self.leaf(indent + 1, &["input", &lit]);
}
let output_lit = kdl_string(fn_spec.output_type());
self.leaf(indent + 1, &["output", &output_lit]);
if fn_spec.output_nullable() {
self.leaf(indent + 1, &["output-nullable", kdl_bool(true)]);
}
let transport_lit = kdl_string(fn_spec.transport().name());
self.leaf(indent + 1, &["transport", &transport_lit]);
if let Some(ctx) = fn_spec.context() {
let lit = kdl_string(ctx);
self.leaf(indent + 1, &["context", &lit]);
}
for affect in fn_spec.affects() {
// Mirror Python's behavior: only context-typed affects make it
// into the KDL `affects` leaf. Function-typed affects are
// reserved for a future IR extension.
if let crate::ir::AffectTarget::Context(name) = affect {
let lit = kdl_string(name);
self.leaf(indent + 1, &["affects", &lit]);
}
}
for merge in fn_spec.merge() {
let lit = kdl_string(merge);
self.leaf(indent + 1, &["merge", &lit]);
}
if fn_spec.is_form() {
self.leaf(indent + 1, &["is-form", kdl_bool(true)]);
if let Some(form_name) = fn_spec.form_name() {
let lit = kdl_string(form_name);
self.leaf(indent + 1, &["form-name", &lit]);
}
if let Some(form_role) = fn_spec.form_role() {
let lit = kdl_string(form_role);
self.leaf(indent + 1, &["form-role", &lit]);
}
}
self.close(indent);
}
fn emit_context(&mut self, indent: usize, ctx_name: &str, members: &[&'static dyn FunctionSpec]) {
let name_lit = kdl_string(ctx_name);
self.open(indent, &["context", &name_lit]);
// Function membership in registration order.
for fn_spec in members {
let lit = kdl_string(fn_spec.name());
self.leaf(indent + 1, &["function", &lit]);
}
// Param info — collect across every member, then emit alphabetized
// by param name to match Python.
struct ParamSlot {
primitive: Primitive,
shared_by: Vec<&'static str>,
}
let mut params: BTreeMap<&'static str, ParamSlot> = BTreeMap::new();
for fn_spec in members {
for p in fn_spec.input_params() {
let slot = params.entry(p.name).or_insert(ParamSlot {
primitive: p.primitive,
shared_by: Vec::new(),
});
slot.primitive = p.primitive;
slot.shared_by.push(fn_spec.name());
}
}
let member_count = members.len();
for (param_name, slot) in params.iter() {
let name_lit = kdl_string(param_name);
self.open(indent + 1, &["param", &name_lit]);
let type_lit = kdl_string(slot.primitive.name());
self.leaf(indent + 2, &["type", &type_lit]);
let required = slot.shared_by.len() == member_count;
self.leaf(indent + 2, &["required", kdl_bool(required)]);
for sharer in &slot.shared_by {
let lit = kdl_string(sharer);
self.leaf(indent + 2, &["shared-by", &lit]);
}
self.close(indent + 1);
}
self.close(indent);
}
fn into_string(mut self) -> String {
// Trim trailing blanks, then add a single terminating newline.
while matches!(self.lines.last(), Some(s) if s.is_empty()) {
self.lines.pop();
}
let mut out = self.lines.join("\n");
out.push('\n');
out
}
}
/// Collected typed registries view used by `build_ir`.
pub(crate) struct IrSnapshot {
pub types: BTreeMap<&'static str, NamedType>,
pub functions: Vec<&'static dyn FunctionSpec>,
pub contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)>,
/// Types that inline to a `TypeShape` at every reference site rather
/// than emitting as their own `type "X" { ... }` entry. Populated from
/// `Alias(Primitive(_))` and `Enum` named types — both are
/// information-zero indirections that the codegen consumer doesn't
/// gain anything from naming. Matches the Python emitter's behavior
/// (Pydantic `FigureId = str` and `Literal["..."]` inline; they don't
/// materialize as named types).
pub inlines: BTreeMap<&'static str, TypeShape>,
}
impl IrSnapshot {
pub(crate) fn collect() -> Self {
// Types: alphabetized for byte-equivalence with Python's `sorted(named_types)`.
let mut all_types: BTreeMap<&'static str, NamedType> = BTreeMap::new();
for entry in TYPES {
all_types.insert(entry.name, (entry.shape_fn)());
}
// Partition into emit-candidate types vs inlines. An inline is a
// named type whose shape collapses to a single `TypeShape` at the
// field site — primitive aliases and string enums.
let mut candidates: BTreeMap<&'static str, NamedType> = BTreeMap::new();
let mut inlines: BTreeMap<&'static str, TypeShape> = BTreeMap::new();
for (name, body) in all_types {
match &body {
NamedType::Alias(TypeShape::Primitive(p)) => {
inlines.insert(name, TypeShape::Primitive(*p));
}
NamedType::Enum(variants) => {
inlines.insert(name, TypeShape::Enum(variants.clone()));
}
_ => {
candidates.insert(name, body);
}
}
}
// Tree-shake: keep only types reachable from a registered function's
// input/output. The function macro registers canonical-named
// entries (e.g. `userPrefsOutput`); derive registers original-named
// entries (`UserPrefs`, `BrushSettings`, …). Only those reached
// via Ref-walk from a function's input/output names belong in the
// emitted IR. Mirrors Python's `_collect_named_types`.
let mut reachable: std::collections::HashSet<&'static str> =
std::collections::HashSet::new();
let mut frontier: Vec<&'static str> = Vec::new();
for fn_spec in FUNCTIONS {
if fn_spec.private() {
continue;
}
if let Some(input_name) = fn_spec.input_type() {
if reachable.insert(input_name) {
frontier.push(input_name);
}
}
let output_name = fn_spec.output_type();
if reachable.insert(output_name) {
frontier.push(output_name);
}
}
while let Some(name) = frontier.pop() {
// Inlines don't carry refs we care about (Primitive/Enum); skip.
if inlines.contains_key(name) {
continue;
}
let body = match candidates.get(name) {
Some(b) => b.clone(),
None => continue,
};
collect_refs(&body, &mut |r| {
if reachable.insert(r) {
frontier.push(r);
}
});
}
let types: BTreeMap<&'static str, NamedType> = candidates
.into_iter()
.filter(|(name, _)| reachable.contains(name))
.collect();
// Functions: alphabetical by wire name (canonical IR ordering,
// matches the Python emitter's `sorted(functions)`). Skip `private`.
let mut functions: Vec<&'static dyn FunctionSpec> = FUNCTIONS
.iter()
.copied()
.filter(|f| !f.private())
.collect();
functions.sort_by_key(|f| f.name());
// Contexts: alphabetical by name (canonical IR ordering), each with
// its members sorted alphabetically too.
let mut context_names: Vec<&'static str> = CONTEXTS.iter().map(|c| c.name).collect();
context_names.sort();
let mut contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)> = Vec::new();
for name in context_names {
let mut members: Vec<&'static dyn FunctionSpec> = functions
.iter()
.copied()
.filter(|f| f.context() == Some(name))
.collect();
members.sort_by_key(|f| f.name());
if !members.is_empty() {
contexts.push((name, members));
}
}
Self {
types,
functions,
contexts,
inlines,
}
}
}
/// Walk every Ref in a NamedType's shape and call `visit` for each name.
fn collect_refs<F: FnMut(&'static str)>(body: &NamedType, visit: &mut F) {
match body {
NamedType::Struct(fields) => {
for field in fields {
walk_shape_refs(&field.shape, visit);
}
}
NamedType::Alias(inner) => walk_shape_refs(inner, visit),
NamedType::Enum(_) => {}
}
}
fn walk_shape_refs<F: FnMut(&'static str)>(shape: &TypeShape, visit: &mut F) {
match shape {
TypeShape::Ref(name) => visit(name),
TypeShape::List(inner) | TypeShape::Optional(inner) => walk_shape_refs(inner, visit),
TypeShape::Union(branches) => {
for b in branches {
walk_shape_refs(b, visit);
}
}
TypeShape::Primitive(_) | TypeShape::Enum(_) => {}
}
}
/// Build the Mizan IR for every registered type/function/context. Returns KDL.
pub fn build_ir() -> String {
crate::graph_check::verify_invariants();
let snap = IrSnapshot::collect();
let mut em = Emitter::new(&snap.inlines);
// Type definitions
let types_emitted = !snap.types.is_empty();
for (name, body) in &snap.types {
em.emit_named_type(0, name, body);
}
if types_emitted {
em.blank();
}
// Functions
let fns_emitted = !snap.functions.is_empty();
for fn_spec in &snap.functions {
em.emit_function(0, *fn_spec);
}
if fns_emitted {
em.blank();
}
// Contexts
let ctxs_emitted = !snap.contexts.is_empty();
for (ctx_name, members) in &snap.contexts {
em.emit_context(0, ctx_name, members);
}
if ctxs_emitted {
em.blank();
}
// Future: channels — once channel registry lands on the Rust side.
em.into_string()
}

View File

@@ -0,0 +1,57 @@
//! Mizan server-side IR substrate. Rust analog of `cores/mizan-python/src/mizan_core/`.
//!
//! Three load-bearing concerns:
//!
//! 1. **IR data model + KDL emitter.** `build_ir()` produces byte-equivalent
//! KDL to the Python emitter. Both backends emit the same contract.
//! 2. **Compile-time registry.** Proc macros from `mizan-macros` populate
//! linkme distributed slices (`TYPES`, `CONTEXTS`, `FUNCTIONS`) at the
//! consumer crate's expansion sites.
//! 3. **Runtime helpers.** `compute_invalidation` / `compute_merges` /
//! `lookup_function` ported from `mizan-fastapi`'s executor; the HTTP
//! adapter calls these per request.
//!
//! Consumers `use mizan_core::prelude::*;` and alias the crate as `mizan` at
//! their call sites so authored code reads `#[mizan::context]` / `#[mizan(...)]`.
pub mod graph_check;
pub mod ir;
pub mod kdl;
pub mod registry;
pub mod runtime;
pub mod traits;
pub use ir::{
AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape,
};
pub use kdl::{build_ir, snake_to_camel};
pub use registry::{
context_members, lookup_context, lookup_function, ContextEntry, TypeEntry, CONTEXTS,
FUNCTIONS, TYPES,
};
pub use runtime::{
compute_invalidation, compute_merges, InvalidationTarget, MergeEntry, MizanError,
RequestHandle,
};
pub use traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
// Re-export proc macros so consumers depend on one crate.
pub use mizan_macros::{client, context, Mizan};
pub mod prelude {
pub use crate::ir::{
AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape,
};
pub use crate::registry::{ContextEntry, TypeEntry};
pub use crate::runtime::{MizanError, RequestHandle};
pub use crate::traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
pub use mizan_macros::Mizan;
}
/// Internal re-exports used by `mizan-macros`-generated code. Not part of
/// the public API — consumers must not depend on names under `__priv`.
#[doc(hidden)]
pub mod __priv {
pub use linkme;
pub use serde_json;
}

View File

@@ -0,0 +1,47 @@
//! Compile-time-populated registries, distributed across the consuming crate's
//! source via linkme. The proc macros emit `#[linkme::distributed_slice(...)]`
//! statics that land here at link time.
use crate::ir::NamedType;
use crate::traits::FunctionSpec;
use linkme::distributed_slice;
/// One named-type registration. Emitted by `#[derive(Mizan)]`.
pub struct TypeEntry {
pub name: &'static str,
pub shape_fn: fn() -> NamedType,
}
/// One context-marker registration. Emitted by `#[mizan::context]`.
pub struct ContextEntry {
pub name: &'static str,
}
#[distributed_slice]
pub static TYPES: [TypeEntry] = [..];
#[distributed_slice]
pub static CONTEXTS: [ContextEntry] = [..];
#[distributed_slice]
pub static FUNCTIONS: [&'static dyn FunctionSpec] = [..];
/// Find a registered function by wire name. Used by the HTTP adapter.
pub fn lookup_function(name: &str) -> Option<&'static dyn FunctionSpec> {
FUNCTIONS.iter().copied().find(|f| f.name() == name)
}
/// Find a registered context by name. Used by graph_check.
pub fn lookup_context(name: &str) -> Option<&'static ContextEntry> {
CONTEXTS.iter().find(|c| c.name == name)
}
/// All functions that declare a given context as their `context` membership.
/// Order matches `FUNCTIONS` iteration order — i.e., registration order.
pub fn context_members(ctx_name: &str) -> Vec<&'static dyn FunctionSpec> {
FUNCTIONS
.iter()
.copied()
.filter(|f| f.context() == Some(ctx_name))
.collect()
}

View File

@@ -0,0 +1,260 @@
//! Runtime helpers — error envelope, request handle, invalidation/merge
//! resolution. Ports `compute_invalidation` / `compute_merges` /
//! `_resolve_merge_slot` / `_scoped_params` from
//! `backends/mizan-fastapi/src/mizan_fastapi/executor.py:189-263`.
use crate::registry::context_members;
use crate::traits::FunctionSpec;
use serde_json::Value;
use std::any::Any;
/// Type-erased handle to the framework's request object. The HTTP adapter
/// stuffs its native `Request` here; user code casts back via the adapter's
/// helper types.
#[derive(Clone)]
pub struct RequestHandle<'a> {
pub inner: &'a (dyn Any + Send + Sync),
}
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>()
}
}
/// Mizan's standard error envelope. Mirrors FastAPI's MizanError enum.
#[derive(Debug, Clone)]
pub enum MizanError {
NotFound(String),
BadRequest(String),
ValidationFailed {
message: String,
details: Value,
},
Unauthorized(String),
Forbidden(String),
NotImplementedYet(String),
InternalError(String),
}
impl MizanError {
pub fn code(&self) -> &'static str {
match self {
MizanError::NotFound(_) => "NOT_FOUND",
MizanError::BadRequest(_) => "BAD_REQUEST",
MizanError::ValidationFailed { .. } => "VALIDATION_FAILED",
MizanError::Unauthorized(_) => "UNAUTHORIZED",
MizanError::Forbidden(_) => "FORBIDDEN",
MizanError::NotImplementedYet(_) => "NOT_IMPLEMENTED",
MizanError::InternalError(_) => "INTERNAL_ERROR",
}
}
pub fn message(&self) -> &str {
match self {
MizanError::NotFound(m)
| MizanError::BadRequest(m)
| MizanError::Unauthorized(m)
| MizanError::Forbidden(m)
| MizanError::NotImplementedYet(m)
| MizanError::InternalError(m) => m,
MizanError::ValidationFailed { message, .. } => message,
}
}
pub fn http_status(&self) -> u16 {
match self {
MizanError::NotFound(_) => 404,
MizanError::BadRequest(_) => 400,
MizanError::ValidationFailed { .. } => 422,
MizanError::Unauthorized(_) => 401,
MizanError::Forbidden(_) => 403,
MizanError::NotImplementedYet(_) => 501,
MizanError::InternalError(_) => 500,
}
}
/// JSON envelope shape consumers see on the wire.
pub fn to_json(&self) -> Value {
let mut body = serde_json::Map::new();
body.insert("code".into(), Value::String(self.code().into()));
body.insert("message".into(), Value::String(self.message().into()));
if let MizanError::ValidationFailed { details, .. } = self {
body.insert("details".into(), details.clone());
}
Value::Object({
let mut env = serde_json::Map::new();
env.insert("error".into(), Value::Object(body));
env
})
}
}
/// One entry in the response's `invalidate` array.
#[derive(Debug, Clone)]
pub enum InvalidationTarget {
/// A whole context is invalidated.
Context(String),
/// A context, scoped to specific param values.
ScopedContext {
context: String,
params: serde_json::Map<String, Value>,
},
/// A specific function output is invalidated.
Function(String),
}
impl InvalidationTarget {
pub fn to_json(&self) -> Value {
match self {
InvalidationTarget::Context(name) => Value::String(name.clone()),
InvalidationTarget::ScopedContext { context, params } => {
let mut m = serde_json::Map::new();
m.insert("context".into(), Value::String(context.clone()));
m.insert("params".into(), Value::Object(params.clone()));
Value::Object(m)
}
InvalidationTarget::Function(name) => {
let mut m = serde_json::Map::new();
m.insert("function".into(), Value::String(name.clone()));
Value::Object(m)
}
}
}
}
/// One entry in the response's `merge` array. Server-resolved slot — the
/// kernel writes the value into `bundle[slot]` directly.
#[derive(Debug, Clone)]
pub struct MergeEntry {
pub context: String,
pub slot: String,
pub value: Value,
pub params: Option<serde_json::Map<String, Value>>,
}
impl MergeEntry {
pub fn to_json(&self) -> Value {
let mut m = serde_json::Map::new();
m.insert("context".into(), Value::String(self.context.clone()));
m.insert("slot".into(), Value::String(self.slot.clone()));
m.insert("value".into(), self.value.clone());
if let Some(params) = &self.params {
m.insert("params".into(), Value::Object(params.clone()));
}
Value::Object(m)
}
}
/// Build the `invalidate` list from a function's `affects` metadata,
/// auto-scoping when arg names match context params.
pub fn compute_invalidation(
fn_spec: &dyn FunctionSpec,
args: &serde_json::Map<String, Value>,
) -> Vec<InvalidationTarget> {
fn_spec
.affects()
.iter()
.map(|target| match target {
crate::ir::AffectTarget::Context(name) => {
let scoped = scoped_params(name, args);
if scoped.is_empty() {
InvalidationTarget::Context((*name).into())
} else {
InvalidationTarget::ScopedContext {
context: (*name).into(),
params: scoped,
}
}
}
crate::ir::AffectTarget::Function { name, .. } => {
InvalidationTarget::Function((*name).into())
}
})
.collect()
}
/// Build the `merge` list from a function's `merge` metadata. Each entry
/// names the slot inside the context bundle the return value lands in.
pub fn compute_merges(
fn_spec: &dyn FunctionSpec,
args: &serde_json::Map<String, Value>,
result: &Value,
) -> Vec<MergeEntry> {
let targets = fn_spec.merge();
if targets.is_empty() {
return Vec::new();
}
let mutation_output = fn_spec.output_type();
let mut out = Vec::new();
for ctx_name in targets {
let slot = match resolve_merge_slot(ctx_name, mutation_output) {
Some(s) => s,
None => continue,
};
let scoped = scoped_params(ctx_name, args);
out.push(MergeEntry {
context: (*ctx_name).into(),
slot,
value: result.clone(),
params: if scoped.is_empty() {
None
} else {
Some(scoped)
},
});
}
out
}
/// Find the unique function-name slot whose Output type matches the
/// mutation's Output type. Matches Python's `types_match_for_merge` —
/// structural shape comparison, not name comparison. Returns None on no
/// match or ambiguous match.
fn resolve_merge_slot(context_name: &str, mutation_output: &str) -> Option<String> {
let mutation_shape = crate::graph_check::resolve_type_shape(mutation_output)?;
let mut matches: Vec<&'static str> = Vec::new();
for fn_spec in context_members(context_name) {
if let Some(candidate_shape) = crate::graph_check::resolve_type_shape(fn_spec.output_type())
{
if crate::graph_check::types_match(&candidate_shape, &mutation_shape) {
matches.push(fn_spec.name());
}
}
}
if matches.len() == 1 {
Some(matches[0].into())
} else {
None
}
}
/// Match input args against the context's declared Input field names.
fn scoped_params(
context_name: &str,
args: &serde_json::Map<String, Value>,
) -> serde_json::Map<String, Value> {
let mut declared: std::collections::HashSet<&'static str> = std::collections::HashSet::new();
for fn_spec in context_members(context_name) {
for p in fn_spec.input_params() {
declared.insert(p.name);
}
}
args.iter()
.filter(|(k, _)| declared.contains(k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}

View File

@@ -0,0 +1,92 @@
//! Surface traits the proc macros implement.
use crate::ir::{AffectTarget, NamedType, Transport};
use crate::runtime::{MizanError, RequestHandle};
use serde_json::Value;
use std::future::Future;
use std::pin::Pin;
/// A type that participates in the Mizan IR. Generated by `#[derive(Mizan)]`.
///
/// `TYPE_NAME` is a `const` (not a function) so it's usable in `static`
/// initializers — TypeEntry's `name` field reads it directly without an
/// init-time function call.
pub trait MizanType {
const TYPE_NAME: &'static str;
fn shape() -> NamedType;
fn type_name() -> &'static str {
Self::TYPE_NAME
}
}
/// A marker type for a Mizan context. Generated by `#[mizan::context]`.
pub trait ContextMarker {
const NAME: &'static str;
}
/// One Mizan-registered function. Generated by `#[mizan(...)]` on async fns.
///
/// Everything here is plain data except `dispatch`, which is the type-erased
/// runtime entry point used by the HTTP adapter.
pub trait FunctionSpec: Send + Sync {
fn name(&self) -> &'static str;
fn camel_name(&self) -> &'static str;
fn has_input(&self) -> bool;
fn input_type(&self) -> Option<&'static str>;
fn output_type(&self) -> &'static str;
fn output_nullable(&self) -> bool {
false
}
fn context(&self) -> Option<&'static str> {
None
}
fn affects(&self) -> &'static [AffectTarget] {
&[]
}
fn merge(&self) -> &'static [&'static str] {
&[]
}
fn transport(&self) -> Transport {
Transport::Http
}
fn private(&self) -> bool {
false
}
fn is_form(&self) -> bool {
false
}
fn form_name(&self) -> Option<&'static str> {
None
}
fn form_role(&self) -> Option<&'static str> {
None
}
/// Field-shape description of this function's Input parameters, used by
/// the context builder to compute shared-param elevation. Empty when
/// `has_input()` is false.
fn input_params(&self) -> &'static [InputParam] {
&[]
}
/// Type-erased dispatch. The HTTP adapter calls this with deserialized
/// JSON arguments; the macro-generated impl deserializes into the
/// function's typed input, awaits the body, and serializes the result.
fn dispatch<'a>(
&'a self,
req: RequestHandle<'a>,
args: Value,
) -> Pin<Box<dyn Future<Output = Result<Value, MizanError>> + Send + 'a>>;
}
/// One parameter of a function's synthesized Input. The macro emits a static
/// slice of these so the context builder can find shared params across
/// context members and produce the `context { param ... shared-by ... }`
/// section of the IR.
#[derive(Debug, Clone, Copy)]
pub struct InputParam {
pub name: &'static str,
pub primitive: crate::ir::Primitive,
pub required: bool,
}

View File

@@ -0,0 +1,129 @@
//! Byte-equivalence: the Rust KDL emitter (driven by the proc macros)
//! against `protocol/mizan-codegen/tests/fixtures/afi_ir.kdl` (canonical
//! Python-emitted reference).
//!
//! This is the Phase-2 verifier — the AFI fixture is authored against the
//! real consumer surface (`#[derive(Mizan)] / #[mizan::context] /
//! #[mizan::client]`), not hand-built static specs.
use mizan_core as mizan;
use mizan_core::prelude::*;
use mizan_core::RequestHandle;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
// ─── Output / shared types ──────────────────────────────────────────────────
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct EchoOutput {
pub message: String,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct WhoamiOutput {
pub email: String,
pub authenticated: bool,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct ProfileOutput {
pub user_id: i64,
pub name: String,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct OrderOutput {
pub id: i64,
pub user_id: i64,
pub total: i64,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct StatusOutput {
pub ok: bool,
}
#[mizan::context("user")]
pub struct UserCtx;
// ─── Fixture functions (mirroring tests/afi/fixture.py) ────────────────────
#[mizan::client]
pub async fn echo(_req: &RequestHandle<'_>, text: String) -> EchoOutput {
EchoOutput {
message: format!("echo: {text}"),
}
}
#[mizan::client]
pub async fn whoami(_req: &RequestHandle<'_>) -> WhoamiOutput {
WhoamiOutput {
email: "anon@example.com".into(),
authenticated: false,
}
}
#[mizan::client(context = UserCtx)]
pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput {
ProfileOutput {
user_id,
name: "placeholder".into(),
}
}
#[mizan::client(context = UserCtx)]
pub async fn user_orders(_req: &RequestHandle<'_>, _user_id: i64) -> Vec<OrderOutput> {
vec![]
}
#[mizan::client(affects = UserCtx)]
pub async fn update_profile(
_req: &RequestHandle<'_>,
_user_id: i64,
_name: String,
) -> StatusOutput {
StatusOutput { ok: true }
}
#[mizan::client]
pub async fn find_user(_req: &RequestHandle<'_>, _user_id: i64) -> Option<ProfileOutput> {
None
}
#[mizan::client(merge = UserCtx)]
pub async fn rename_user(
_req: &RequestHandle<'_>,
user_id: i64,
name: String,
) -> ProfileOutput {
ProfileOutput { user_id, name }
}
// ─── The byte-equivalence test ──────────────────────────────────────────────
fn canonical_kdl_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../protocol/mizan-codegen/tests/fixtures/afi_ir.kdl")
}
#[test]
fn build_ir_matches_canonical_afi_kdl() {
let expected = std::fs::read_to_string(canonical_kdl_path()).expect("read canonical KDL");
let actual = mizan_core::build_ir();
if actual != expected {
for (lineno, (a, b)) in actual.lines().zip(expected.lines()).enumerate() {
if a != b {
panic!(
"KDL diverges at line {}:\n expected: {b:?}\n actual: {a:?}",
lineno + 1,
);
}
}
panic!(
"KDL diverges in length: actual_len={} expected_len={}",
actual.len(),
expected.len(),
);
}
}

View File

@@ -11,16 +11,29 @@ Tree organized by role.
backends/ server protocol adapters
mizan-django/ Django adapter
mizan-fastapi/ FastAPI adapter (AFI-common scope)
mizan-rust-axum/ Rust/Axum adapter (handlers, errors, IR export)
mizan-tauri/ Tauri adapter — Mizan calls served in-process
mizan-ts/ TypeScript adapter (proves the protocol is language-agnostic)
frontends/ client kernel + per-framework adapters
mizan-base/ framework-agnostic kernel; owns data, status, error; adapters subscribe
frontends/ client kernel + per-framework adapters + transports
mizan-base/ framework-agnostic kernel (@mizan/base); owns data, status,
error; adapters subscribe through the MizanTransport interface
mizan-react/ React contexts + hooks over the kernel
mizan-vue/ Vue composables over the kernel
mizan-svelte/ Svelte stores/runes over the kernel
mizan-rust/ Rust client kernel
mizan-tauri-transport/ MizanTransport over Tauri IPC
mizan-webview-transport/ MizanTransport over a webview message channel
mizan-webview-channels/ channel transport over a webview bridge
cores/ shared language-level primitives
mizan-python/ @client decorator, registry, MWT, HMAC cache keys
mizan-rust/ Rust core — IR build (build_ir()), registry
mizan-rust-macros/ #[derive(Mizan)] / #[mizan::client] proc-macros
protocol/ protocol-level tooling
mizan-generate/ codegen — schema in, typed client out
mizan-codegen/ codegen — Rust binary (crate `mizan-codegen`); reads KDL IR,
emits typed clients. Targets: stage1, react, vue, svelte,
channels, python, rust. Askama templates under templates/.
mizan-generate/ thin npm-package launcher (bin/launcher.mjs) dispatching to
the compiled mizan-codegen binary per platform
workers/ runtime workers / bridges
mizan-ssr/ Bun subprocess used by the Django template backend
```
@@ -35,11 +48,16 @@ compose.
## Kernel model
The client kernel (`mizan-base`) is the one hard thing. Per-
framework adapters are thin idiomatic wrappers around it. Codegen
emits typed bindings against the framework adapter's surface, not
against the raw kernel — so a React developer gets `useEcho()` and
`<MizanContext>`, a Vue developer gets `useEcho()` composables, a
The client kernel (`@mizan/base`) is the one hard thing. It owns
`ContextState<T> = {data, status, error}`, the context registry
(`registerContext`), `mizanCall` / `mizanFetch`, server-driven `merge`
and `invalidate`, and `initSession`. It reaches the backend through a
pluggable `MizanTransport` (`call` / `fetch`); the default is the
HTTP `httpTransport()`, swapped via `configure({ transport })` for
Tauri / webview hosts. Per-framework adapters are thin idiomatic
wrappers that subscribe to the kernel. Codegen emits typed bindings
against the framework adapter's surface — a React developer gets
`useEcho()` hooks, a Vue developer gets `useEcho()` composables, a
Svelte developer gets readable stores. Same kernel underneath.
## KDL is the IR
@@ -56,20 +74,23 @@ divergence between adapters is what the IR exists to prevent.
Forward-direction primitives:
- `cores/mizan-python` builds the IR from registered functions
(`build_ir()` walks `mizan_core.registry`, emits KDL)
- A `mizan-schema` package (forthcoming) holds the canonical KDL
grammar / type system definition that every adapter targets
- Each backend adapter emits KDL on stdout from an IR-export command:
FastAPI `python -m mizan_fastapi.ir <module>`, Django
`python manage.py export_mizan_ir`, Rust a consumer-side cargo bin
that calls `mizan_core::build_ir()`. Python's `build_ir()` walks
`mizan_core.registry`. The IR grammar (`type` / `function` /
`context` / `channel` nodes) is parsed by `mizan-codegen`'s
`src/ir.rs`; fixtures live at
`protocol/mizan-codegen/tests/fixtures/*.kdl`.
- `protocol/mizan-codegen/src/fetch.rs` spawns the configured source
command and parses the KDL it writes.
- Codegen reads KDL directly — no OpenAPI envelope, no
`openapi-typescript`, no per-backend converter divergence
- Edge manifest, MWT claims, and other protocol artifacts all derive
from the same KDL
**Current implementation is transitional.** Today the codegen consumes
OpenAPI 3.0 (`x-mizan-functions` + `x-mizan-contexts` extensions over
Pydantic→JSON-Schema), produced via Django Ninja or FastAPI's native
generator. That layered indirection is what introduces adapter
divergence (see the AFI conformance suite). KDL-as-IR collapses it.
`openapi-typescript`, no per-backend converter divergence. The
former JavaScript/Node two-stage codegen (`openapi-typescript` plus
`.mjs` adapters) has been deleted; codegen is now the single Rust
binary.
- Edge manifest, MWT claims, and other protocol artifacts derive from
the same registry/IR.
## Launch surface

View File

@@ -16,14 +16,22 @@ standardized replacement exists.
## Resolution: HMAC cache key (JSON-canonical form)
```
HMAC-SHA256(secret, JSON.stringify({
ctx:{context}:HMAC-SHA256(secret, json.dumps({
"c": context,
"p": sorted_params,
"p": sorted_params, // values normalized to JSON-native strings
"r": rev,
"u": user_id // omitted for public content
}, sort_keys=True))
}, sort_keys=True, separators=(",", ":")))
```
`derive_cache_key(secret, context, params, user_id=None, rev=0)`
`"ctx:{context}:{hmac_hex}"`. The `ctx:{context}:` prefix lets broad
purge SCAN by prefix. Param values are normalized for cross-language
consistency (`True``"true"`, `None``"null"`) before stringification.
Implemented in `cores/mizan-python/src/mizan_core/cache/keys.py` and
`backends/mizan-ts/src/cache/keys.ts` (`deriveCacheKey`); pin tests
verify identical output.
### Key derivation rules
- **Public content** — URL path + query params (standard CDN).
@@ -45,20 +53,24 @@ Mizan claims on `X-Mizan-Token` header. Replaces the old
**Not a compiled binary ABI. Not a pluggable Python protocol.**
Each backend adapter (Python, TypeScript, future PHP/C#/Go)
implements the cache protocol in its own language, backed by Redis.
implements the cache protocol in its own language.
**Conformance verified by a shared test suite.**
### Required operations
- `cache_get`
- `cache_put`
- `cache_purge`
- `cache_purge_user`
- `cache_purge` (scoped recomputes the key; broad SCANs the
`ctx:{context}:*` prefix)
### Storage
Redis only. Handles persistence, cross-worker sharing, crash
recovery.
Two backends behind a `CacheBackend` protocol
(`mizan_core/cache/backend.py`):
- `MemoryCache` — dict-based, for testing.
- `RedisCache` — production; persistence, cross-worker sharing, crash
recovery. Broad purge via SCAN, delete via UNLINK.
## Deploy invalidation

View File

@@ -15,8 +15,10 @@ detection.
| `pkey` | Deterministic hash of user's permission state at issuance |
| `exp` | Configurable short TTL — controls permission staleness window (Django setting) |
| `iat` | Issued at |
| `kid` | Key ID — for secret rotation |
| `kid` | Key ID — for secret rotation. Carried in the JOSE header (RFC 7515), not the payload |
| `aud` | Audience binding — prevents cross-tenant replay |
| `nbf` | Not-before — tolerates clock skew |
| `staff` / `super` | `is_staff` / `is_superuser`, used to build `MWTUser` without a DB query |
## Key decisions
@@ -25,10 +27,14 @@ detection.
- **`X-Mizan-Token` header, not `Authorization: Bearer`.** Avoids
collision with DRF, allauth, and existing JWT systems. Cloudflare
WAF/Access do not inspect custom headers.
- **Replaces `JWTUser` + `_try_jwt_auth` entirely.** Old approach is
deleted.
- **`MWTUser`** is a minimal, DB-free request user built from the
token claims (`cores/mizan-python/src/mizan_core/mwt.py`).
> A separate JWT module (`mizan/jwt/`) still exists for standard
> user-auth access/refresh tokens; MWT is the cache-keying identity
> layer, not a replacement for that module.
- **App handles authentication** (session, social, etc.). Mizan
issues MWT *from* the authenticated identity.
issues MWT *from* the authenticated identity
(`create_mwt(user, secret, ttl, audience, kid)`).
- **Edge Worker** validates MWT, extracts `sub` for HMAC cache key,
checks `exp`.
- **`pkey` computation must be deterministic:**
@@ -43,9 +49,11 @@ detection.
JSON with sorted keys:
```
HMAC(secret, JSON.stringify({"c": context, "p": sorted_params, "u": user_id}))
ctx:{context}:HMAC(secret, JSON.stringify({"c": context, "p": sorted_params, "r": rev, "u": user_id}))
```
See [CACHE_KEYING.md](CACHE_KEYING.md) for the full derivation.
## What this solves
- DRF token collision
@@ -55,5 +63,8 @@ HMAC(secret, JSON.stringify({"c": context, "p": sorted_params, "u": user_id}))
## Usage rule
All cache-layer auth code uses MWT, not Django session or raw JWT.
The `@client(auth=...)` parameter gates on MWT validity.
MWT is the identity Edge/cache layers key on. The `@client(auth=...)`
parameter is enforced server-side in `mizan/client/executor.py`
(`_check_auth_requirement`), which checks `request.user` against the
auth requirement (`required` / `staff` / `superuser` / callable);
`request.user` may be an `MWTUser` (stateless) or a session user.

View File

@@ -22,17 +22,16 @@ multi-state privacy. ~$58K legal costs.
TS "Deploy" exists via Workers for Platforms at no additional
compliance cost.
## Free framework: mizan-cache (origin-side cache)
## Free framework: origin-side cache (`mizan.cache`)
Python package implementing the **full cache protocol locally**
same HMAC key derivation, metadata schema, and purge semantics as
Edge.
Shipped in `mizan_core.cache` (re-exported as `mizan.cache` from the
Django adapter) implementing the **full cache protocol locally**
same HMAC key derivation and purge semantics as Edge.
Three backends:
Two backends behind a `CacheBackend` protocol:
- In-memory dict (default)
- Redis
- SQLite
- `MemoryCache` — in-memory dict (testing)
- `RedisCache` — production
### Dual purpose
@@ -44,8 +43,8 @@ Three backends:
## Spec additions
- `@client(cache=False)` — uncacheable; emits `Cache-Control: no-store`.
- Cache ABI: `get(key)`, `put(key, response, metadata)`,
`purge(context, params)`.
- Cache ABI (`mizan.cache`): `cache_get(secret, backend, context, params)`,
`cache_put(...)`, `cache_purge(backend, context, params=…, secret=…)`.
## Launch compliance (Render only)

View File

@@ -14,6 +14,15 @@ Works on a $5 VPS with local Bun. **No Edge required.** PSR is part
of the protocol; it's available to every Mizan deployment regardless
of hosting.
> Current state: the Edge manifest records each context's
> `render_strategy` (`"psr"` for public, `"dynamic_cached"` for
> user-scoped) — see `mizan/export/` and the `export_edge_manifest`
> management command — and the SSR bridge can render a component to
> HTML. The render-on-mutation orchestration that wires those together
> (mutation → trigger local render → store HTML) is not yet present in
> the open-source backends; it is the manifest-driven behavior the
> Edge layer consumes.
## Edge Delivery — Mizan Render (Paid Product)
Pre-rendered HTML cached globally on Cloudflare CDN.

View File

@@ -10,23 +10,31 @@ rendering engine.
TEMPLATES = [
{
'BACKEND': 'mizan.ssr.MizanTemplates',
...
'DIRS': [BASE_DIR / 'frontend'],
'OPTIONS': {
'worker': 'path/to/mizan-ssr/src/worker.tsx',
'timeout': 5,
},
}
]
```
Then `render(request, 'ProfilePage', context)` calls the Bun
Then `render(request, 'components/Hello.tsx', context)` calls the Bun
subprocess bridge instead of rendering a Django/Jinja2 template.
**The component name IS the template name.**
**The template name IS a `.tsx`/`.jsx` file path**, resolved against
`DIRS`; `get_template` returns a `MizanTemplate` wrapping the absolute
file path. The context dict becomes the component's props (`request`
and `csrf_token` stripped). Rendered output is wrapped in
`<div id="mizan-root">…</div>` plus a
`<script>window.__MIZAN_SSR_DATA__=…</script>` hydration payload.
## AFI boundary
| Side | Responsibility |
|---|---|
| Backend adapter | Implements `mizan.ssr()` — executes context functions, gathers data |
| Frontend adapter | Implements `renderToHTML()` — takes component + props, produces HTML |
| Bun subprocess | Hosts the frontend adapter |
| stdin/stdout JSON-RPC | Transport between the two |
| Backend adapter (`SSRBridge`) | Manages the Bun subprocess lifecycle; gathers props |
| Bun worker (`worker.tsx`) | `import()`s the file path, `renderToString(createElement(Component, props))` |
| stdin/stdout JSON-RPC | Newline-delimited; `{id, method:"render", params:{file, props}}``{id, html}` / `{id, error}`; `ping``{id, pong:true}` |
## Why template backend
@@ -35,16 +43,22 @@ subprocess bridge instead of rendering a Django/Jinja2 template.
- Django developers already use `render(request, template, context)`
— no new API to learn.
- URL routing, views, middleware, auth — all unchanged.
- The template tag `{% mizan_render %}` is a convenience for
developers who *also* use Django templates (e.g., a base.html shell
with Mizan components inside).
> A `templatetags/` package exists for a future `{% mizan_render %}`
> convenience tag (base.html shell with Mizan components inside), but
> it is currently empty — no tag is implemented yet.
## Implementation surface
The SSR bridge module implements Django's template backend interface:
The SSR backend (`mizan/ssr/backend.py`) implements Django's template
backend interface:
- `BaseEngine` subclass
- `Template` class with `.render(context, request)`
- `MizanTemplates(BaseEngine)` — requires `OPTIONS['worker']` (path to
`worker.tsx`); `get_template(name)` resolves a file under `DIRS`
- `MizanTemplate` with `.render(context, request)` → calls the bridge
- `SSRBridge` (`bridge.py`) — spawns `bun run <worker>`, holds the
persistent subprocess, correlates requests by message id, thread-safe,
auto-restarts on crash, waits for the worker's ready signal
Everything Django expects from a template backend, but the actual
rendering routes to Bun.

View File

@@ -22,7 +22,7 @@ import {
type ContextState,
} from '@mizan/base'
import { fetchGlobalContext, type GlobalContextData, type GlobalContextParams, fetchLocalContext, type LocalContextData, type LocalContextParams, callEcho, callAdd, callWhoami, callHttpOnlyEcho, callStaffOnly, callSuperuserOnly, callVerifiedOnly, callMultiply, callNotImplementedFn, callBuggyFn, callPermissionCheckFn, callWsWhoami, callJwtObtain, callJwtRefresh } from './index'
import { fetchGlobalContext, type GlobalContextData, type GlobalContextParams, fetchLocalContext, type LocalContextData, type LocalContextParams, callEcho, callAdd, callWhoami, callHttpOnlyEcho, callStaffOnly, callSuperuserOnly, callVerifiedOnly, callMultiply, callNotImplementedFn, callBuggyFn, callPermissionCheckFn, callWsWhoami, callJwtObtain, callJwtRefresh, type currentUserOutput, type greetOutput } from './index'
// Internal — runs inside a Provider, registers with the kernel exactly once.
function useContextSubscription<T>(
@@ -174,6 +174,8 @@ export function useJwtRefresh() {
export interface MizanContextProps {
/** Base URL for protocol endpoints. Defaults to "/api/mizan". */
baseUrl?: string
/** Set to `false` for backends without a `/session/` endpoint (e.g. FastAPI). */
session?: boolean
children: ReactNode
}
@@ -181,10 +183,13 @@ export interface MizanContextProps {
* Root provider — calls configure() once and mounts the global context (if defined).
* Must wrap any component using Mizan-generated hooks.
*/
export function MizanContext({ baseUrl, children }: MizanContextProps) {
export function MizanContext({ baseUrl, session, children }: MizanContextProps) {
const configured = useRef(false)
if (!configured.current) {
if (baseUrl) configure({ baseUrl })
const opts: Parameters<typeof configure>[0] = {}
if (baseUrl !== undefined) opts.baseUrl = baseUrl
if (session !== undefined) opts.session = session
if (Object.keys(opts).length > 0) configure(opts)
configured.current = true
}
return <GlobalContextProvider>{children}</GlobalContextProvider>

View File

@@ -10,11 +10,8 @@ export default defineConfig({
alias: {
'mizan/channels': path.join(reactPkg, 'channels/index.ts'),
'mizan/client/react': path.join(reactPkg, 'client/react.ts'),
'mizan/client/nextjs': path.join(reactPkg, 'client/nextjs.tsx'),
'mizan/client': path.join(reactPkg, 'client/index.ts'),
'mizan/jwt': path.join(reactPkg, 'jwt/index.ts'),
'mizan/allauth/nextjs': path.join(reactPkg, 'allauth/nextjs.tsx'),
'mizan/allauth': path.join(reactPkg, 'allauth/index.ts'),
'mizan': path.join(reactPkg, 'index.ts'),
'@rythazhur/mizan/channels': path.join(reactPkg, 'channels/index.ts'),
'@rythazhur/mizan/jwt': path.join(reactPkg, 'jwt/index.ts'),

View File

@@ -142,6 +142,58 @@ def current_user(request) -> UserOutput:
)
# ─── Merge protocol fixtures ────────────────────────────────────────────────
class MorphGroupMeta(BaseModel):
"""Group summary — narrower shape than MorphLayer. Listed alongside
morph_layers so the server's slot resolver has to discriminate by
return-type rather than by bundle order."""
id: int
label: str
count: int
class MorphLayer(BaseModel):
id: int
group_id: int
label: str
value: float
_morph_groups: list[MorphGroupMeta] = [
MorphGroupMeta(id=1, label="face", count=2),
]
_morph_layers: list[MorphLayer] = [
MorphLayer(id=1, group_id=1, label="brow", value=0.0),
MorphLayer(id=2, group_id=1, label="jaw", value=0.0),
]
@client(context="morphs")
def morph_groups(request) -> list[MorphGroupMeta]:
"""Summary-shape slot — server must route MorphLayer mutations away from here."""
return list(_morph_groups)
@client(context="morphs")
def morph_layers(request) -> list[MorphLayer]:
"""Detailed-shape slot — server routes MorphLayer mutations here."""
return list(_morph_layers)
@client(merge="morphs")
def set_morph_value(request, id: int, value: float) -> MorphLayer:
"""Mutation that returns the changed row; kernel splices into morph_layers."""
for layer in _morph_layers:
if layer.id == id:
layer.value = value
return layer
raise ValueError(f"unknown morph layer id={id}")
# ─── Registration ───────────────────────────────────────────────────────────
@@ -156,6 +208,9 @@ register(not_implemented_fn, "not_implemented_fn")
register(buggy_fn, "buggy_fn")
register(permission_check_fn, "permission_check_fn")
register(current_user, "current_user")
register(morph_groups, "morph_groups")
register(morph_layers, "morph_layers")
register(set_morph_value, "set_morph_value")
# ─── App ────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,16 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanFetch } from '@mizan/base'
import type { morphGroupsOutput, morphLayersOutput } from '../types'
export interface MorphsContextData {
morph_groups: morphGroupsOutput
morph_layers: morphLayersOutput
}
export type MorphsContextParams = Record<string, never>
export function fetchMorphsContext(params: MorphsContextParams): Promise<MorphsContextData> {
return mizanFetch('morphs', params)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { setMorphValueInput, setMorphValueOutput } from '../types'
export function callSetMorphValue(args: setMorphValueInput): Promise<setMorphValueOutput> {
return mizanCall('set_morph_value', args)
}

View File

@@ -3,6 +3,7 @@
export * from './types'
export { fetchGlobalContext, type GlobalContextData, type GlobalContextParams } from './contexts/global'
export { fetchMorphsContext, type MorphsContextData, type MorphsContextParams } from './contexts/morphs'
export { callEcho } from './functions/echo'
export { callAdd } from './functions/add'
@@ -14,6 +15,7 @@ export { callVerifiedOnly } from './functions/verifiedOnly'
export { callNotImplementedFn } from './functions/notImplementedFn'
export { callBuggyFn } from './functions/buggyFn'
export { callPermissionCheckFn } from './functions/permissionCheckFn'
export { callSetMorphValue } from './functions/setMorphValue'
// Stage 2 framework adapter
export * from './react'

View File

@@ -22,7 +22,7 @@ import {
type ContextState,
} from '@mizan/base'
import { fetchGlobalContext, type GlobalContextData, type GlobalContextParams, callEcho, callAdd, callMultiply, callWhoami, callStaffOnly, callSuperuserOnly, callVerifiedOnly, callNotImplementedFn, callBuggyFn, callPermissionCheckFn } from './index'
import { fetchGlobalContext, type GlobalContextData, type GlobalContextParams, fetchMorphsContext, type MorphsContextData, type MorphsContextParams, callEcho, callAdd, callMultiply, callWhoami, callStaffOnly, callSuperuserOnly, callVerifiedOnly, callNotImplementedFn, callBuggyFn, callPermissionCheckFn, callSetMorphValue, type currentUserOutput, type morphGroupsOutput, type morphLayersOutput } from './index'
// Internal — runs inside a Provider, registers with the kernel exactly once.
function useContextSubscription<T>(
@@ -94,6 +94,29 @@ export function useCurrentUser(): currentUserOutput | null {
return useGlobalContext().data?.current_user ?? null
}
// ── Morphs Context ──
const MorphsCtx = createContext<ContextState<MorphsContextData> | null>(null)
export function MorphsContext({ children }: { children: ReactNode }) {
const state = useContextSubscription('morphs', {}, () => fetchMorphsContext({} as any))
return <MorphsCtx.Provider value={state}>{children}</MorphsCtx.Provider>
}
export function useMorphsContext(): ContextState<MorphsContextData> {
const ctx = useContext(MorphsCtx)
if (!ctx) throw new Error('useMorphsContext requires <MorphsContext>')
return ctx
}
export function useMorphGroups(): morphGroupsOutput | null {
return useMorphsContext().data?.morph_groups ?? null
}
export function useMorphLayers(): morphLayersOutput | null {
return useMorphsContext().data?.morph_layers ?? null
}
export function useEcho() {
return useMutation<Parameters<typeof callEcho>[0], Awaited<ReturnType<typeof callEcho>>>(callEcho)
}
@@ -134,11 +157,17 @@ export function usePermissionCheckFn() {
return useMutation<Parameters<typeof callPermissionCheckFn>[0], Awaited<ReturnType<typeof callPermissionCheckFn>>>(callPermissionCheckFn)
}
export function useSetMorphValue() {
return useMutation<Parameters<typeof callSetMorphValue>[0], Awaited<ReturnType<typeof callSetMorphValue>>>(callSetMorphValue)
}
// ── MizanContext root provider ──
export interface MizanContextProps {
/** Base URL for protocol endpoints. Defaults to "/api/mizan". */
baseUrl?: string
/** Set to `false` for backends without a `/session/` endpoint (e.g. FastAPI). */
session?: boolean
children: ReactNode
}
@@ -146,10 +175,13 @@ export interface MizanContextProps {
* Root provider — calls configure() once and mounts the global context (if defined).
* Must wrap any component using Mizan-generated hooks.
*/
export function MizanContext({ baseUrl, children }: MizanContextProps) {
export function MizanContext({ baseUrl, session, children }: MizanContextProps) {
const configured = useRef(false)
if (!configured.current) {
if (baseUrl) configure({ baseUrl })
const opts: Parameters<typeof configure>[0] = {}
if (baseUrl !== undefined) opts.baseUrl = baseUrl
if (session !== undefined) opts.session = session
if (Object.keys(opts).length > 0) configure(opts)
configured.current = true
}
return <GlobalContextProvider>{children}</GlobalContextProvider>

View File

@@ -327,6 +327,92 @@
"isContext": "global"
}
}
},
"/mizan/morph_groups": {
"post": {
"summary": "Summary-shape slot — server must route MorphLayer mutations away from here.",
"operationId": "morphGroups",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/morphGroupsOutput"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": "morphs"
}
}
},
"/mizan/morph_layers": {
"post": {
"summary": "Detailed-shape slot — server routes MorphLayer mutations here.",
"operationId": "morphLayers",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/morphLayersOutput"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": "morphs"
}
}
},
"/mizan/set_morph_value": {
"post": {
"summary": "Mutation that returns the changed row; kernel splices into morph_layers.",
"operationId": "setMorphValue",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/setMorphValueInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/setMorphValueOutput"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": false
}
}
}
},
"components": {
@@ -344,6 +430,58 @@
"type": "object",
"title": "HTTPValidationError"
},
"MorphGroupMeta": {
"properties": {
"id": {
"type": "integer",
"title": "Id"
},
"label": {
"type": "string",
"title": "Label"
},
"count": {
"type": "integer",
"title": "Count"
}
},
"type": "object",
"required": [
"id",
"label",
"count"
],
"title": "MorphGroupMeta",
"description": "Group summary — narrower shape than MorphLayer. Listed alongside\nmorph_layers so the server's slot resolver has to discriminate by\nreturn-type rather than by bundle order."
},
"MorphLayer": {
"properties": {
"id": {
"type": "integer",
"title": "Id"
},
"group_id": {
"type": "integer",
"title": "Group Id"
},
"label": {
"type": "string",
"title": "Label"
},
"value": {
"type": "number",
"title": "Value"
}
},
"type": "object",
"required": [
"id",
"group_id",
"label",
"value"
],
"title": "MorphLayer"
},
"ValidationError": {
"properties": {
"loc": {
@@ -477,6 +615,20 @@
],
"title": "echoOutput"
},
"morphGroupsOutput": {
"items": {
"$ref": "#/components/schemas/MorphGroupMeta"
},
"type": "array",
"title": "morphGroupsOutput"
},
"morphLayersOutput": {
"items": {
"$ref": "#/components/schemas/MorphLayer"
},
"type": "array",
"title": "morphLayersOutput"
},
"multiplyInput": {
"properties": {
"x": {
@@ -547,6 +699,52 @@
],
"title": "permissionCheckFnOutput"
},
"setMorphValueInput": {
"properties": {
"id": {
"type": "integer",
"title": "Id"
},
"value": {
"type": "number",
"title": "Value"
}
},
"type": "object",
"required": [
"id",
"value"
],
"title": "setMorphValueInput"
},
"setMorphValueOutput": {
"properties": {
"id": {
"type": "integer",
"title": "Id"
},
"group_id": {
"type": "integer",
"title": "Group Id"
},
"label": {
"type": "string",
"title": "Label"
},
"value": {
"type": "number",
"title": "Value"
}
},
"type": "object",
"required": [
"id",
"group_id",
"label",
"value"
],
"title": "setMorphValueOutput"
},
"staffOnlyOutput": {
"properties": {
"message": {
@@ -743,6 +941,45 @@
"isForm": false,
"formName": null,
"formRole": null
},
{
"name": "morph_groups",
"camelName": "morphGroups",
"hasInput": false,
"inputType": null,
"outputType": "morphGroupsOutput",
"transport": "http",
"isContext": "morphs",
"isForm": false,
"formName": null,
"formRole": null
},
{
"name": "morph_layers",
"camelName": "morphLayers",
"hasInput": false,
"inputType": null,
"outputType": "morphLayersOutput",
"transport": "http",
"isContext": "morphs",
"isForm": false,
"formName": null,
"formRole": null
},
{
"name": "set_morph_value",
"camelName": "setMorphValue",
"hasInput": true,
"inputType": "setMorphValueInput",
"outputType": "setMorphValueOutput",
"transport": "http",
"isContext": false,
"isForm": false,
"formName": null,
"formRole": null,
"merge": [
"morphs"
]
}
],
"x-mizan-contexts": {
@@ -751,6 +988,13 @@
"current_user"
],
"params": {}
},
"morphs": {
"functions": [
"morph_groups",
"morph_layers"
],
"params": {}
}
}
}

View File

@@ -188,6 +188,57 @@ export interface paths {
patch?: never;
trace?: never;
};
"/mizan/morph_groups": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Summary-shape slot — server must route MorphLayer mutations away from here. */
post: operations["morphGroups"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mizan/morph_layers": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Detailed-shape slot — server routes MorphLayer mutations here. */
post: operations["morphLayers"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mizan/set_morph_value": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Mutation that returns the changed row; kernel splices into morph_layers. */
post: operations["setMorphValue"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -197,6 +248,31 @@ export interface components {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/**
* MorphGroupMeta
* @description Group summary — narrower shape than MorphLayer. Listed alongside
* morph_layers so the server's slot resolver has to discriminate by
* return-type rather than by bundle order.
*/
MorphGroupMeta: {
/** Id */
id: number;
/** Label */
label: string;
/** Count */
count: number;
};
/** MorphLayer */
MorphLayer: {
/** Id */
id: number;
/** Group Id */
group_id: number;
/** Label */
label: string;
/** Value */
value: number;
};
/** ValidationError */
ValidationError: {
/** Location */
@@ -249,6 +325,10 @@ export interface components {
/** Message */
message: string;
};
/** morphGroupsOutput */
morphGroupsOutput: components["schemas"]["MorphGroupMeta"][];
/** morphLayersOutput */
morphLayersOutput: components["schemas"]["MorphLayer"][];
/** multiplyInput */
multiplyInput: {
/** X */
@@ -276,6 +356,24 @@ export interface components {
/** Message */
message: string;
};
/** setMorphValueInput */
setMorphValueInput: {
/** Id */
id: number;
/** Value */
value: number;
};
/** setMorphValueOutput */
setMorphValueOutput: {
/** Id */
id: number;
/** Group Id */
group_id: number;
/** Label */
label: string;
/** Value */
value: number;
};
/** staffOnlyOutput */
staffOnlyOutput: {
/** Message */
@@ -584,11 +682,86 @@ export interface operations {
};
};
};
morphGroups: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["morphGroupsOutput"];
};
};
};
};
morphLayers: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["morphLayersOutput"];
};
};
};
};
setMorphValue: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["setMorphValueInput"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["setMorphValueOutput"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
}
// Convenience type exports
export type HTTPValidationError = components["schemas"]["HTTPValidationError"]
export type MorphGroupMeta = components["schemas"]["MorphGroupMeta"]
export type MorphLayer = components["schemas"]["MorphLayer"]
export type ValidationError = components["schemas"]["ValidationError"]
export type addInput = components["schemas"]["addInput"]
export type addOutput = components["schemas"]["addOutput"]
@@ -596,11 +769,15 @@ export type buggyFnOutput = components["schemas"]["buggyFnOutput"]
export type currentUserOutput = components["schemas"]["currentUserOutput"]
export type echoInput = components["schemas"]["echoInput"]
export type echoOutput = components["schemas"]["echoOutput"]
export type morphGroupsOutput = components["schemas"]["morphGroupsOutput"]
export type morphLayersOutput = components["schemas"]["morphLayersOutput"]
export type multiplyInput = components["schemas"]["multiplyInput"]
export type multiplyOutput = components["schemas"]["multiplyOutput"]
export type notImplementedFnOutput = components["schemas"]["notImplementedFnOutput"]
export type permissionCheckFnInput = components["schemas"]["permissionCheckFnInput"]
export type permissionCheckFnOutput = components["schemas"]["permissionCheckFnOutput"]
export type setMorphValueInput = components["schemas"]["setMorphValueInput"]
export type setMorphValueOutput = components["schemas"]["setMorphValueOutput"]
export type staffOnlyOutput = components["schemas"]["staffOnlyOutput"]
export type superuserOnlyOutput = components["schemas"]["superuserOnlyOutput"]
export type verifiedOnlyOutput = components["schemas"]["verifiedOnlyOutput"]

View File

@@ -11,6 +11,7 @@ import { useState, useEffect } from 'react'
import {
MizanContext,
MorphsContext,
useEcho,
useAdd,
useMultiply,
@@ -22,6 +23,9 @@ import {
useBuggyFn,
usePermissionCheckFn,
useCurrentUser,
useMorphGroups,
useMorphLayers,
useSetMorphValue,
MizanError,
useMizan,
} from './api'
@@ -51,6 +55,7 @@ export function Fixtures() {
case 'permission-error': return <PermissionError_ />
case 'permission-success': return <PermissionSuccess />
case 'context-current-user': return <ContextCurrentUser />
case 'merge-morph': return <MergeMorph />
default: return <div data-testid="ready">Harness ready. Set #hash.</div>
}
}
@@ -130,3 +135,29 @@ function ContextCurrentUser() {
return <div>loading context...</div>
}
}
function MergeMorph() {
return (
<MorphsContext>
<MergeMorphInner />
</MorphsContext>
)
}
function MergeMorphInner() {
const groups = useMorphGroups()
const layers = useMorphLayers()
const { mutate } = useSetMorphValue()
const [fired, setFired] = useState(false)
useEffect(() => {
if (layers && groups && !fired) {
setFired(true)
mutate({ id: 1, value: 0.75 })
}
}, [layers, groups, fired, mutate])
if (layers === null || groups === null) return <div>loading...</div>
return <pre data-testid="result">{JSON.stringify({ groups, layers })}</pre>
}

View File

@@ -4,7 +4,7 @@ import { Fixtures } from './fixtures'
function App() {
return (
<MizanContext baseUrl="/api/mizan">
<MizanContext baseUrl="/api/mizan" session={false}>
<Fixtures />
</MizanContext>
)

View File

@@ -137,3 +137,56 @@ test.describe('generated context hooks', () => {
expect(result.email).toBe('')
})
})
// ─── Session gate ───────────────────────────────────────────────────────────
test.describe('session gate', () => {
test('no /session/ requests fire when configured with session={false}', async ({ page }) => {
const sessionCalls: string[] = []
page.on('request', (req) => {
const url = req.url()
if (url.includes('/session/')) sessionCalls.push(url)
})
await fixture(page, 'echo')
await getResult(page)
expect(sessionCalls).toEqual([])
})
})
// ─── Merge protocol ─────────────────────────────────────────────────────────
test.describe('merge protocol', () => {
test('@client(merge=...) routes to the correct slot, leaves siblings untouched, no refetch', async ({ page }) => {
// The morphs context bundles two list slots:
// morph_groups: list[MorphGroupMeta] — {id, label, count}
// morph_layers: list[MorphLayer] — {id, group_id, label, value}
// set_morph_value returns MorphLayer. Server-side slot resolution
// (via mizan_core.type_utils.types_match_for_merge) must route to
// morph_layers and leave morph_groups intact. A kernel-side heuristic
// would have to guess between the two id-bearing list slots.
const morphsFetches: string[] = []
page.on('request', (req) => {
const url = req.url()
if (url.includes('/api/mizan/ctx/morphs/')) morphsFetches.push(url)
})
await page.goto(`${BASE}#merge-morph`)
await page.waitForFunction(() => {
const el = document.querySelector('[data-testid="result"]')
if (!el) return false
try {
const data = JSON.parse(el.textContent!)
return data.layers?.some((l: any) => l.id === 1 && l.value === 0.75)
} catch { return false }
}, { timeout: 5000 })
const result = await getResult(page)
const layer = result.layers.find((l: any) => l.id === 1)
expect(layer.value).toBe(0.75)
expect(layer.label).toBe('brow')
// Sibling slot is unchanged — the server didn't route MorphLayer into morph_groups.
expect(result.groups).toEqual([{ id: 1, label: 'face', count: 2 }])
// Initial mount fetches once; merge path must not trigger a refetch.
expect(morphsFetches.length).toBe(1)
})
})

View File

@@ -7,5 +7,5 @@
"exports": {
".": "./src/index.ts"
},
"license": "MIT"
"license": "Elastic-2.0"
}

View File

@@ -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 {
@@ -37,6 +70,21 @@ interface MizanConfig {
getHeaders: () => Record<string, string> | Promise<Record<string, string>>
csrfCookieName: string
csrfHeaderName: string
/**
* Whether the backend exposes `/session/` for CSRF/session bootstrap.
* `true` for Django (the default — preserves existing setups); set
* `false` for FastAPI or any backend that doesn't ship a session
* endpoint to avoid a 404 storm on startup. A future revision moves
* 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 = {
@@ -44,6 +92,9 @@ const config: MizanConfig = {
getHeaders: () => ({}),
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 {
@@ -67,6 +118,7 @@ function getCSRFToken(): string | null {
let _sessionReady: Promise<void> | null = null
export function initSession(): Promise<void> {
if (!config.session) return Promise.resolve()
if (_sessionReady) return _sessionReady
_sessionReady = (async () => {
@@ -187,6 +239,59 @@ export function registerContext(
}
}
// === Merge ===
//
// A mutation that declares `@client(merge=ctx)` returns `{merge: [{context,
// slot, params?, value}]}` alongside `result`/`invalidate`. The server has
// already resolved which bundle slot the value lands in (by matching the
// mutation's return type against each context function's return type), so
// the kernel does no inference — it writes directly to `bundle[slot]`,
// upserting by id when the slot is a list. The type information lives in
// the schema-aware backend layer; the kernel is type-erased on purpose.
function spliceSlot(slot: unknown, value: unknown): unknown {
if (Array.isArray(slot)) {
if (Array.isArray(value)) return value
if (value && typeof value === 'object' && 'id' in value) {
const id = (value as { id: unknown }).id
const idx = slot.findIndex(item =>
item && typeof item === 'object' && 'id' in item
&& (item as { id: unknown }).id === id
)
const next = slot.slice()
if (idx >= 0) next[idx] = value
else next.push(value)
return next
}
}
return value
}
export function merge(
context: string,
params: Record<string, any> | undefined,
slot: string,
value: unknown,
): void {
const entries = contexts.get(context)
if (!entries) return
const entry = entries.get(stableKey(params ?? {}))
if (!entry || entry.state.data == null) return
const data = entry.state.data
if (!data || typeof data !== 'object' || Array.isArray(data)) return
const bundle = data as Record<string, unknown>
if (!(slot in bundle)) return
entry.state = {
data: { ...bundle, [slot]: spliceSlot(bundle[slot], value) },
status: 'success',
error: null,
}
entry.listeners.forEach(l => l())
}
// === Invalidation ===
const pending: Set<string> = new Set()
@@ -281,30 +386,16 @@ async function resolveHeaders(): Promise<Record<string, string>> {
}
}
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()
}
export async function mizanCall(
functionName: string,
args: Record<string, any>,
): Promise<any> {
/**
* 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'
@@ -315,17 +406,69 @@ export async function mizanCall(
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()
},
}
}
const data = await 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> {
return config.transport.fetch(contextName, params)
}
export async function mizanCall(
functionName: string,
args: Record<string, any>,
): Promise<any> {
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
// server told us to refetch, that wins.
if (data.merge) {
for (const entry of data.merge) {
merge(entry.context, entry.params, entry.slot, entry.value)
}
}
// Server-driven invalidation
if (data.invalidate) {
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.
}
}

View File

@@ -12,39 +12,44 @@ npm install @rythazhur/mizan@git+https://git.impactsoundworks.com/isw/mizan.git#
You don't use this package directly. You use the **generated hooks**.
This is the pre-kernel React adapter: it ships its own `MizanProvider`
(`src/context.tsx`) that owns HTTP/WebSocket/CSRF/session/context state
directly, rather than subscribing to the `@mizan/base` kernel. It is still
the provider the Django + desktop example wires against. (`DjangoContext`,
`useDjango`, etc. are deprecated aliases for the `Mizan*` names.)
### 1. Configure
```js
// django.config.mjs
export default {
source: {
django: {
managePath: '../backend/manage.py',
command: ['uv', 'run', 'python'],
},
},
output: 'src/api/generated.ts',
}
```toml
# mizan.toml
output = "src/api"
targets = ["react"]
[source.django]
manage_path = "../backend/manage.py"
command = ["uv", "run", "python"]
```
### 2. Generate
The codegen is the `mizan-generate` Rust binary (source at
`protocol/mizan-codegen/`; `protocol/mizan-generate/` is the npm launcher):
```bash
npx mizan-generate # once
npx mizan-generate --watch # dev mode
mizan-generate --config mizan.toml
```
### 3. Wrap your app
```tsx
import { DjangoContext } from '@/api'
import { MizanProvider } from '@rythazhur/mizan'
<DjangoContext>
<MizanProvider>
<App />
</DjangoContext>
</MizanProvider>
```
`DjangoContext` is the only provider you need. It handles HTTP, WebSocket, CSRF, session init, context auto-fetching, and channel connections.
`MizanProvider` is the only provider you need. It handles HTTP, WebSocket, CSRF, session init, context auto-fetching, and channel connections.
### 4. Use generated hooks
@@ -71,19 +76,22 @@ chat.messages // typed, reactive
## Generated Files
The Rust codegen emits per-target files into the configured `output`
directory (Stage 1 is auto-included whenever `react` is a target):
| File | Contents |
|------|----------|
| `generated.django.tsx` | `DjangoContext` + typed hooks |
| `generated.mizan.ts` | Pydantic types |
| `generated.forms.ts` | Form hooks with Zod |
| `generated.channels.hooks.tsx` | Channel hooks |
| `index.ts` | Re-exports everything |
| `types.ts` | Pydantic types |
| `contexts/<name>.ts` | Per-context `fetchXxx` bundles |
| `react.tsx` | `<MizanContext>` provider + typed `use{Hook}()` hooks |
| `channels.ts` / `channels.hooks.tsx` | Channel types + hooks (when the schema carries channels) |
| `index.ts` | Stage 1 re-export root |
## Sub-exports
| Import | When to use |
|--------|------------|
| `@rythazhur/mizan` | Core: mizanProvider, hooks, forms, errors |
| `@rythazhur/mizan` | Core: `MizanProvider`, hooks, forms, errors |
| `@rythazhur/mizan/channels` | WebSocket channels |
| `@rythazhur/mizan/jwt` | JWT token management |
| `@rythazhur/mizan/client` | HTTP clients (CSR/SSR) |

View File

@@ -1,6 +1,7 @@
{
"name": "@rythazhur/mizan",
"version": "0.1.1",
"license": "Elastic-2.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -17,10 +18,6 @@
"types": "./dist/client/react.d.ts",
"import": "./dist/client/react.js"
},
"./client/nextjs": {
"types": "./dist/client/nextjs.d.ts",
"import": "./dist/client/nextjs.js"
},
"./channels": {
"types": "./dist/channels/index.d.ts",
"import": "./dist/channels/index.js"
@@ -28,14 +25,6 @@
"./jwt": {
"types": "./dist/jwt/index.d.ts",
"import": "./dist/jwt/index.js"
},
"./allauth": {
"types": "./dist/allauth/index.d.ts",
"import": "./dist/allauth/index.js"
},
"./allauth/nextjs": {
"types": "./dist/allauth/nextjs.d.ts",
"import": "./dist/allauth/nextjs.js"
}
},
"scripts": {

1
frontends/mizan-rust/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
target/

1697
frontends/mizan-rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
[package]
name = "mizan-rust"
version = "0.1.0"
edition = "2021"
description = "Mizan client kernel — Rust port of @mizan/base. Context registry, fetch/call, merge, invalidation, error envelope parsing. Same wire as the TS / Vue / Svelte clients."
license = "Elastic-2.0"
[features]
default = []
pyo3 = ["dep:pyo3", "dep:pythonize"]
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "io-std"] }
tokio-util = "0.7"
reqwest = { version = "0.12", default-features = false, features = ["json", "cookies", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_urlencoded = "0.7"
pyo3 = { version = "0.22", optional = true, features = ["extension-module", "abi3-py311"] }
pythonize = { version = "0.22", optional = true }
[dev-dependencies]
tokio = { version = "1", features = ["full"] }

View File

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

View File

@@ -0,0 +1,365 @@
//! Context registry.
//!
//! Mirrors the `contexts: Map<string, Map<ParamKey, ContextEntry>>`
//! shape in `frontends/mizan-base/src/index.ts`. Each entry holds the
//! latest `ContextState`, a `tokio::sync::watch::Sender` for notifying
//! subscribers, and a fetch function the registry invokes on demand.
//!
//! Subscribers receive a `ContextHandle` whose `rx: watch::Receiver`
//! they read from in their own loop. Watch channels overwrite the
//! previous value if the receiver hasn't consumed it yet — the render
//! loop sees only the latest state on each tick, never an intermediate
//! one. The TS kernel achieves the same effect via React's external
//! store re-render coalescing.
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use serde_json::Value;
use tokio::sync::{Mutex, RwLock, mpsc, watch};
use tokio_util::sync::CancellationToken;
use crate::error::MizanError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextStatus {
Idle,
Loading,
Success,
Error,
}
#[derive(Debug, Clone)]
pub struct ContextState<T> {
pub data: Option<T>,
pub status: ContextStatus,
pub error: Option<Arc<MizanError>>,
}
pub type ContextStateRaw = ContextState<Value>;
impl ContextStateRaw {
pub fn idle() -> Self {
Self { data: None, status: ContextStatus::Idle, error: None }
}
}
pub type FetchFn = Arc<
dyn Fn() -> Pin<Box<dyn Future<Output = Result<Value, MizanError>> + Send + 'static>>
+ Send
+ Sync,
>;
struct ContextEntry {
#[allow(dead_code)]
params: Value,
tx: watch::Sender<ContextStateRaw>,
fetch_fn: FetchFn,
refetch_tx: mpsc::UnboundedSender<()>,
/// Cancel signal for the entry's spawned refetch loop. Set when the
/// last handle on the entry unregisters.
cancel: CancellationToken,
}
pub struct ContextRegistry {
/// Outer key: context name. Inner key: `stable_key(params)`.
entries: RwLock<HashMap<String, HashMap<String, Arc<Mutex<ContextEntry>>>>>,
}
impl Default for ContextRegistry {
fn default() -> Self {
Self::new()
}
}
impl ContextRegistry {
pub fn new() -> Self {
Self { entries: RwLock::new(HashMap::new()) }
}
/// Register an instance of `(context_name, params)`. Idempotent —
/// re-registering the same key returns a handle on the existing
/// entry (the fetch_fn closure is replaced so the latest binding
/// wins).
pub async fn register(
self: &Arc<Self>,
name: impl Into<String>,
params: Value,
fetch_fn: FetchFn,
initial_data: Option<Value>,
) -> ContextHandle {
let name = name.into();
let key = stable_key(&params);
let mut outer = self.entries.write().await;
let inner = outer.entry(name.clone()).or_default();
if let Some(existing) = inner.get(&key).cloned() {
// Update the fetch closure so the latest registration's
// closure wins (matches the TS Strict-Mode behavior).
{
let mut entry = existing.lock().await;
entry.fetch_fn = fetch_fn;
}
let entry = existing.lock().await;
return ContextHandle {
rx: entry.tx.subscribe(),
refetch_tx: entry.refetch_tx.clone(),
cancel: entry.cancel.clone(),
registry: Arc::clone(self),
name,
key,
};
}
let initial = match initial_data {
Some(data) => ContextState { data: Some(data), status: ContextStatus::Success, error: None },
None => ContextStateRaw::idle(),
};
let (tx, _rx) = watch::channel(initial);
let (refetch_tx, mut refetch_rx) = mpsc::unbounded_channel::<()>();
let cancel = CancellationToken::new();
let entry = Arc::new(Mutex::new(ContextEntry {
params: params.clone(),
tx: tx.clone(),
fetch_fn: fetch_fn.clone(),
refetch_tx: refetch_tx.clone(),
cancel: cancel.clone(),
}));
inner.insert(key.clone(), Arc::clone(&entry));
drop(outer);
// Spawn the entry's refetch loop. The loop owns its own fetch
// closure handle resolution via the entry mutex — each tick
// reads the latest closure, so updates via re-register apply.
let entry_for_task = Arc::clone(&entry);
let cancel_for_task = cancel.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = cancel_for_task.cancelled() => break,
msg = refetch_rx.recv() => {
if msg.is_none() { break; }
let (fetch_fn, tx) = {
let entry = entry_for_task.lock().await;
(entry.fetch_fn.clone(), entry.tx.clone())
};
// Loading state
let cur = tx.borrow().clone();
let loading = ContextState { data: cur.data, status: ContextStatus::Loading, error: None };
let _ = tx.send(loading);
// Drive the fetch
match fetch_fn().await {
Ok(data) => {
let _ = tx.send(ContextState { data: Some(data), status: ContextStatus::Success, error: None });
}
Err(err) => {
let cur = tx.borrow().clone();
let _ = tx.send(ContextState {
data: cur.data,
status: ContextStatus::Error,
error: Some(Arc::new(err)),
});
}
}
}
}
}
});
ContextHandle {
rx: tx.subscribe(),
refetch_tx,
cancel,
registry: Arc::clone(self),
name,
key,
}
}
/// Merge a value into a context entry's bundle slot. Mirrors the
/// TS kernel `merge(context, params, slot, value)` call.
pub async fn merge(
&self,
name: &str,
params: Option<&Value>,
slot: &str,
value: &Value,
) {
let key = match params {
Some(p) => stable_key(p),
None => stable_key(&Value::Object(Default::default())),
};
let entry_handle = {
let outer = self.entries.read().await;
outer.get(name).and_then(|inner| inner.get(&key)).cloned()
};
let Some(entry_arc) = entry_handle else { return };
let entry = entry_arc.lock().await;
let cur = entry.tx.borrow().clone();
let Some(bundle) = cur.data.as_ref() else { return };
let Some(merged) = crate::merge::merge_into_bundle(bundle, slot, value) else { return };
let _ = entry.tx.send(ContextState {
data: Some(merged),
status: ContextStatus::Success,
error: None,
});
}
/// Trigger refetch on every entry of `name`.
pub async fn invalidate_broad(&self, name: &str) {
let entries = {
let outer = self.entries.read().await;
outer.get(name).map(|inner| inner.values().cloned().collect::<Vec<_>>())
};
let Some(entries) = entries else { return };
for entry in entries {
let tx = {
let e = entry.lock().await;
e.refetch_tx.clone()
};
let _ = tx.send(());
}
}
/// Trigger refetch on the single entry matching `(name, params)`.
pub async fn invalidate_scoped(&self, name: &str, params: &Value) {
let key = stable_key(params);
let entry_arc = {
let outer = self.entries.read().await;
outer.get(name).and_then(|inner| inner.get(&key)).cloned()
};
let Some(entry_arc) = entry_arc else { return };
let tx = {
let entry = entry_arc.lock().await;
entry.refetch_tx.clone()
};
let _ = tx.send(());
}
async fn unregister(&self, name: &str, key: &str) {
let mut outer = self.entries.write().await;
if let Some(inner) = outer.get_mut(name) {
if let Some(entry) = inner.remove(key) {
let entry = entry.lock().await;
entry.cancel.cancel();
}
if inner.is_empty() {
outer.remove(name);
}
}
}
}
pub struct ContextHandle {
pub rx: watch::Receiver<ContextStateRaw>,
refetch_tx: mpsc::UnboundedSender<()>,
cancel: CancellationToken,
registry: Arc<ContextRegistry>,
name: String,
key: String,
}
impl ContextHandle {
/// Drive a refetch. Returns immediately; the new state lands on
/// `rx` once the kernel's refetch task finishes the fetch.
pub fn refetch(&self) {
let _ = self.refetch_tx.send(());
}
pub fn state(&self) -> ContextStateRaw {
self.rx.borrow().clone()
}
pub fn cancel_token(&self) -> CancellationToken {
self.cancel.clone()
}
pub async fn unregister(self) {
self.registry.unregister(&self.name, &self.key).await;
}
}
/// Byte-identical to TS `JSON.stringify(params, Object.keys(params).sort())`.
///
/// Uses `BTreeMap` for deterministic key ordering and serializes via
/// `serde_json::to_string` (compact, no whitespace) — matches the TS
/// default. Non-object / non-string params (numbers, booleans) pass
/// through serde_json's standard JSON representation.
pub fn stable_key(params: &Value) -> String {
match params {
Value::Object(map) => {
let sorted: BTreeMap<&String, &Value> = map.iter().collect();
serde_json::to_string(&sorted).unwrap_or_default()
}
other => serde_json::to_string(other).unwrap_or_default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn stable_key_sorts_object_keys() {
let a = stable_key(&json!({"b": 1, "a": 2}));
let b = stable_key(&json!({"a": 2, "b": 1}));
assert_eq!(a, b);
assert_eq!(a, r#"{"a":2,"b":1}"#);
}
#[test]
fn stable_key_handles_empty_object() {
assert_eq!(stable_key(&json!({})), "{}");
}
#[tokio::test]
async fn register_and_refetch() {
let registry = Arc::new(ContextRegistry::new());
let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
let counter_clone = Arc::clone(&counter);
let fetch_fn: FetchFn = Arc::new(move || {
let counter = Arc::clone(&counter_clone);
Box::pin(async move {
let n = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
Ok(json!({ "count": n }))
})
});
let mut handle = registry.register("test", json!({}), fetch_fn, None).await;
handle.refetch();
// Poll until success — watch::Receiver::changed() returns once
// per "newest value seen" advance, so back-to-back sends from the
// refetch task can coalesce into a single notification. The loop
// ignores intermediate Loading states and waits for Success.
loop {
tokio::time::timeout(std::time::Duration::from_secs(2), handle.rx.changed())
.await
.expect("changed timed out")
.unwrap();
if handle.state().status == ContextStatus::Success {
break;
}
}
let state = handle.state();
assert_eq!(state.data.unwrap()["count"], 1);
}
}

View File

@@ -0,0 +1,121 @@
//! Wire error envelope. Mirrors `MizanError` in `frontends/mizan-base/src/index.ts`.
//!
//! Two envelope shapes are tolerated:
//!
//! - FastAPI: `{"error": {"code": "...", "message": "...", "details": ...}}`
//! - Django: `{"error": true, "code": "...", "message": "...", "details": ...}`
//!
//! When neither shape parses, `code` falls back to `HTTP_<status>` and the
//! raw response body is the message.
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Clone)]
pub struct MizanError {
pub status: u16,
pub code: String,
pub message: String,
pub details: Option<Value>,
pub raw_body: String,
}
impl MizanError {
pub fn from_response(status: u16, body: String) -> Self {
let parsed = serde_json::from_str::<Envelope>(&body).ok();
let (code, message, details) = match parsed {
Some(Envelope::Fastapi { error }) => (
error.code.unwrap_or_else(|| format!("HTTP_{status}")),
error.message.unwrap_or_else(|| format!("Mizan call failed ({status})")),
error.details,
),
Some(Envelope::Django { code, message, details, .. }) => (
code.unwrap_or_else(|| format!("HTTP_{status}")),
message.unwrap_or_else(|| format!("Mizan call failed ({status})")),
details,
),
None => (
format!("HTTP_{status}"),
format!("Mizan call failed ({status})"),
None,
),
};
Self { status, code, message, details, raw_body: body }
}
pub fn transport(message: impl Into<String>) -> Self {
Self {
status: 0,
code: "TRANSPORT".to_string(),
message: message.into(),
details: None,
raw_body: String::new(),
}
}
}
impl std::fmt::Display for MizanError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Mizan {} ({}): {}", self.status, self.code, self.message)
}
}
impl std::error::Error for MizanError {}
#[derive(Deserialize)]
#[serde(untagged)]
enum Envelope {
Fastapi { error: NestedError },
Django {
// Django form is `{"error": true, "code": ..., "message": ..., "details": ...}`.
// `error` is a bool sentinel; the actual fields are siblings.
#[allow(dead_code)]
error: bool,
code: Option<String>,
message: Option<String>,
details: Option<Value>,
},
}
#[derive(Deserialize)]
struct NestedError {
code: Option<String>,
message: Option<String>,
details: Option<Value>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_fastapi_envelope() {
let body = r#"{"error":{"code":"BAD_REQUEST","message":"oops","details":{"k":1}}}"#;
let e = MizanError::from_response(400, body.to_string());
assert_eq!(e.code, "BAD_REQUEST");
assert_eq!(e.message, "oops");
assert_eq!(e.details, Some(serde_json::json!({"k": 1})));
}
#[test]
fn parses_django_envelope() {
let body = r#"{"error":true,"code":"NOT_FOUND","message":"missing","details":null}"#;
let e = MizanError::from_response(404, body.to_string());
assert_eq!(e.code, "NOT_FOUND");
assert_eq!(e.message, "missing");
}
#[test]
fn falls_back_on_unparseable_body() {
let e = MizanError::from_response(500, "Internal Server Error".to_string());
assert_eq!(e.code, "HTTP_500");
assert!(e.message.contains("500"));
}
}

View File

@@ -0,0 +1,148 @@
//! Invalidation queue.
//!
//! Mirrors the TS kernel's `pending` / `pendingScoped` / `flush()` pair
//! at `frontends/mizan-base/src/index.ts`. Mutations accumulate
//! invalidation targets; the queue batches them and triggers refetches
//! on the matching context entries.
//!
//! The TS kernel uses `queueMicrotask(flush)` to batch within a single
//! event-loop tick. The Rust equivalent is a `tokio::task::yield_now()`
//! debounce: when `invalidate()` is called, push to the queue, and if
//! no flush is scheduled spawn a task that yields once then flushes.
//! That gives the same "batch within a single async tick" semantics.
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use serde_json::Value;
use tokio::sync::Mutex;
use crate::context::ContextRegistry;
#[derive(Debug, Clone)]
pub struct ScopedTarget {
pub context: String,
pub params: Value,
}
#[derive(Default)]
struct Pending {
broad: HashSet<String>,
scoped: Vec<ScopedTarget>,
}
pub struct InvalidationQueue {
pending: Mutex<Pending>,
scheduled: AtomicBool,
registry: Arc<ContextRegistry>,
}
impl InvalidationQueue {
pub fn new(registry: Arc<ContextRegistry>) -> Arc<Self> {
Arc::new(Self {
pending: Mutex::new(Pending::default()),
scheduled: AtomicBool::new(false),
registry,
})
}
/// Schedule a broad invalidation (every entry of `name` refetches).
pub async fn invalidate(self: &Arc<Self>, name: impl Into<String>) {
{
let mut pending = self.pending.lock().await;
pending.broad.insert(name.into());
}
self.schedule_flush();
}
/// Schedule a scoped invalidation (the entry matching `(name,
/// params)` refetches).
pub async fn invalidate_scoped(self: &Arc<Self>, name: impl Into<String>, params: Value) {
{
let mut pending = self.pending.lock().await;
pending.scoped.push(ScopedTarget { context: name.into(), params });
}
self.schedule_flush();
}
fn schedule_flush(self: &Arc<Self>) {
if self.scheduled.swap(true, Ordering::SeqCst) {
return;
}
let this = Arc::clone(self);
tokio::spawn(async move {
// Yield once to batch invalidations queued in the same
// async tick — equivalent to TS `queueMicrotask`.
tokio::task::yield_now().await;
this.flush().await;
this.scheduled.store(false, Ordering::SeqCst);
});
}
async fn flush(&self) {
let snapshot = {
let mut pending = self.pending.lock().await;
let broad = std::mem::take(&mut pending.broad);
let scoped = std::mem::take(&mut pending.scoped);
(broad, scoped)
};
let (broad, scoped) = snapshot;
// Broad first — they cover all scoped variants of the same name.
for name in &broad {
self.registry.invalidate_broad(name).await;
}
for target in &scoped {
if broad.contains(&target.context) {
continue;
}
self.registry.invalidate_scoped(&target.context, &target.params).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::{ContextHandle, ContextRegistry, ContextStatus, FetchFn};
use serde_json::json;
fn counted_fetch(counter: Arc<std::sync::atomic::AtomicU32>) -> FetchFn {
Arc::new(move || {
let counter = Arc::clone(&counter);
Box::pin(async move {
let n = counter.fetch_add(1, Ordering::SeqCst) + 1;
Ok(json!({ "count": n }))
})
})
}
async fn wait_for_success(handle: &mut ContextHandle) {
loop {
handle.rx.changed().await.unwrap();
if handle.state().status == ContextStatus::Success {
return;
}
}
}
#[tokio::test]
async fn broad_invalidate_triggers_refetch() {
let registry = Arc::new(ContextRegistry::new());
let queue = InvalidationQueue::new(Arc::clone(&registry));
let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
let mut handle = registry.register("user", json!({}), counted_fetch(Arc::clone(&counter)), None).await;
handle.refetch();
wait_for_success(&mut handle).await;
assert_eq!(counter.load(Ordering::SeqCst), 1);
queue.invalidate("user").await;
wait_for_success(&mut handle).await;
assert_eq!(counter.load(Ordering::SeqCst), 2);
}
}

View File

@@ -0,0 +1,28 @@
//! Mizan client kernel.
//!
//! Rust port of `@mizan/base` (frontends/mizan-base/src/index.ts). Same
//! public surface, same protocol, same wire shape. Consumers — generated
//! per-app crates, the GPU worker, the Python `PyMizanClient` — depend
//! on this kernel and never construct HTTP requests directly.
//!
//! Modules:
//! - [`client`] — `MizanClient`, `MizanConfig`, session init
//! - [`context`] — registry, `ContextState`, `ContextHandle`, `stable_key`
//! - [`error`] — `MizanError`, envelope parsing
//! - [`transport`] — `mizan_fetch`, `mizan_call`, retry, header resolution
//! - [`merge`] — `splice_slot`
//! - [`invalidation`] — `InvalidationQueue`, debounced flush
pub mod client;
pub mod context;
pub mod error;
pub mod invalidation;
pub mod merge;
pub mod transport;
#[cfg(feature = "pyo3")]
pub mod pyo3_bridge;
pub use client::{MizanClient, MizanConfig};
pub use context::{ContextHandle, ContextState, ContextStateRaw, ContextStatus, stable_key};
pub use error::MizanError;

Some files were not shown because too many files have changed in this diff Show More