Compare commits
20 Commits
c15c6f3e14
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 587be8c4ab | |||
| ae684a36cb | |||
| adcc027894 | |||
| 6c5f6f1fba | |||
| 58d2cb2848 | |||
| b41f469bbd | |||
| 66b2db81fb | |||
| 67ad91b673 | |||
| 4effcc7597 | |||
| 776e0cf27a | |||
| ffdf9aa24d | |||
| 578e124d67 | |||
| a5ef93b879 | |||
| 22dcf0e3c1 | |||
| 54f060c273 | |||
| a1d1d6928f | |||
| 45bde51166 | |||
| 9900f8a36f | |||
| 7fb0c4a400 | |||
| 43bcf3f26f |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
467
CLAUDE.md
@@ -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¶m2=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
107
INVARIANTS.md
Normal 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
184
ISSUES.md
@@ -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
95
LICENSE
Normal 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 licensor’s 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.
|
||||
15
MIZAN.md
15
MIZAN.md
@@ -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
122
README.md
Normal 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.
|
||||
67
ROADMAP.md
67
ROADMAP.md
@@ -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** — T1–T12 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 (M1–M18) 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
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))
|
||||
if ctx_fn_cls is None:
|
||||
continue
|
||||
ctx_input = getattr(ctx_fn_cls, "Input", None)
|
||||
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["auto_scoped_params"] = sorted(auto_scoped)
|
||||
|
||||
mutation_entry: dict[str, Any] = {
|
||||
"affects": affected_contexts,
|
||||
}
|
||||
if auto_scoped:
|
||||
mutation_entry["auto_scoped_params"] = auto_scoped
|
||||
if meta.get("private"):
|
||||
mutation_entry["private"] = True
|
||||
mutation["private"] = True
|
||||
if meta.get("route"):
|
||||
mutation_entry["route"] = meta["route"]
|
||||
mutation_entry["methods"] = meta.get("methods", ["POST"])
|
||||
mutation["route"] = meta["route"]
|
||||
mutation["methods"] = meta.get("methods", ["POST"])
|
||||
|
||||
manifest["mutations"][fn_name] = mutation_entry
|
||||
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)
|
||||
|
||||
@@ -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="")
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
@@ -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"]}
|
||||
|
||||
39
backends/mizan-fastapi/src/mizan_fastapi/ir.py
Normal file
39
backends/mizan-fastapi/src/mizan_fastapi/ir.py
Normal 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())
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
591
backends/mizan-rust-axum/Cargo.lock
generated
Normal 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"
|
||||
15
backends/mizan-rust-axum/Cargo.toml
Normal file
15
backends/mizan-rust-axum/Cargo.toml
Normal 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"] }
|
||||
27
backends/mizan-rust-axum/src/errors.rs
Normal file
27
backends/mizan-rust-axum/src/errors.rs
Normal 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
|
||||
}
|
||||
}
|
||||
162
backends/mizan-rust-axum/src/handlers.rs
Normal file
162
backends/mizan-rust-axum/src/handlers.rs
Normal 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, ¶ms);
|
||||
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)
|
||||
}
|
||||
58
backends/mizan-rust-axum/src/lib.rs
Normal file
58
backends/mizan-rust-axum/src/lib.rs
Normal 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
4621
backends/mizan-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
backends/mizan-tauri/Cargo.toml
Normal file
12
backends/mizan-tauri/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "mizan-tauri"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Tauri backend adapter for Mizan — typed RPC dispatch over Tauri's IPC. Single `mizan_invoke` command routes through mizan-core's compile-time function registry."
|
||||
license = "Elastic-2.0"
|
||||
|
||||
[dependencies]
|
||||
mizan-core = { path = "../../cores/mizan-rust" }
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
290
backends/mizan-tauri/README.md
Normal file
290
backends/mizan-tauri/README.md
Normal 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.
|
||||
220
backends/mizan-tauri/src/lib.rs
Normal file
220
backends/mizan-tauri/src/lib.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
|
||||
//!
|
||||
//! Ships as a Tauri plugin. The consumer installs it with one line:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! tauri::Builder::default()
|
||||
//! .plugin(mizan_tauri::init())
|
||||
//! .run(tauri::generate_context!())
|
||||
//! .expect("error while running tauri application");
|
||||
//! ```
|
||||
//!
|
||||
//! The plugin exposes a single command `mizan_invoke` (full Tauri name
|
||||
//! `plugin:mizan|mizan_invoke`). The JS-side `@mizan/tauri-transport`
|
||||
//! sends call/fetch envelopes to it; the dispatch routes through
|
||||
//! `mizan-core`'s FUNCTIONS / CONTEXTS registries — the same
|
||||
//! linkme-backed distributed slices the HTTP adapter (mizan-rust-axum)
|
||||
//! consumes. There is no per-function tauri::command; the registry IS
|
||||
//! the dispatch table.
|
||||
//!
|
||||
//! Wire envelope:
|
||||
//!
|
||||
//! ```json
|
||||
//! { "op": "call", "fn": "list_sessions", "args": {} }
|
||||
//! { "op": "fetch", "context": "session", "params": {} }
|
||||
//! ```
|
||||
//!
|
||||
//! Response shapes mirror POST /call/ and GET /ctx/.../ from
|
||||
//! mizan-rust-axum:
|
||||
//!
|
||||
//! * `call` → `{ result, invalidate, merge? }`
|
||||
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
|
||||
//!
|
||||
//! Error responses come back as the `Err` variant of the Tauri command's
|
||||
//! `Result`, which Tauri serializes into the JS-side `Promise.reject`.
|
||||
//! The TS-side transport re-wraps it into a `MizanError` so consumers
|
||||
//! see one error surface regardless of transport.
|
||||
|
||||
use mizan_core::{
|
||||
compute_invalidation, compute_merges, lookup_context, lookup_function,
|
||||
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use tauri::{
|
||||
plugin::{Builder, TauriPlugin},
|
||||
Runtime,
|
||||
};
|
||||
|
||||
/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())`
|
||||
/// on the `tauri::Builder`. The plugin name is `mizan`; the dispatch
|
||||
/// command is reachable from JS as `plugin:mizan|mizan_invoke`.
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::<R>::new("mizan")
|
||||
.invoke_handler(tauri::generate_handler![mizan_invoke])
|
||||
.build()
|
||||
}
|
||||
|
||||
// === Wire envelope ===
|
||||
|
||||
/// One Mizan request. The JS-side transport sends `{ envelope: ... }`;
|
||||
/// Tauri's serde deserializer pulls this struct out of the `envelope`
|
||||
/// field of the invoke payload.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "op")]
|
||||
pub enum Envelope {
|
||||
#[serde(rename = "call")]
|
||||
Call {
|
||||
/// Wire-level function name — registered name on the Rust side.
|
||||
#[serde(rename = "fn")]
|
||||
function_name: String,
|
||||
#[serde(default)]
|
||||
args: Map<String, Value>,
|
||||
},
|
||||
#[serde(rename = "fetch")]
|
||||
Fetch {
|
||||
context: String,
|
||||
#[serde(default)]
|
||||
params: Map<String, Value>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Error payload returned to the frontend. Mirrors the HTTP adapter's
|
||||
/// `{"code", "message", "details?"}` shape; the TS-side transport reads
|
||||
/// this and constructs a `MizanError`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorPayload {
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<Value>,
|
||||
}
|
||||
|
||||
impl From<MizanError> for ErrorPayload {
|
||||
fn from(e: MizanError) -> Self {
|
||||
let details = if let MizanError::ValidationFailed { details, .. } = &e {
|
||||
Some(details.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Self {
|
||||
code: e.code(),
|
||||
message: e.message().to_string(),
|
||||
details,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Dispatch ===
|
||||
|
||||
/// The single Mizan dispatch command. Registered on the plugin's invoke
|
||||
/// handler — the consumer never wires it directly.
|
||||
///
|
||||
/// `app: AppHandle` is auto-injected by Tauri; the function body borrows
|
||||
/// it into a `RequestHandle` so `#[mizan::client]` functions can
|
||||
/// `req.downcast::<tauri::AppHandle>()` for app-managed state or event
|
||||
/// emission. Stateless functions ignore the handle.
|
||||
#[tauri::command]
|
||||
async fn mizan_invoke<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
envelope: Envelope,
|
||||
) -> Result<Value, ErrorPayload> {
|
||||
match envelope {
|
||||
Envelope::Call {
|
||||
function_name,
|
||||
args,
|
||||
} => handle_call(&app, &function_name, args).await,
|
||||
Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_call<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
fn_name: &str,
|
||||
args: Map<String, Value>,
|
||||
) -> Result<Value, ErrorPayload> {
|
||||
let fn_spec = lookup_function(fn_name).ok_or_else(|| {
|
||||
ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"function {fn_name:?} not registered"
|
||||
)))
|
||||
})?;
|
||||
|
||||
let req = RequestHandle::new(app);
|
||||
let result = fn_spec
|
||||
.dispatch(req, Value::Object(args.clone()))
|
||||
.await
|
||||
.map_err(ErrorPayload::from)?;
|
||||
|
||||
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &args)
|
||||
.iter()
|
||||
.map(InvalidationTarget::to_json)
|
||||
.collect();
|
||||
let merges = compute_merges(fn_spec, &args, &result);
|
||||
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||
};
|
||||
|
||||
let mut payload = json!({
|
||||
"result": result,
|
||||
"invalidate": invalidate,
|
||||
});
|
||||
if let Some(merge) = merge_payload {
|
||||
payload
|
||||
.as_object_mut()
|
||||
.expect("payload is a JSON object")
|
||||
.insert("merge".into(), Value::Array(merge));
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
async fn handle_fetch<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
context_name: &str,
|
||||
params: Map<String, Value>,
|
||||
) -> Result<Value, ErrorPayload> {
|
||||
if lookup_context(context_name).is_none() {
|
||||
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"context {context_name:?} not registered"
|
||||
))));
|
||||
}
|
||||
|
||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| f.context() == Some(context_name))
|
||||
.collect();
|
||||
if members.is_empty() {
|
||||
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"context {context_name:?} has no registered members"
|
||||
))));
|
||||
}
|
||||
|
||||
let mut bundled = Map::new();
|
||||
for fn_spec in &members {
|
||||
let args = filter_args(*fn_spec, ¶ms);
|
||||
let req = RequestHandle::new(app);
|
||||
let result = fn_spec
|
||||
.dispatch(req, Value::Object(args))
|
||||
.await
|
||||
.map_err(ErrorPayload::from)?;
|
||||
bundled.insert(fn_spec.name().to_string(), result);
|
||||
}
|
||||
|
||||
Ok(Value::Object(bundled))
|
||||
}
|
||||
|
||||
/// Filter the envelope's params down to keys this function declares as
|
||||
/// input. The HTTP/axum adapter coerces string-typed query params to
|
||||
/// JSON primitives in the equivalent step; the Tauri arg channel already
|
||||
/// carries typed JSON, so the filter is sufficient on its own.
|
||||
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> {
|
||||
let mut out = Map::new();
|
||||
for ip in fn_spec.input_params() {
|
||||
if let Some(v) = params.get(ip.name) {
|
||||
out.insert(ip.name.into(), v.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -10,5 +10,5 @@
|
||||
"devDependencies": {
|
||||
"bun-types": "latest"
|
||||
},
|
||||
"license": "MIT"
|
||||
"license": "Elastic-2.0"
|
||||
}
|
||||
|
||||
@@ -52,10 +52,6 @@ function extractParams(fn: Function): ParamDef[] {
|
||||
})
|
||||
}
|
||||
|
||||
function isResponseReturn(result: any): boolean {
|
||||
return result instanceof Response
|
||||
}
|
||||
|
||||
/**
|
||||
* Function wrapper — registers a standalone function.
|
||||
*
|
||||
|
||||
@@ -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/
|
||||
*
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
582
cores/mizan-python/src/mizan_core/ir.py
Normal file
582
cores/mizan-python/src/mizan_core/ir.py
Normal 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"]))
|
||||
104
cores/mizan-python/src/mizan_core/type_utils.py
Normal file
104
cores/mizan-python/src/mizan_core/type_utils.py
Normal 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
54
cores/mizan-rust-macros/Cargo.lock
generated
Normal 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"
|
||||
15
cores/mizan-rust-macros/Cargo.toml
Normal file
15
cores/mizan-rust-macros/Cargo.toml
Normal 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"
|
||||
77
cores/mizan-rust-macros/src/context.rs
Normal file
77
cores/mizan-rust-macros/src/context.rs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
206
cores/mizan-rust-macros/src/derive.rs
Normal file
206
cores/mizan-rust-macros/src/derive.rs
Normal 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),*
|
||||
])
|
||||
}
|
||||
}
|
||||
516
cores/mizan-rust-macros/src/function.rs
Normal file
516
cores/mizan-rust-macros/src/function.rs
Normal 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)
|
||||
}
|
||||
|
||||
58
cores/mizan-rust-macros/src/lib.rs
Normal file
58
cores/mizan-rust-macros/src/lib.rs
Normal 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()
|
||||
}
|
||||
208
cores/mizan-rust-macros/src/shape.rs
Normal file
208
cores/mizan-rust-macros/src/shape.rs
Normal 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
2445
cores/mizan-rust-ssr/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
cores/mizan-rust-ssr/Cargo.toml
Normal file
22
cores/mizan-rust-ssr/Cargo.toml
Normal 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"] }
|
||||
133
cores/mizan-rust-ssr/src/lib.rs
Normal file
133
cores/mizan-rust-ssr/src/lib.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
3
cores/mizan-rust-ssr/tests/fixture/.gitignore
vendored
Normal file
3
cores/mizan-rust-ssr/tests/fixture/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
bundle.js
|
||||
7
cores/mizan-rust-ssr/tests/fixture/Hello.js
Normal file
7
cores/mizan-rust-ssr/tests/fixture/Hello.js
Normal 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}!`)
|
||||
}
|
||||
8
cores/mizan-rust-ssr/tests/fixture/entry.js
Normal file
8
cores/mizan-rust-ssr/tests/fixture/entry.js
Normal 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))
|
||||
7
cores/mizan-rust-ssr/tests/fixture/package.json
Normal file
7
cores/mizan-rust-ssr/tests/fixture/package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"esbuild": "^0.28.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
}
|
||||
}
|
||||
29
cores/mizan-rust-ssr/tests/fixture/runner.cjs
Normal file
29
cores/mizan-rust-ssr/tests/fixture/runner.cjs
Normal 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)")
|
||||
54
cores/mizan-rust-ssr/tests/no_rsc.rs
Normal file
54
cores/mizan-rust-ssr/tests/no_rsc.rs
Normal 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
173
cores/mizan-rust/Cargo.lock
generated
Normal 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"
|
||||
16
cores/mizan-rust/Cargo.toml
Normal file
16
cores/mizan-rust/Cargo.toml
Normal 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"
|
||||
200
cores/mizan-rust/src/graph_check.rs
Normal file
200
cores/mizan-rust/src/graph_check.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
93
cores/mizan-rust/src/ir.rs
Normal file
93
cores/mizan-rust/src/ir.rs
Normal 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
508
cores/mizan-rust/src/kdl.rs
Normal 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()
|
||||
}
|
||||
|
||||
57
cores/mizan-rust/src/lib.rs
Normal file
57
cores/mizan-rust/src/lib.rs
Normal 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;
|
||||
}
|
||||
47
cores/mizan-rust/src/registry.rs
Normal file
47
cores/mizan-rust/src/registry.rs
Normal 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()
|
||||
}
|
||||
260
cores/mizan-rust/src/runtime.rs
Normal file
260
cores/mizan-rust/src/runtime.rs
Normal 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()
|
||||
}
|
||||
|
||||
92
cores/mizan-rust/src/traits.rs
Normal file
92
cores/mizan-rust/src/traits.rs
Normal 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,
|
||||
}
|
||||
129
cores/mizan-rust/tests/afi_parity.rs
Normal file
129
cores/mizan-rust/tests/afi_parity.rs
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,18 +9,31 @@ Tree organized by role.
|
||||
|
||||
```
|
||||
backends/ server protocol adapters
|
||||
mizan-django/ Django adapter
|
||||
mizan-fastapi/ FastAPI adapter (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
|
||||
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 + 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-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
|
||||
|
||||
|
||||
@@ -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))
|
||||
"u": user_id // omitted for public content
|
||||
}, 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -22,17 +22,16 @@ multi-state privacy. ~$5–8K 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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Fixtures } from './fixtures'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<MizanContext baseUrl="/api/mizan">
|
||||
<MizanContext baseUrl="/api/mizan" session={false}>
|
||||
<Fixtures />
|
||||
</MizanContext>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"license": "MIT"
|
||||
"license": "Elastic-2.0"
|
||||
}
|
||||
|
||||
@@ -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,51 +386,89 @@ async function resolveHeaders(): Promise<Record<string, string>> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default Mizan transport — POST `${baseUrl}/call/` and GET
|
||||
* `${baseUrl}/ctx/${name}/`. Compatible with `mizan-fastapi`,
|
||||
* `mizan-django`, and `mizan-rust-axum`. Swap with a different
|
||||
* transport via `configure({ transport })` when running in a
|
||||
* non-HTTP host (e.g. Tauri).
|
||||
*/
|
||||
export function httpTransport(): MizanTransport {
|
||||
return {
|
||||
async call(functionName, args) {
|
||||
const headers = await resolveHeaders()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
|
||||
const res = await fetch(`${config.baseUrl}/call/`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ fn: functionName, args }),
|
||||
})
|
||||
if (!res.ok) throw new MizanError(res.status, await res.text())
|
||||
return res.json()
|
||||
},
|
||||
async fetch(contextName, params) {
|
||||
const url = new URL(
|
||||
`${config.baseUrl}/ctx/${contextName}/`,
|
||||
typeof globalThis.location !== 'undefined'
|
||||
? globalThis.location.origin
|
||||
: 'http://localhost',
|
||||
)
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
url.searchParams.set(k, String(v))
|
||||
}
|
||||
}
|
||||
const headers = await resolveHeaders()
|
||||
const res = await fetchWithRetry(url.toString(), {
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (!res.ok) throw new MizanError(res.status, await res.text())
|
||||
return res.json()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Install the default transport now that httpTransport is in scope. The
|
||||
// config object was constructed earlier with a placeholder so the type
|
||||
// stayed honest; this line is the actual binding.
|
||||
config.transport = httpTransport()
|
||||
|
||||
export async function mizanFetch(
|
||||
contextName: string,
|
||||
params?: Record<string, any>,
|
||||
): Promise<any> {
|
||||
const url = new URL(
|
||||
`${config.baseUrl}/ctx/${contextName}/`,
|
||||
typeof globalThis.location !== 'undefined' ? globalThis.location.origin : 'http://localhost',
|
||||
)
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
url.searchParams.set(k, String(v))
|
||||
}
|
||||
}
|
||||
|
||||
const headers = await resolveHeaders()
|
||||
const res = await fetchWithRetry(url.toString(), { headers, credentials: 'same-origin' })
|
||||
if (!res.ok) throw new MizanError(res.status, await res.text())
|
||||
return res.json()
|
||||
return config.transport.fetch(contextName, params)
|
||||
}
|
||||
|
||||
export async function mizanCall(
|
||||
functionName: string,
|
||||
args: Record<string, any>,
|
||||
): Promise<any> {
|
||||
const headers = await resolveHeaders()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
const data = await config.transport.call(functionName, args)
|
||||
|
||||
const res = await fetch(`${config.baseUrl}/call/`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ fn: functionName, args }),
|
||||
})
|
||||
if (!res.ok) throw new MizanError(res.status, await res.text())
|
||||
|
||||
const data = await res.json()
|
||||
// Server-driven merges run before invalidations so a context that is
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
1
frontends/mizan-rust/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
target/
|
||||
1697
frontends/mizan-rust/Cargo.lock
generated
Normal file
1697
frontends/mizan-rust/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontends/mizan-rust/Cargo.toml
Normal file
24
frontends/mizan-rust/Cargo.toml
Normal 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"] }
|
||||
195
frontends/mizan-rust/src/client.rs
Normal file
195
frontends/mizan-rust/src/client.rs
Normal 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(®istry));
|
||||
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;
|
||||
}
|
||||
}
|
||||
365
frontends/mizan-rust/src/context.rs
Normal file
365
frontends/mizan-rust/src/context.rs
Normal 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(¶ms);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
121
frontends/mizan-rust/src/error.rs
Normal file
121
frontends/mizan-rust/src/error.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
148
frontends/mizan-rust/src/invalidation.rs
Normal file
148
frontends/mizan-rust/src/invalidation.rs
Normal 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(®istry));
|
||||
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);
|
||||
}
|
||||
}
|
||||
28
frontends/mizan-rust/src/lib.rs
Normal file
28
frontends/mizan-rust/src/lib.rs
Normal 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
Reference in New Issue
Block a user