From 3f737132a22828476db50ded014cb6b53c7a82f4 Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Thu, 2 Apr 2026 17:24:26 -0400 Subject: [PATCH] Server-driven invalidation + raw context response format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../mizan-django/src/mizan/client/executor.py | 34 +++++-- .../mizan-django/src/mizan/tests/test_core.py | 91 +++++++++++++++++++ 2 files changed, 116 insertions(+), 9 deletions(-) diff --git a/packages/mizan-django/src/mizan/client/executor.py b/packages/mizan-django/src/mizan/client/executor.py index 5829b91..1896d71 100644 --- a/packages/mizan-django/src/mizan/client/executor.py +++ b/packages/mizan-django/src/mizan/client/executor.py @@ -478,7 +478,25 @@ def function_call_view(request: HttpRequest) -> JsonResponse: }.get(result.code, 400) 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( @@ -560,13 +578,10 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse: Endpoint: GET /api/mizan/ctx//?param1=val1¶m2=val2 - Response on success: + Response: raw bundled data (keys are function names, values are results) { - "error": false, - "data": { - "user_profile": { ... }, - "user_orders": [ ... ] - } + "user_profile": { ... }, + "user_orders": [ ... ] } """ if request.method != "GET": @@ -575,7 +590,7 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse: message="Only GET method allowed", ).to_response(status=405) - params = dict(request.GET) + params = request.GET.dict() result = execute_context(request, context_name, params) if isinstance(result, FunctionError): @@ -590,4 +605,5 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse: }.get(result.code, 400) return result.to_response(status=status) - return result.to_response() + # Return raw bundled data (not wrapped in {"error": false, "data": ...}) + return JsonResponse(result.data) diff --git a/packages/mizan-django/src/mizan/tests/test_core.py b/packages/mizan-django/src/mizan/tests/test_core.py index f40c9a0..10f19a2 100644 --- a/packages/mizan-django/src/mizan/tests/test_core.py +++ b/packages/mizan-django/src/mizan/tests/test_core.py @@ -756,6 +756,97 @@ class AffectsTests(TestCase): 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): """Tests for the bundled context fetch endpoint (execute_context)."""