Mutation→context merge primitive across the stack
The @client(merge=[context, ...]) decorator lets a mutation patch its
return value directly into the cached context bundle by matching the
mutation's Output type against each context-function's Output type
to identify the slot, then splicing server-side. Kernel runs
splice_slot on the response to apply locally — no refetch, no
invalidate-cascade.
Lands H14, H15, H16, M19, M20 from ISSUES.md.
Backends (Django + FastAPI):
_resolve_merges() in both executors walks @client(merge=...) targets,
resolves the per-context slot via types_match_for_merge, and emits
{context, slot, value, params?} entries on the response. Param
auto-scoping mirrors _resolve_invalidation's tier-1 logic.
Frontend kernel (mizan-base):
Response handler reads the merge[] array and applies splice_slot
for each entry — locates the cached context bundle by name+params,
overwrites the named slot with the new value, notifies subscribers.
Core (mizan-python):
@client decorator extended with merge= parameter. Schema export
threads merge metadata onto the OpenAPI x-mizan-functions entries.
Examples / fixtures:
fastapi-react-site harness exercises merge + Playwright spec covers
the end-to-end happy path (mutation → instant UI update without
network refetch). AFI fixture's rename_user function is the
canonical merge target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,8 +18,13 @@ Identified by domain expert review (Cloudflare, Serverless, Vercel, React Query,
|
|||||||
- ~~H10~~ _meta always fresh dict
|
- ~~H10~~ _meta always fresh dict
|
||||||
- ~~H11~~ Python normalizes True→"true" for cross-language HMAC
|
- ~~H11~~ Python normalizes True→"true" for cross-language HMAC
|
||||||
- ~~H13~~ isValid checks all required fields are touched
|
- ~~H13~~ isValid checks all required fields are touched
|
||||||
|
- ~~H14~~ `@client(merge=...)` primitive — kernel splices return value into cached context, no refetch
|
||||||
|
- ~~H15~~ Schema export handles `BaseModel | None` return types
|
||||||
|
- ~~H16~~ Generated `react.tsx` imports per-function `*Output` types
|
||||||
- ~~M11~~ execute_function return type includes HttpResponseBase
|
- ~~M11~~ execute_function return type includes HttpResponseBase
|
||||||
- ~~M18~~ registerContext cleanup uses ?. (no crash)
|
- ~~M18~~ registerContext cleanup uses ?. (no crash)
|
||||||
|
- ~~M19~~ `list[BaseModel]` returns reach the wire as bare arrays (RootModel-based rename, no `{result: ...}` wrap)
|
||||||
|
- ~~M20~~ `initSession()` gated on `configure({session: bool})` (Django default-on, FastAPI opt-out)
|
||||||
|
|
||||||
## Remaining Critical
|
## Remaining Critical
|
||||||
|
|
||||||
|
|||||||
@@ -305,6 +305,75 @@ def _resolve_invalidation(
|
|||||||
return result if result else None
|
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(
|
def _format_invalidate_header(
|
||||||
invalidate: list[str | dict[str, Any]],
|
invalidate: list[str | dict[str, Any]],
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -488,10 +557,12 @@ def execute_function(
|
|||||||
output["Cache-Control"] = "no-store"
|
output["Cache-Control"] = "no-store"
|
||||||
return output
|
return output
|
||||||
|
|
||||||
# RPC path — serialize output
|
# RPC path — serialize output. to_jsonable_python walks BaseModel /
|
||||||
if output is None:
|
# list / dict recursively, so list[BaseModel] (and nested shapes) come
|
||||||
return FunctionResult(data=None)
|
# out wire-ready without a per-shape branch.
|
||||||
return FunctionResult(data=output.model_dump())
|
from pydantic_core import to_jsonable_python
|
||||||
|
|
||||||
|
return FunctionResult(data=to_jsonable_python(output))
|
||||||
|
|
||||||
|
|
||||||
def _try_mwt_auth(request: HttpRequest) -> bool:
|
def _try_mwt_auth(request: HttpRequest) -> bool:
|
||||||
@@ -731,9 +802,12 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
view_class = get_function(fn_name)
|
view_class = get_function(fn_name)
|
||||||
response_data = {"result": result.data}
|
response_data = {"result": result.data}
|
||||||
invalidate_contexts = _resolve_invalidation(view_class, input_data)
|
invalidate_contexts = _resolve_invalidation(view_class, input_data)
|
||||||
|
merges = _resolve_merges(view_class, input_data, result.data)
|
||||||
|
|
||||||
if invalidate_contexts:
|
if invalidate_contexts:
|
||||||
response_data["invalidate"] = invalidate_contexts
|
response_data["invalidate"] = invalidate_contexts
|
||||||
|
if merges:
|
||||||
|
response_data["merge"] = merges
|
||||||
|
|
||||||
response = JsonResponse(response_data)
|
response = JsonResponse(response_data)
|
||||||
response["Cache-Control"] = "no-store"
|
response["Cache-Control"] = "no-store"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ if TYPE_CHECKING:
|
|||||||
from ninja import NinjaAPI
|
from ninja import NinjaAPI
|
||||||
|
|
||||||
from mizan_core.registry import get_registry, get_schema, get_context_groups, get_function
|
from mizan_core.registry import get_registry, get_schema, get_context_groups, get_function
|
||||||
|
from mizan_core.type_utils import extract_list_element, extract_optional
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -200,7 +201,7 @@ def generate_openapi_schema() -> dict[str, Any]:
|
|||||||
Returns a complete OpenAPI document that can be processed by openapi-typescript.
|
Returns a complete OpenAPI document that can be processed by openapi-typescript.
|
||||||
"""
|
"""
|
||||||
from ninja import NinjaAPI # Lazy import
|
from ninja import NinjaAPI # Lazy import
|
||||||
from pydantic import BaseModel, create_model # Lazy import
|
from pydantic import BaseModel, RootModel, create_model # Lazy import
|
||||||
|
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
functions = registry.get("functions", {})
|
functions = registry.get("functions", {})
|
||||||
@@ -241,16 +242,30 @@ def generate_openapi_schema() -> dict[str, Any]:
|
|||||||
input_type_name = f"{camel_name}Input" if has_input else None
|
input_type_name = f"{camel_name}Input" if has_input else None
|
||||||
output_type_name = f"{camel_name}Output"
|
output_type_name = f"{camel_name}Output"
|
||||||
|
|
||||||
|
# Strip Optional so the rename gets a concrete base — nullability is
|
||||||
|
# carried on the response declaration, not the schema class itself.
|
||||||
|
output_inner, output_nullable = extract_optional(output_cls)
|
||||||
|
|
||||||
# Create renamed Pydantic classes for cleaner schema names
|
# Create renamed Pydantic classes for cleaner schema names
|
||||||
# Store them in schema_classes so they persist beyond loop scope
|
# 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:
|
if has_input:
|
||||||
schema_classes[input_type_name] = create_model(
|
schema_classes[input_type_name] = create_model(
|
||||||
input_type_name, __base__=input_cls
|
input_type_name, __base__=input_cls
|
||||||
)
|
)
|
||||||
schema_classes[output_type_name] = create_model(
|
if extract_list_element(output_inner) is not None:
|
||||||
output_type_name, __base__=output_cls
|
# list[T] — RootModel makes the rename emit `type: array` rather
|
||||||
|
# than wrapping the list in a property.
|
||||||
|
schema_classes[output_type_name] = type(
|
||||||
|
output_type_name, (RootModel[output_inner],), {},
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
schema_classes[output_type_name] = create_model(
|
||||||
|
output_type_name, __base__=output_inner
|
||||||
|
)
|
||||||
|
|
||||||
|
response_cls = schema_classes[output_type_name]
|
||||||
|
if output_nullable:
|
||||||
|
response_cls = response_cls | None
|
||||||
|
|
||||||
# Register endpoint using helper to avoid closure capture issues
|
# Register endpoint using helper to avoid closure capture issues
|
||||||
_register_schema_endpoint(
|
_register_schema_endpoint(
|
||||||
@@ -259,7 +274,7 @@ def generate_openapi_schema() -> dict[str, Any]:
|
|||||||
operation_id=camel_name,
|
operation_id=camel_name,
|
||||||
summary=fn_class.__doc__ or f"Call {name}",
|
summary=fn_class.__doc__ or f"Call {name}",
|
||||||
input_cls=schema_classes.get(input_type_name),
|
input_cls=schema_classes.get(input_type_name),
|
||||||
output_cls=schema_classes[output_type_name],
|
output_cls=response_cls,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Collect function metadata for provider generation
|
# Collect function metadata for provider generation
|
||||||
@@ -280,6 +295,8 @@ def generate_openapi_schema() -> dict[str, Any]:
|
|||||||
# Affects metadata (mutation invalidation)
|
# Affects metadata (mutation invalidation)
|
||||||
if meta.get("affects"):
|
if meta.get("affects"):
|
||||||
fn_meta_entry["affects"] = meta["affects"]
|
fn_meta_entry["affects"] = meta["affects"]
|
||||||
|
if meta.get("merge"):
|
||||||
|
fn_meta_entry["merge"] = meta["merge"]
|
||||||
|
|
||||||
# For form schema functions, extract field definitions for Zod generation
|
# For form schema functions, extract field definitions for Zod generation
|
||||||
if meta.get("form") and meta.get("form_role") == "schema":
|
if meta.get("form") and meta.get("form_role") == "schema":
|
||||||
|
|||||||
@@ -1033,6 +1033,44 @@ class ServerDrivenInvalidationTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
self.assertEqual(response["Cache-Control"], "no-store")
|
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):
|
class ContextFetchTests(TestCase):
|
||||||
"""Tests for the bundled context fetch endpoint (execute_context)."""
|
"""Tests for the bundled context fetch endpoint (execute_context)."""
|
||||||
@@ -1383,6 +1421,30 @@ class TypeAnnotationTests(TestCase):
|
|||||||
self.assertIsInstance(result, FunctionResult)
|
self.assertIsInstance(result, FunctionResult)
|
||||||
self.assertIsNone(result.data)
|
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
|
# RPC Mode Tests
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ from __future__ import annotations
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
from pydantic import BaseModel, ValidationError
|
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 ─────────────────────────────────────────────────────────
|
# ─── Error taxonomy ─────────────────────────────────────────────────────────
|
||||||
@@ -148,15 +150,21 @@ def _resolve_function(fn_name: str) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
def _serialize(result: Any) -> 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,
|
request: Any,
|
||||||
fn_name: str,
|
fn_name: str,
|
||||||
input_data: dict[str, Any] | None = None,
|
input_data: dict[str, Any] | None = None,
|
||||||
) -> Any:
|
) -> 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)
|
view_class = _resolve_function(fn_name)
|
||||||
_enforce_auth(request, view_class._meta.get("auth"))
|
_enforce_auth(request, view_class._meta.get("auth"))
|
||||||
|
|
||||||
@@ -164,7 +172,7 @@ def execute_function(
|
|||||||
validated = _validate_input(view.Input, input_data)
|
validated = _validate_input(view.Input, input_data)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = view.call(validated)
|
result = await view.acall(validated)
|
||||||
except NotImplementedError as e:
|
except NotImplementedError as e:
|
||||||
raise NotImplementedYet(str(e) or "Not implemented") from e
|
raise NotImplementedYet(str(e) or "Not implemented") from e
|
||||||
except MizanError:
|
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]
|
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:
|
def _invalidation_target(target: dict[str, Any], input_data: dict[str, Any]) -> Any:
|
||||||
match target.get("type"):
|
match target.get("type"):
|
||||||
case "context":
|
case "context":
|
||||||
name = target["name"]
|
name = target["name"]
|
||||||
scope_keys = (target.get("params") or {}).keys()
|
scoped = _scoped_params(name, input_data)
|
||||||
scoped = {k: input_data[k] for k in scope_keys if k in input_data}
|
|
||||||
return {"context": name, "params": scoped} if scoped else name
|
return {"context": name, "params": scoped} if scoped else name
|
||||||
case "function":
|
case "function":
|
||||||
return {"function": target["name"]}
|
return {"function": target["name"]}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from .executor import (
|
|||||||
MizanError,
|
MizanError,
|
||||||
NotFound,
|
NotFound,
|
||||||
compute_invalidation,
|
compute_invalidation,
|
||||||
|
compute_merges,
|
||||||
execute_function,
|
execute_function,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,10 +50,15 @@ class CallBody(BaseModel):
|
|||||||
|
|
||||||
@router.post("/call/")
|
@router.post("/call/")
|
||||||
async def function_call(body: CallBody, request: Request) -> JSONResponse:
|
async def function_call(body: CallBody, request: Request) -> JSONResponse:
|
||||||
"""RPC dispatch — `{"fn": "...", "args": {...}}` → `{"result": ..., "invalidate": [...]}`."""
|
"""RPC dispatch — `{"fn": "...", "args": {...}}` → `{"result": ..., "invalidate": [...], "merge"?: [...]}`."""
|
||||||
result = execute_function(request, body.fn, body.args)
|
fn_class = get_function(body.fn)
|
||||||
invalidate = compute_invalidation(get_function(body.fn), body.args)
|
result = await execute_function(request, body.fn, body.args)
|
||||||
return _no_store({"result": result, "invalidate": invalidate})
|
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}/")
|
@router.get("/ctx/{context_name}/")
|
||||||
@@ -63,7 +69,7 @@ async def context_fetch(context_name: str, request: Request) -> JSONResponse:
|
|||||||
raise NotFound(f"Context '{context_name}' not found")
|
raise NotFound(f"Context '{context_name}' not found")
|
||||||
|
|
||||||
params = dict(request.query_params)
|
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)
|
return _no_store(bundled)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
@@ -34,6 +36,11 @@ class UserOutput(BaseModel):
|
|||||||
authenticated: bool
|
authenticated: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ItemOutput(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app():
|
def app():
|
||||||
"""Build a fresh FastAPI app + Mizan router with a few @client functions."""
|
"""Build a fresh FastAPI app + Mizan router with a few @client functions."""
|
||||||
@@ -63,12 +70,39 @@ def app():
|
|||||||
def whoami(request) -> UserOutput:
|
def whoami(request) -> UserOutput:
|
||||||
return UserOutput(email="real@example.com", authenticated=True)
|
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(echo, "echo")
|
||||||
register(add, "add")
|
register(add, "add")
|
||||||
register(current_user, "current_user")
|
register(current_user, "current_user")
|
||||||
register(user_count, "user_count")
|
register(user_count, "user_count")
|
||||||
register(update_email, "update_email")
|
register(update_email, "update_email")
|
||||||
register(whoami, "whoami")
|
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 = FastAPI()
|
||||||
fastapi_app.include_router(mizan_router, prefix="/api/mizan")
|
fastapi_app.include_router(mizan_router, prefix="/api/mizan")
|
||||||
@@ -171,3 +205,58 @@ class InvalidationTests:
|
|||||||
body = r.json()
|
body = r.json()
|
||||||
# affects='user' is a context-name string → invalidate list contains 'user'
|
# affects='user' is a context-name string → invalidate list contains 'user'
|
||||||
assert "user" in body["invalidate"]
|
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"] == []
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Two styles supported:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import inspect
|
import inspect
|
||||||
import warnings
|
import warnings
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
@@ -165,6 +166,16 @@ class ServerFunction(ABC, Generic[TInput, TOutput]):
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError(f"{self.__class__.__name__} must implement call()")
|
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
|
@classmethod
|
||||||
def get_schema_export(cls) -> dict[str, Any]:
|
def get_schema_export(cls) -> dict[str, Any]:
|
||||||
"""Export schema for TypeScript generation."""
|
"""Export schema for TypeScript generation."""
|
||||||
@@ -207,17 +218,30 @@ class _FunctionWrapper(ServerFunction):
|
|||||||
|
|
||||||
def call(self, input):
|
def call(self, input):
|
||||||
"""Execute the wrapped function, unpacking input into individual args."""
|
"""Execute the wrapped function, unpacking input into individual args."""
|
||||||
if input is not None and self._param_names:
|
return self._postprocess(self._invoke_sync(input))
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
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)
|
# View path — return a framework-native response directly (no serialization)
|
||||||
if is_framework_response(result):
|
if is_framework_response(result):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Wrap primitive returns in the generated output model
|
# Wrap primitive returns in the generated output model
|
||||||
if self._is_primitive_output:
|
if self._is_primitive_output:
|
||||||
return self._output_cls(result=result)
|
return self._output_cls(result=result)
|
||||||
@@ -276,12 +300,18 @@ def _resolve_context(context: ContextMode) -> str | Literal[False]:
|
|||||||
AffectsTarget = ReactContext | str | type["ServerFunction"]
|
AffectsTarget = ReactContext | str | type["ServerFunction"]
|
||||||
AffectsMode = AffectsTarget | list[AffectsTarget] | None
|
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(
|
def client(
|
||||||
fn: Callable = None,
|
fn: Callable = None,
|
||||||
*,
|
*,
|
||||||
context: ContextMode = False,
|
context: ContextMode = False,
|
||||||
affects: AffectsMode = None,
|
affects: AffectsMode = None,
|
||||||
|
merge: MergeMode = None,
|
||||||
private: bool = False,
|
private: bool = False,
|
||||||
route: str | None = None,
|
route: str | None = None,
|
||||||
methods: list[str] | None = None,
|
methods: list[str] | None = None,
|
||||||
@@ -303,6 +333,12 @@ def client(
|
|||||||
Mutually exclusive with context=.
|
Mutually exclusive with context=.
|
||||||
Scoping is automatic via argument name matching.
|
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.
|
private: If True, the function is not client-callable.
|
||||||
- Not exposed as an RPC endpoint
|
- Not exposed as an RPC endpoint
|
||||||
- No generated TypeScript
|
- No generated TypeScript
|
||||||
@@ -352,6 +388,13 @@ def client(
|
|||||||
"A function cannot be both a context reader and a mutation."
|
"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
|
# Validate auth parameter
|
||||||
if auth is not None:
|
if auth is not None:
|
||||||
if isinstance(auth, str) and auth not in _VALID_AUTH_STRINGS:
|
if isinstance(auth, str) and auth not in _VALID_AUTH_STRINGS:
|
||||||
@@ -362,7 +405,7 @@ def client(
|
|||||||
|
|
||||||
def decorator(fn: Callable) -> type[ServerFunction]:
|
def decorator(fn: Callable) -> type[ServerFunction]:
|
||||||
return _create_server_function(
|
return _create_server_function(
|
||||||
fn, context=resolved_context, affects=affects,
|
fn, context=resolved_context, affects=affects, merge=merge,
|
||||||
private=private, route=route, methods=methods,
|
private=private, route=route, methods=methods,
|
||||||
websocket=websocket, auth=auth, rev=rev, cache=cache,
|
websocket=websocket, auth=auth, rev=rev, cache=cache,
|
||||||
)
|
)
|
||||||
@@ -370,13 +413,32 @@ def client(
|
|||||||
# Support both @client and @client(...)
|
# Support both @client and @client(...)
|
||||||
if fn is not None:
|
if fn is not None:
|
||||||
return _create_server_function(
|
return _create_server_function(
|
||||||
fn, context=resolved_context, affects=affects,
|
fn, context=resolved_context, affects=affects, merge=merge,
|
||||||
private=private, route=route, methods=methods,
|
private=private, route=route, methods=methods,
|
||||||
websocket=websocket, auth=auth, rev=rev, cache=cache,
|
websocket=websocket, auth=auth, rev=rev, cache=cache,
|
||||||
)
|
)
|
||||||
return decorator
|
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:
|
def _normalize_affects(affects: AffectsMode) -> list[dict[str, str]] | None:
|
||||||
"""Normalize the affects parameter into a list of target descriptors."""
|
"""Normalize the affects parameter into a list of target descriptors."""
|
||||||
if affects is None:
|
if affects is None:
|
||||||
@@ -410,6 +472,7 @@ def _create_server_function(
|
|||||||
*,
|
*,
|
||||||
context: str | Literal[False] = False,
|
context: str | Literal[False] = False,
|
||||||
affects: str | type["ServerFunction"] | list[str | type["ServerFunction"]] | None = None,
|
affects: str | type["ServerFunction"] | list[str | type["ServerFunction"]] | None = None,
|
||||||
|
merge: MergeMode = None,
|
||||||
private: bool = False,
|
private: bool = False,
|
||||||
route: str | None = None,
|
route: str | None = None,
|
||||||
methods: list[str] | None = None,
|
methods: list[str] | None = None,
|
||||||
@@ -468,25 +531,9 @@ def _create_server_function(
|
|||||||
is_primitive_output = False
|
is_primitive_output = False
|
||||||
else:
|
else:
|
||||||
# RPC path — resolve output type
|
# RPC path — resolve output type
|
||||||
import types
|
from mizan_core.type_utils import is_structured_output
|
||||||
|
|
||||||
def is_basemodel_type(t: Any) -> bool:
|
if is_structured_output(output_type):
|
||||||
"""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):
|
|
||||||
output_cls = output_type
|
output_cls = output_type
|
||||||
is_primitive_output = False
|
is_primitive_output = False
|
||||||
else:
|
else:
|
||||||
@@ -538,6 +585,11 @@ def _create_server_function(
|
|||||||
if normalized_affects:
|
if normalized_affects:
|
||||||
meta["affects"] = 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
|
# WebSocket: enable WebSocket transport
|
||||||
if websocket:
|
if websocket:
|
||||||
meta["websocket"] = True
|
meta["websocket"] = True
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
type ContextState,
|
type ContextState,
|
||||||
} from '@mizan/base'
|
} 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.
|
// Internal — runs inside a Provider, registers with the kernel exactly once.
|
||||||
function useContextSubscription<T>(
|
function useContextSubscription<T>(
|
||||||
@@ -174,6 +174,8 @@ export function useJwtRefresh() {
|
|||||||
export interface MizanContextProps {
|
export interface MizanContextProps {
|
||||||
/** Base URL for protocol endpoints. Defaults to "/api/mizan". */
|
/** Base URL for protocol endpoints. Defaults to "/api/mizan". */
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
|
/** Set to `false` for backends without a `/session/` endpoint (e.g. FastAPI). */
|
||||||
|
session?: boolean
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,10 +183,13 @@ export interface MizanContextProps {
|
|||||||
* Root provider — calls configure() once and mounts the global context (if defined).
|
* Root provider — calls configure() once and mounts the global context (if defined).
|
||||||
* Must wrap any component using Mizan-generated hooks.
|
* 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)
|
const configured = useRef(false)
|
||||||
if (!configured.current) {
|
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
|
configured.current = true
|
||||||
}
|
}
|
||||||
return <GlobalContextProvider>{children}</GlobalContextProvider>
|
return <GlobalContextProvider>{children}</GlobalContextProvider>
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────────────
|
# ─── Registration ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -156,6 +208,9 @@ register(not_implemented_fn, "not_implemented_fn")
|
|||||||
register(buggy_fn, "buggy_fn")
|
register(buggy_fn, "buggy_fn")
|
||||||
register(permission_check_fn, "permission_check_fn")
|
register(permission_check_fn, "permission_check_fn")
|
||||||
register(current_user, "current_user")
|
register(current_user, "current_user")
|
||||||
|
register(morph_groups, "morph_groups")
|
||||||
|
register(morph_layers, "morph_layers")
|
||||||
|
register(set_morph_value, "set_morph_value")
|
||||||
|
|
||||||
|
|
||||||
# ─── App ────────────────────────────────────────────────────────────────────
|
# ─── 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 * from './types'
|
||||||
|
|
||||||
export { fetchGlobalContext, type GlobalContextData, type GlobalContextParams } from './contexts/global'
|
export { fetchGlobalContext, type GlobalContextData, type GlobalContextParams } from './contexts/global'
|
||||||
|
export { fetchMorphsContext, type MorphsContextData, type MorphsContextParams } from './contexts/morphs'
|
||||||
|
|
||||||
export { callEcho } from './functions/echo'
|
export { callEcho } from './functions/echo'
|
||||||
export { callAdd } from './functions/add'
|
export { callAdd } from './functions/add'
|
||||||
@@ -14,6 +15,7 @@ export { callVerifiedOnly } from './functions/verifiedOnly'
|
|||||||
export { callNotImplementedFn } from './functions/notImplementedFn'
|
export { callNotImplementedFn } from './functions/notImplementedFn'
|
||||||
export { callBuggyFn } from './functions/buggyFn'
|
export { callBuggyFn } from './functions/buggyFn'
|
||||||
export { callPermissionCheckFn } from './functions/permissionCheckFn'
|
export { callPermissionCheckFn } from './functions/permissionCheckFn'
|
||||||
|
export { callSetMorphValue } from './functions/setMorphValue'
|
||||||
|
|
||||||
// Stage 2 framework adapter
|
// Stage 2 framework adapter
|
||||||
export * from './react'
|
export * from './react'
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
type ContextState,
|
type ContextState,
|
||||||
} from '@mizan/base'
|
} 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.
|
// Internal — runs inside a Provider, registers with the kernel exactly once.
|
||||||
function useContextSubscription<T>(
|
function useContextSubscription<T>(
|
||||||
@@ -94,6 +94,29 @@ export function useCurrentUser(): currentUserOutput | null {
|
|||||||
return useGlobalContext().data?.current_user ?? 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() {
|
export function useEcho() {
|
||||||
return useMutation<Parameters<typeof callEcho>[0], Awaited<ReturnType<typeof callEcho>>>(callEcho)
|
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)
|
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 ──
|
// ── MizanContext root provider ──
|
||||||
|
|
||||||
export interface MizanContextProps {
|
export interface MizanContextProps {
|
||||||
/** Base URL for protocol endpoints. Defaults to "/api/mizan". */
|
/** Base URL for protocol endpoints. Defaults to "/api/mizan". */
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
|
/** Set to `false` for backends without a `/session/` endpoint (e.g. FastAPI). */
|
||||||
|
session?: boolean
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,10 +175,13 @@ export interface MizanContextProps {
|
|||||||
* Root provider — calls configure() once and mounts the global context (if defined).
|
* Root provider — calls configure() once and mounts the global context (if defined).
|
||||||
* Must wrap any component using Mizan-generated hooks.
|
* 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)
|
const configured = useRef(false)
|
||||||
if (!configured.current) {
|
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
|
configured.current = true
|
||||||
}
|
}
|
||||||
return <GlobalContextProvider>{children}</GlobalContextProvider>
|
return <GlobalContextProvider>{children}</GlobalContextProvider>
|
||||||
|
|||||||
@@ -327,6 +327,92 @@
|
|||||||
"isContext": "global"
|
"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": {
|
"components": {
|
||||||
@@ -344,6 +430,58 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "HTTPValidationError"
|
"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": {
|
"ValidationError": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"loc": {
|
"loc": {
|
||||||
@@ -477,6 +615,20 @@
|
|||||||
],
|
],
|
||||||
"title": "echoOutput"
|
"title": "echoOutput"
|
||||||
},
|
},
|
||||||
|
"morphGroupsOutput": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/MorphGroupMeta"
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"title": "morphGroupsOutput"
|
||||||
|
},
|
||||||
|
"morphLayersOutput": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/MorphLayer"
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"title": "morphLayersOutput"
|
||||||
|
},
|
||||||
"multiplyInput": {
|
"multiplyInput": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"x": {
|
"x": {
|
||||||
@@ -547,6 +699,52 @@
|
|||||||
],
|
],
|
||||||
"title": "permissionCheckFnOutput"
|
"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": {
|
"staffOnlyOutput": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"message": {
|
"message": {
|
||||||
@@ -743,6 +941,45 @@
|
|||||||
"isForm": false,
|
"isForm": false,
|
||||||
"formName": null,
|
"formName": null,
|
||||||
"formRole": 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": {
|
"x-mizan-contexts": {
|
||||||
@@ -751,6 +988,13 @@
|
|||||||
"current_user"
|
"current_user"
|
||||||
],
|
],
|
||||||
"params": {}
|
"params": {}
|
||||||
|
},
|
||||||
|
"morphs": {
|
||||||
|
"functions": [
|
||||||
|
"morph_groups",
|
||||||
|
"morph_layers"
|
||||||
|
],
|
||||||
|
"params": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,6 +188,57 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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 type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@@ -197,6 +248,31 @@ export interface components {
|
|||||||
/** Detail */
|
/** Detail */
|
||||||
detail?: components["schemas"]["ValidationError"][];
|
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 */
|
||||||
ValidationError: {
|
ValidationError: {
|
||||||
/** Location */
|
/** Location */
|
||||||
@@ -249,6 +325,10 @@ export interface components {
|
|||||||
/** Message */
|
/** Message */
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
/** morphGroupsOutput */
|
||||||
|
morphGroupsOutput: components["schemas"]["MorphGroupMeta"][];
|
||||||
|
/** morphLayersOutput */
|
||||||
|
morphLayersOutput: components["schemas"]["MorphLayer"][];
|
||||||
/** multiplyInput */
|
/** multiplyInput */
|
||||||
multiplyInput: {
|
multiplyInput: {
|
||||||
/** X */
|
/** X */
|
||||||
@@ -276,6 +356,24 @@ export interface components {
|
|||||||
/** Message */
|
/** Message */
|
||||||
message: string;
|
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 */
|
||||||
staffOnlyOutput: {
|
staffOnlyOutput: {
|
||||||
/** Message */
|
/** 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
|
// Convenience type exports
|
||||||
export type HTTPValidationError = components["schemas"]["HTTPValidationError"]
|
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 ValidationError = components["schemas"]["ValidationError"]
|
||||||
export type addInput = components["schemas"]["addInput"]
|
export type addInput = components["schemas"]["addInput"]
|
||||||
export type addOutput = components["schemas"]["addOutput"]
|
export type addOutput = components["schemas"]["addOutput"]
|
||||||
@@ -596,11 +769,15 @@ export type buggyFnOutput = components["schemas"]["buggyFnOutput"]
|
|||||||
export type currentUserOutput = components["schemas"]["currentUserOutput"]
|
export type currentUserOutput = components["schemas"]["currentUserOutput"]
|
||||||
export type echoInput = components["schemas"]["echoInput"]
|
export type echoInput = components["schemas"]["echoInput"]
|
||||||
export type echoOutput = components["schemas"]["echoOutput"]
|
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 multiplyInput = components["schemas"]["multiplyInput"]
|
||||||
export type multiplyOutput = components["schemas"]["multiplyOutput"]
|
export type multiplyOutput = components["schemas"]["multiplyOutput"]
|
||||||
export type notImplementedFnOutput = components["schemas"]["notImplementedFnOutput"]
|
export type notImplementedFnOutput = components["schemas"]["notImplementedFnOutput"]
|
||||||
export type permissionCheckFnInput = components["schemas"]["permissionCheckFnInput"]
|
export type permissionCheckFnInput = components["schemas"]["permissionCheckFnInput"]
|
||||||
export type permissionCheckFnOutput = components["schemas"]["permissionCheckFnOutput"]
|
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 staffOnlyOutput = components["schemas"]["staffOnlyOutput"]
|
||||||
export type superuserOnlyOutput = components["schemas"]["superuserOnlyOutput"]
|
export type superuserOnlyOutput = components["schemas"]["superuserOnlyOutput"]
|
||||||
export type verifiedOnlyOutput = components["schemas"]["verifiedOnlyOutput"]
|
export type verifiedOnlyOutput = components["schemas"]["verifiedOnlyOutput"]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useState, useEffect } from 'react'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
MizanContext,
|
MizanContext,
|
||||||
|
MorphsContext,
|
||||||
useEcho,
|
useEcho,
|
||||||
useAdd,
|
useAdd,
|
||||||
useMultiply,
|
useMultiply,
|
||||||
@@ -22,6 +23,9 @@ import {
|
|||||||
useBuggyFn,
|
useBuggyFn,
|
||||||
usePermissionCheckFn,
|
usePermissionCheckFn,
|
||||||
useCurrentUser,
|
useCurrentUser,
|
||||||
|
useMorphGroups,
|
||||||
|
useMorphLayers,
|
||||||
|
useSetMorphValue,
|
||||||
MizanError,
|
MizanError,
|
||||||
useMizan,
|
useMizan,
|
||||||
} from './api'
|
} from './api'
|
||||||
@@ -51,6 +55,7 @@ export function Fixtures() {
|
|||||||
case 'permission-error': return <PermissionError_ />
|
case 'permission-error': return <PermissionError_ />
|
||||||
case 'permission-success': return <PermissionSuccess />
|
case 'permission-success': return <PermissionSuccess />
|
||||||
case 'context-current-user': return <ContextCurrentUser />
|
case 'context-current-user': return <ContextCurrentUser />
|
||||||
|
case 'merge-morph': return <MergeMorph />
|
||||||
default: return <div data-testid="ready">Harness ready. Set #hash.</div>
|
default: return <div data-testid="ready">Harness ready. Set #hash.</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,3 +135,29 @@ function ContextCurrentUser() {
|
|||||||
return <div>loading context...</div>
|
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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<MizanContext baseUrl="/api/mizan">
|
<MizanContext baseUrl="/api/mizan" session={false}>
|
||||||
<Fixtures />
|
<Fixtures />
|
||||||
</MizanContext>
|
</MizanContext>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -137,3 +137,56 @@ test.describe('generated context hooks', () => {
|
|||||||
expect(result.email).toBe('')
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ interface MizanConfig {
|
|||||||
getHeaders: () => Record<string, string> | Promise<Record<string, string>>
|
getHeaders: () => Record<string, string> | Promise<Record<string, string>>
|
||||||
csrfCookieName: string
|
csrfCookieName: string
|
||||||
csrfHeaderName: 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
|
||||||
}
|
}
|
||||||
|
|
||||||
const config: MizanConfig = {
|
const config: MizanConfig = {
|
||||||
@@ -44,6 +52,7 @@ const config: MizanConfig = {
|
|||||||
getHeaders: () => ({}),
|
getHeaders: () => ({}),
|
||||||
csrfCookieName: 'csrftoken',
|
csrfCookieName: 'csrftoken',
|
||||||
csrfHeaderName: 'X-CSRFToken',
|
csrfHeaderName: 'X-CSRFToken',
|
||||||
|
session: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configure(opts: Partial<MizanConfig>): void {
|
export function configure(opts: Partial<MizanConfig>): void {
|
||||||
@@ -67,6 +76,7 @@ function getCSRFToken(): string | null {
|
|||||||
let _sessionReady: Promise<void> | null = null
|
let _sessionReady: Promise<void> | null = null
|
||||||
|
|
||||||
export function initSession(): Promise<void> {
|
export function initSession(): Promise<void> {
|
||||||
|
if (!config.session) return Promise.resolve()
|
||||||
if (_sessionReady) return _sessionReady
|
if (_sessionReady) return _sessionReady
|
||||||
|
|
||||||
_sessionReady = (async () => {
|
_sessionReady = (async () => {
|
||||||
@@ -187,6 +197,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 ===
|
// === Invalidation ===
|
||||||
|
|
||||||
const pending: Set<string> = new Set()
|
const pending: Set<string> = new Set()
|
||||||
@@ -318,6 +381,15 @@ export async function mizanCall(
|
|||||||
|
|
||||||
const data = await res.json()
|
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
|
// Server-driven invalidation
|
||||||
if (data.invalidate) {
|
if (data.invalidate) {
|
||||||
for (const entry of data.invalidate) {
|
for (const entry of data.invalidate) {
|
||||||
|
|||||||
@@ -82,6 +82,18 @@ def update_profile(request, user_id: int, name: str) -> StatusOutput:
|
|||||||
return StatusOutput(ok=True)
|
return StatusOutput(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@client
|
||||||
|
def find_user(request, user_id: int) -> ProfileOutput | None:
|
||||||
|
"""Optional return — exercises Pydantic `T | None` schema introspection."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@client(merge="user")
|
||||||
|
def rename_user(request, user_id: int, name: str) -> ProfileOutput:
|
||||||
|
"""Merge target — kernel splices return value into the user context."""
|
||||||
|
return ProfileOutput(user_id=user_id, name=name)
|
||||||
|
|
||||||
|
|
||||||
# ─── Registration ───────────────────────────────────────────────────────────
|
# ─── Registration ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -92,3 +104,5 @@ def register_fixture() -> None:
|
|||||||
register(user_profile, "user_profile")
|
register(user_profile, "user_profile")
|
||||||
register(user_orders, "user_orders")
|
register(user_orders, "user_orders")
|
||||||
register(update_profile, "update_profile")
|
register(update_profile, "update_profile")
|
||||||
|
register(find_user, "find_user")
|
||||||
|
register(rename_user, "rename_user")
|
||||||
|
|||||||
@@ -20,3 +20,8 @@ pythonpath = ["."]
|
|||||||
testpaths = ["."]
|
testpaths = ["."]
|
||||||
python_classes = ["*Tests", "*Test", "Test*"]
|
python_classes = ["*Tests", "*Test", "Test*"]
|
||||||
python_functions = ["test_*"]
|
python_functions = ["test_*"]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"uvicorn>=0.47.0",
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user