Server-driven invalidation + raw context response format

Mutation responses now include invalidation directives from the server:

  POST /api/mizan/call/
  → {"result": {...}, "invalidate": ["user"]}

The client never hardcodes invalidation targets. The server resolves
affects= metadata and returns what to invalidate. mizan-runtime reads
the invalidate key and triggers refetches automatically.

Context fetch returns raw bundled data (not wrapped):

  GET /api/mizan/ctx/user/?user_id=5
  → {"user_profile": {...}, "user_orders": [...]}

Also fixed QueryDict handling (use .dict() not dict() to avoid
list-wrapped values).

267 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 17:24:26 -04:00
parent 787f90fd12
commit 3f737132a2
2 changed files with 116 additions and 9 deletions

View File

@@ -478,7 +478,25 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
}.get(result.code, 400) }.get(result.code, 400)
return result.to_response(status=status) return result.to_response(status=status)
return result.to_response() # Build response with server-driven invalidation
view_class = get_function(fn_name)
response_data = {"result": result.data}
if view_class:
meta = getattr(view_class, "_meta", {})
affects = meta.get("affects")
if affects:
invalidate = []
for target in affects:
if target["type"] == "context":
invalidate.append(target["name"])
elif target["type"] == "function" and target.get("context"):
invalidate.append(target["context"])
if invalidate:
# Dedupe while preserving order
response_data["invalidate"] = list(dict.fromkeys(invalidate))
return JsonResponse(response_data)
def execute_context( def execute_context(
@@ -560,14 +578,11 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
Endpoint: GET /api/mizan/ctx/<context_name>/?param1=val1&param2=val2 Endpoint: GET /api/mizan/ctx/<context_name>/?param1=val1&param2=val2
Response on success: Response: raw bundled data (keys are function names, values are results)
{ {
"error": false,
"data": {
"user_profile": { ... }, "user_profile": { ... },
"user_orders": [ ... ] "user_orders": [ ... ]
} }
}
""" """
if request.method != "GET": if request.method != "GET":
return FunctionError( return FunctionError(
@@ -575,7 +590,7 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
message="Only GET method allowed", message="Only GET method allowed",
).to_response(status=405) ).to_response(status=405)
params = dict(request.GET) params = request.GET.dict()
result = execute_context(request, context_name, params) result = execute_context(request, context_name, params)
if isinstance(result, FunctionError): if isinstance(result, FunctionError):
@@ -590,4 +605,5 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
}.get(result.code, 400) }.get(result.code, 400)
return result.to_response(status=status) return result.to_response(status=status)
return result.to_response() # Return raw bundled data (not wrapped in {"error": false, "data": ...})
return JsonResponse(result.data)

View File

@@ -756,6 +756,97 @@ class AffectsTests(TestCase):
self.assertNotIn("affects", plain._meta) self.assertNotIn("affects", plain._meta)
class ServerDrivenInvalidationTests(TestCase):
"""Tests that mutation responses include invalidation directives."""
def setUp(self):
clear_registry()
self.factory = RequestFactory()
def tearDown(self):
clear_registry()
def test_mutation_response_includes_invalidate(self):
"""Mutation with affects= returns invalidate in response."""
from mizan.client.executor import function_call_view
UserCtx = ReactContext("user")
@client(context=UserCtx)
def user_profile(request: HttpRequest) -> ValidOutput:
return ValidOutput(valid=True)
@client(affects=UserCtx)
def edit_profile(request: HttpRequest, name: str) -> ValidOutput:
return ValidOutput(valid=True)
register(user_profile, "user_profile")
register(edit_profile, "edit_profile")
request = self.factory.post(
"/api/mizan/call/",
json.dumps({"fn": "edit_profile", "args": {"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("result", data)
self.assertIn("invalidate", data)
self.assertEqual(data["invalidate"], ["user"])
def test_mutation_without_affects_has_no_invalidate(self):
"""Mutation without affects= returns result only."""
from mizan.client.executor import function_call_view
@client
def plain_action(request: HttpRequest) -> ValidOutput:
return ValidOutput(valid=True)
register(plain_action, "plain_action")
request = self.factory.post(
"/api/mizan/call/",
json.dumps({"fn": "plain_action", "args": {}}),
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("result", data)
self.assertNotIn("invalidate", data)
def test_context_fetch_returns_raw_data(self):
"""Context GET returns raw bundled data, not wrapped."""
from mizan.client.executor import context_fetch_view
class Out(BaseModel):
name: str
@client(context="team")
def team_info(request: HttpRequest, team_id: int) -> Out:
return Out(name=f"team_{team_id}")
register(team_info, "team_info")
request = self.factory.get("/api/mizan/ctx/team/?team_id=3")
request.user = AnonymousUser()
response = context_fetch_view(request, "team")
data = json.loads(response.content)
# Raw data — no "error" or "data" wrapper
self.assertNotIn("error", data)
self.assertIn("team_info", data)
self.assertEqual(data["team_info"]["name"], "team_3")
class ContextFetchTests(TestCase): class ContextFetchTests(TestCase):
"""Tests for the bundled context fetch endpoint (execute_context).""" """Tests for the bundled context fetch endpoint (execute_context)."""