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

@@ -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"] == []