diff --git a/ISSUES.md b/ISSUES.md index dc8625b..e3a0200 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -18,8 +18,13 @@ Identified by domain expert review (Cloudflare, Serverless, Vercel, React Query, - ~~H10~~ _meta always fresh dict - ~~H11~~ Python normalizes True→"true" for cross-language HMAC - ~~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 - ~~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 diff --git a/backends/mizan-django/src/mizan/client/executor.py b/backends/mizan-django/src/mizan/client/executor.py index e40768d..b3c6a52 100644 --- a/backends/mizan-django/src/mizan/client/executor.py +++ b/backends/mizan-django/src/mizan/client/executor.py @@ -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" diff --git a/backends/mizan-django/src/mizan/export/__init__.py b/backends/mizan-django/src/mizan/export/__init__.py index 354211e..7c2d7ed 100644 --- a/backends/mizan-django/src/mizan/export/__init__.py +++ b/backends/mizan-django/src/mizan/export/__init__.py @@ -27,6 +27,7 @@ if TYPE_CHECKING: from ninja import NinjaAPI 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__ = [ @@ -200,7 +201,7 @@ def generate_openapi_schema() -> dict[str, Any]: 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 + from pydantic import BaseModel, RootModel, create_model # Lazy import registry = get_registry() 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 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 # 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 - ) + if extract_list_element(output_inner) is not None: + # 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_schema_endpoint( @@ -259,7 +274,7 @@ def generate_openapi_schema() -> dict[str, Any]: 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], + output_cls=response_cls, ) # Collect function metadata for provider generation @@ -280,6 +295,8 @@ def generate_openapi_schema() -> dict[str, Any]: # Affects metadata (mutation invalidation) if meta.get("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 if meta.get("form") and meta.get("form_role") == "schema": diff --git a/backends/mizan-django/src/mizan/tests/test_core.py b/backends/mizan-django/src/mizan/tests/test_core.py index 87cdaf8..f84d032 100644 --- a/backends/mizan-django/src/mizan/tests/test_core.py +++ b/backends/mizan-django/src/mizan/tests/test_core.py @@ -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 diff --git a/backends/mizan-fastapi/src/mizan_fastapi/executor.py b/backends/mizan-fastapi/src/mizan_fastapi/executor.py index 59a04dd..64bdac1 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/executor.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/executor.py @@ -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"]} diff --git a/backends/mizan-fastapi/src/mizan_fastapi/router.py b/backends/mizan-fastapi/src/mizan_fastapi/router.py index febab0f..19dcb28 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/router.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/router.py @@ -28,6 +28,7 @@ from .executor import ( MizanError, NotFound, compute_invalidation, + compute_merges, execute_function, ) @@ -49,10 +50,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 +69,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) diff --git a/backends/mizan-fastapi/tests/test_dispatch.py b/backends/mizan-fastapi/tests/test_dispatch.py index 0c3e58e..7e4ca0c 100644 --- a/backends/mizan-fastapi/tests/test_dispatch.py +++ b/backends/mizan-fastapi/tests/test_dispatch.py @@ -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"] == [] diff --git a/cores/mizan-python/src/mizan_core/client/function.py b/cores/mizan-python/src/mizan_core/client/function.py index ef84ec6..bcdda59 100644 --- a/cores/mizan-python/src/mizan_core/client/function.py +++ b/cores/mizan-python/src/mizan_core/client/function.py @@ -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 diff --git a/examples/django-react-site/harness/src/api/react.tsx b/examples/django-react-site/harness/src/api/react.tsx index 62e3cda..a447791 100644 --- a/examples/django-react-site/harness/src/api/react.tsx +++ b/examples/django-react-site/harness/src/api/react.tsx @@ -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( @@ -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[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 {children} diff --git a/examples/fastapi-react-site/backend/main.py b/examples/fastapi-react-site/backend/main.py index 21bcf5a..8d51654 100644 --- a/examples/fastapi-react-site/backend/main.py +++ b/examples/fastapi-react-site/backend/main.py @@ -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 ──────────────────────────────────────────────────────────────────── diff --git a/examples/fastapi-react-site/harness/src/api/contexts/morphs.ts b/examples/fastapi-react-site/harness/src/api/contexts/morphs.ts new file mode 100644 index 0000000..d1a3c42 --- /dev/null +++ b/examples/fastapi-react-site/harness/src/api/contexts/morphs.ts @@ -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 + +export function fetchMorphsContext(params: MorphsContextParams): Promise { + return mizanFetch('morphs', params) +} diff --git a/examples/fastapi-react-site/harness/src/api/functions/setMorphValue.ts b/examples/fastapi-react-site/harness/src/api/functions/setMorphValue.ts new file mode 100644 index 0000000..70861df --- /dev/null +++ b/examples/fastapi-react-site/harness/src/api/functions/setMorphValue.ts @@ -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 { + return mizanCall('set_morph_value', args) +} diff --git a/examples/fastapi-react-site/harness/src/api/index.ts b/examples/fastapi-react-site/harness/src/api/index.ts index 3ba7c78..39d005b 100644 --- a/examples/fastapi-react-site/harness/src/api/index.ts +++ b/examples/fastapi-react-site/harness/src/api/index.ts @@ -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' diff --git a/examples/fastapi-react-site/harness/src/api/react.tsx b/examples/fastapi-react-site/harness/src/api/react.tsx index 6667b82..cab59f8 100644 --- a/examples/fastapi-react-site/harness/src/api/react.tsx +++ b/examples/fastapi-react-site/harness/src/api/react.tsx @@ -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( @@ -94,6 +94,29 @@ export function useCurrentUser(): currentUserOutput | null { return useGlobalContext().data?.current_user ?? null } +// ── Morphs Context ── + +const MorphsCtx = createContext | null>(null) + +export function MorphsContext({ children }: { children: ReactNode }) { + const state = useContextSubscription('morphs', {}, () => fetchMorphsContext({} as any)) + return {children} +} + +export function useMorphsContext(): ContextState { + const ctx = useContext(MorphsCtx) + if (!ctx) throw new Error('useMorphsContext requires ') + 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[0], Awaited>>(callEcho) } @@ -134,11 +157,17 @@ export function usePermissionCheckFn() { return useMutation[0], Awaited>>(callPermissionCheckFn) } +export function useSetMorphValue() { + return useMutation[0], Awaited>>(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[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 {children} diff --git a/examples/fastapi-react-site/harness/src/api/schema.json b/examples/fastapi-react-site/harness/src/api/schema.json index 1775ca9..7972fd3 100644 --- a/examples/fastapi-react-site/harness/src/api/schema.json +++ b/examples/fastapi-react-site/harness/src/api/schema.json @@ -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": {} } } } \ No newline at end of file diff --git a/examples/fastapi-react-site/harness/src/api/types.ts b/examples/fastapi-react-site/harness/src/api/types.ts index d23bae8..01665e8 100644 --- a/examples/fastapi-react-site/harness/src/api/types.ts +++ b/examples/fastapi-react-site/harness/src/api/types.ts @@ -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; 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"] diff --git a/examples/fastapi-react-site/harness/src/fixtures.tsx b/examples/fastapi-react-site/harness/src/fixtures.tsx index c977a9d..16219a0 100644 --- a/examples/fastapi-react-site/harness/src/fixtures.tsx +++ b/examples/fastapi-react-site/harness/src/fixtures.tsx @@ -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 case 'permission-success': return case 'context-current-user': return + case 'merge-morph': return default: return
Harness ready. Set #hash.
} } @@ -130,3 +135,29 @@ function ContextCurrentUser() { return
loading context...
} } + + +function MergeMorph() { + return ( + + + + ) +} + +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
loading...
+ return
{JSON.stringify({ groups, layers })}
+} diff --git a/examples/fastapi-react-site/harness/src/main.tsx b/examples/fastapi-react-site/harness/src/main.tsx index edcb61f..e37d088 100644 --- a/examples/fastapi-react-site/harness/src/main.tsx +++ b/examples/fastapi-react-site/harness/src/main.tsx @@ -4,7 +4,7 @@ import { Fixtures } from './fixtures' function App() { return ( - + ) diff --git a/examples/fastapi-react-site/mizan.spec.ts b/examples/fastapi-react-site/mizan.spec.ts index 5d861a0..72b274c 100644 --- a/examples/fastapi-react-site/mizan.spec.ts +++ b/examples/fastapi-react-site/mizan.spec.ts @@ -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) + }) +}) diff --git a/frontends/mizan-base/src/index.ts b/frontends/mizan-base/src/index.ts index 7ee6cf6..9a310e8 100644 --- a/frontends/mizan-base/src/index.ts +++ b/frontends/mizan-base/src/index.ts @@ -37,6 +37,14 @@ interface MizanConfig { getHeaders: () => Record | Promise> 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 } const config: MizanConfig = { @@ -44,6 +52,7 @@ const config: MizanConfig = { getHeaders: () => ({}), csrfCookieName: 'csrftoken', csrfHeaderName: 'X-CSRFToken', + session: true, } export function configure(opts: Partial): void { @@ -67,6 +76,7 @@ function getCSRFToken(): string | null { let _sessionReady: Promise | null = null export function initSession(): Promise { + if (!config.session) return Promise.resolve() if (_sessionReady) return _sessionReady _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 | 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 + 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 = new Set() @@ -318,6 +381,15 @@ export async function mizanCall( 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) { diff --git a/tests/afi/fixture.py b/tests/afi/fixture.py index 410dc9f..2447083 100644 --- a/tests/afi/fixture.py +++ b/tests/afi/fixture.py @@ -82,6 +82,18 @@ def update_profile(request, user_id: int, name: str) -> StatusOutput: 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 ─────────────────────────────────────────────────────────── @@ -92,3 +104,5 @@ def register_fixture() -> None: register(user_profile, "user_profile") register(user_orders, "user_orders") register(update_profile, "update_profile") + register(find_user, "find_user") + register(rename_user, "rename_user") diff --git a/tests/afi/pyproject.toml b/tests/afi/pyproject.toml index e05be14..57f38ad 100644 --- a/tests/afi/pyproject.toml +++ b/tests/afi/pyproject.toml @@ -20,3 +20,8 @@ pythonpath = ["."] testpaths = ["."] python_classes = ["*Tests", "*Test", "Test*"] python_functions = ["test_*"] + +[dependency-groups] +dev = [ + "uvicorn>=0.47.0", +]