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