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:
2026-05-17 18:29:06 -04:00
parent 43bcf3f26f
commit 7fb0c4a400
22 changed files with 1142 additions and 56 deletions

View File

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

View File

@@ -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":

View File

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