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:
@@ -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"
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
Reference in New Issue
Block a user