Add X-Mizan-Invalidate header (second invalidation transport)
Mutation responses now carry invalidation via two transports:
1. JSON body: {"result": ..., "invalidate": ["user"]}
2. HTTP header: X-Mizan-Invalidate: user, notifications
Both are set on every mutation response. The JSON body is consumed
by the client runtime (mizanCall). The header is consumed by Edge
for CDN cache purging and by XHR responses for htmx-style apps.
Header format: comma-separated contexts, semicolon-separated params.
X-Mizan-Invalidate: user;user_id=5, notifications
Also: _resolve_invalidation and _format_invalidate_header extracted
as reusable helpers for when return-type branching adds HttpResponse
support (view-path mutations will only use the header transport).
Updated ROADMAP.md with full v1 plan including both transports,
return-type branching, affects_params, and Edge manifest.
270 Django tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -159,6 +159,72 @@ def _check_auth_requirement(
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_invalidation(
|
||||
view_class: type | None,
|
||||
) -> list[str | dict[str, Any]] | None:
|
||||
"""
|
||||
Resolve the invalidation targets from a function's affects metadata.
|
||||
|
||||
Returns a list suitable for both JSON body and header serialization:
|
||||
- Simple: ["user", "notifications"]
|
||||
- Scoped: [{"context": "user", "params": {"user_id": 5}}]
|
||||
- Mixed: ["notifications", {"context": "user", "params": {"user_id": 5}}]
|
||||
|
||||
Returns None if no invalidation needed.
|
||||
"""
|
||||
if view_class is None:
|
||||
return None
|
||||
|
||||
meta = getattr(view_class, "_meta", {})
|
||||
affects = meta.get("affects")
|
||||
if not affects:
|
||||
return None
|
||||
|
||||
contexts = []
|
||||
for target in affects:
|
||||
if target["type"] == "context":
|
||||
contexts.append(target["name"])
|
||||
elif target["type"] == "function" and target.get("context"):
|
||||
contexts.append(target["context"])
|
||||
|
||||
if not contexts:
|
||||
return None
|
||||
|
||||
# Dedupe while preserving order
|
||||
return list(dict.fromkeys(contexts))
|
||||
|
||||
|
||||
def _format_invalidate_header(
|
||||
invalidate: list[str | dict[str, Any]],
|
||||
) -> str:
|
||||
"""
|
||||
Format invalidation targets as X-Mizan-Invalidate header value.
|
||||
|
||||
Format: comma-separated contexts. Semicolon-separated params per context.
|
||||
|
||||
Examples:
|
||||
["user"] → "user"
|
||||
["user", "notifications"] → "user, notifications"
|
||||
[{"context": "user", "params": {"user_id": 5}}]
|
||||
→ "user;user_id=5"
|
||||
["notifications", {"context": "user", "params": {"user_id": 5, "org_id": 3}}]
|
||||
→ "notifications, user;user_id=5;org_id=3"
|
||||
"""
|
||||
parts = []
|
||||
for entry in invalidate:
|
||||
if isinstance(entry, str):
|
||||
parts.append(entry)
|
||||
elif isinstance(entry, dict):
|
||||
ctx = entry["context"]
|
||||
params = entry.get("params", {})
|
||||
if params:
|
||||
param_str = ";".join(f"{k}={v}" for k, v in sorted(params.items()))
|
||||
parts.append(f"{ctx};{param_str}")
|
||||
else:
|
||||
parts.append(ctx)
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def execute_function(
|
||||
request: HttpRequest,
|
||||
fn_name: str,
|
||||
@@ -478,26 +544,21 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
}.get(result.code, 400)
|
||||
return result.to_response(status=status)
|
||||
|
||||
# Build response with server-driven invalidation
|
||||
# Build response with server-driven invalidation (both transports)
|
||||
view_class = get_function(fn_name)
|
||||
response_data = {"result": result.data}
|
||||
invalidate_contexts = _resolve_invalidation(view_class)
|
||||
|
||||
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))
|
||||
if invalidate_contexts:
|
||||
response_data["invalidate"] = invalidate_contexts
|
||||
|
||||
response = JsonResponse(response_data)
|
||||
response["Cache-Control"] = "no-store"
|
||||
|
||||
# Always set the header transport too (Edge reads this)
|
||||
if invalidate_contexts:
|
||||
response["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate_contexts)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@@ -799,6 +799,36 @@ class ServerDrivenInvalidationTests(TestCase):
|
||||
self.assertEqual(data["invalidate"], ["user"])
|
||||
self.assertEqual(response["Cache-Control"], "no-store")
|
||||
|
||||
# Both transports: JSON body + HTTP header
|
||||
self.assertEqual(response["X-Mizan-Invalidate"], "user")
|
||||
|
||||
def test_mutation_multiple_affects_header(self):
|
||||
"""Mutation with multiple affects= produces comma-separated header."""
|
||||
from mizan.client.executor import function_call_view
|
||||
|
||||
UserCtx = ReactContext("user")
|
||||
NotifCtx = ReactContext("notifications")
|
||||
|
||||
@client(affects=[UserCtx, NotifCtx])
|
||||
def bulk_update(request: HttpRequest) -> ValidOutput:
|
||||
return ValidOutput(valid=True)
|
||||
|
||||
register(bulk_update, "bulk_update")
|
||||
|
||||
request = self.factory.post(
|
||||
"/api/mizan/call/",
|
||||
json.dumps({"fn": "bulk_update", "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.assertEqual(data["invalidate"], ["user", "notifications"])
|
||||
self.assertEqual(response["X-Mizan-Invalidate"], "user, notifications")
|
||||
|
||||
def test_mutation_without_affects_has_no_invalidate(self):
|
||||
"""Mutation without affects= returns result only."""
|
||||
from mizan.client.executor import function_call_view
|
||||
@@ -822,6 +852,41 @@ class ServerDrivenInvalidationTests(TestCase):
|
||||
|
||||
self.assertIn("result", data)
|
||||
self.assertNotIn("invalidate", data)
|
||||
self.assertNotIn("X-Mizan-Invalidate", response)
|
||||
|
||||
def test_format_invalidate_header(self):
|
||||
"""Test the X-Mizan-Invalidate header format helper."""
|
||||
from mizan.client.executor import _format_invalidate_header
|
||||
|
||||
# Simple contexts
|
||||
self.assertEqual(_format_invalidate_header(["user"]), "user")
|
||||
self.assertEqual(
|
||||
_format_invalidate_header(["user", "notifications"]),
|
||||
"user, notifications",
|
||||
)
|
||||
|
||||
# Scoped with params
|
||||
self.assertEqual(
|
||||
_format_invalidate_header([{"context": "user", "params": {"user_id": 5}}]),
|
||||
"user;user_id=5",
|
||||
)
|
||||
|
||||
# Mixed
|
||||
self.assertEqual(
|
||||
_format_invalidate_header([
|
||||
"notifications",
|
||||
{"context": "user", "params": {"user_id": 5}},
|
||||
]),
|
||||
"notifications, user;user_id=5",
|
||||
)
|
||||
|
||||
# Multiple params (sorted for determinism)
|
||||
self.assertEqual(
|
||||
_format_invalidate_header([
|
||||
{"context": "user", "params": {"org_id": 3, "user_id": 5}},
|
||||
]),
|
||||
"user;org_id=3;user_id=5",
|
||||
)
|
||||
|
||||
def test_context_fetch_returns_raw_data(self):
|
||||
"""Context GET returns raw bundled data, not wrapped."""
|
||||
|
||||
Reference in New Issue
Block a user