Add affects_params for scoped invalidation
affects_params is a callable that extracts which specific params were
affected by a mutation. The server uses it to produce scoped
invalidation in both transports:
@client(affects=UserContext, affects_params=lambda req: {'user_id': req.user.pk})
def update_avatar(request, url: str) -> dict: ...
JSON body: {"result": ..., "invalidate": [{"context": "user", "params": {"user_id": 42}}]}
Header: X-Mizan-Invalidate: user;user_id=42
Edge reads the scoped params to purge only /profile/42/ instead of
all user profiles. The runtime refetches only the UserContext mounted
with user_id=42, not all UserContext instances.
Requires affects= to be set. Falls back to broad invalidation if
the callable fails.
272 Django tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -161,10 +161,14 @@ def _check_auth_requirement(
|
||||
|
||||
def _resolve_invalidation(
|
||||
view_class: type | None,
|
||||
request: HttpRequest | None = None,
|
||||
) -> list[str | dict[str, Any]] | None:
|
||||
"""
|
||||
Resolve the invalidation targets from a function's affects metadata.
|
||||
|
||||
If affects_params is declared, calls it with the request to produce
|
||||
scoped invalidation entries.
|
||||
|
||||
Returns a list suitable for both JSON body and header serialization:
|
||||
- Simple: ["user", "notifications"]
|
||||
- Scoped: [{"context": "user", "params": {"user_id": 5}}]
|
||||
@@ -180,18 +184,35 @@ def _resolve_invalidation(
|
||||
if not affects:
|
||||
return None
|
||||
|
||||
contexts = []
|
||||
# Resolve context names from affects targets
|
||||
context_names = []
|
||||
for target in affects:
|
||||
if target["type"] == "context":
|
||||
contexts.append(target["name"])
|
||||
context_names.append(target["name"])
|
||||
elif target["type"] == "function" and target.get("context"):
|
||||
contexts.append(target["context"])
|
||||
context_names.append(target["context"])
|
||||
|
||||
if not contexts:
|
||||
if not context_names:
|
||||
return None
|
||||
|
||||
# Dedupe while preserving order
|
||||
return list(dict.fromkeys(contexts))
|
||||
context_names = list(dict.fromkeys(context_names))
|
||||
|
||||
# If affects_params is declared, produce scoped entries
|
||||
affects_params_fn = meta.get("affects_params")
|
||||
if affects_params_fn and request is not None:
|
||||
try:
|
||||
params = affects_params_fn(request)
|
||||
if params and isinstance(params, dict):
|
||||
return [
|
||||
{"context": name, "params": params}
|
||||
for name in context_names
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"affects_params callable failed: {e}")
|
||||
# Fall through to broad invalidation
|
||||
|
||||
return context_names
|
||||
|
||||
|
||||
def _format_invalidate_header(
|
||||
@@ -547,7 +568,7 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
# 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)
|
||||
invalidate_contexts = _resolve_invalidation(view_class, request)
|
||||
|
||||
if invalidate_contexts:
|
||||
response_data["invalidate"] = invalidate_contexts
|
||||
|
||||
@@ -251,51 +251,50 @@ def client(
|
||||
*,
|
||||
context: ContextMode = False,
|
||||
affects: AffectsMode = None,
|
||||
affects_params: Callable[[Any], dict[str, Any]] | None = None,
|
||||
websocket: bool = False,
|
||||
auth: bool | str | Callable[[Any], bool] | None = None,
|
||||
) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]:
|
||||
"""
|
||||
Register a function as a server function.
|
||||
|
||||
Type annotations define the schema - just like Django Ninja/FastAPI.
|
||||
Function parameters become input fields automatically.
|
||||
|
||||
Args:
|
||||
context: Named context for React state management.
|
||||
- False (default): Not a context, just a callable function.
|
||||
- ReactContext instance: groups functions into a named context.
|
||||
- GlobalContext: reserved, auto-mounted at root, SSR-hydrated.
|
||||
- Raw string: also accepted (e.g., 'user'), but ReactContext preferred.
|
||||
|
||||
affects: Declare which contexts this mutation invalidates.
|
||||
- A ReactContext instance
|
||||
- A list of ReactContext instances
|
||||
- Also accepts strings or function references for backwards compat
|
||||
- A ReactContext instance or list of them
|
||||
Mutually exclusive with context=.
|
||||
|
||||
affects_params: Callable that extracts scoped invalidation params.
|
||||
Called with the request after function execution.
|
||||
Returns a dict of params that scope the invalidation.
|
||||
Produces: invalidate: [{context: "user", params: {user_id: 5}}]
|
||||
And header: X-Mizan-Invalidate: user;user_id=5
|
||||
Requires affects= to be set.
|
||||
|
||||
websocket: Enable WebSocket RPC transport (default: False).
|
||||
|
||||
auth: Authentication requirement.
|
||||
- None (default): No auth required
|
||||
- True or 'required': Must be authenticated
|
||||
- 'staff': Must have is_staff=True
|
||||
- 'superuser': Must have is_superuser=True
|
||||
- callable(request) -> bool: Custom check function
|
||||
- 'staff'/'superuser': role-based
|
||||
- callable(request) -> bool: Custom check
|
||||
|
||||
Usage:
|
||||
UserContext = ReactContext('user')
|
||||
|
||||
@client(context=GlobalContext)
|
||||
def current_user(request) -> UserOutput: ...
|
||||
|
||||
@client(context=UserContext)
|
||||
def user_profile(request, user_id: int) -> ProfileOutput: ...
|
||||
|
||||
@client(affects=UserContext)
|
||||
def edit_profile(request, name: str) -> dict: ...
|
||||
|
||||
@client(affects=[UserContext, OrderContext])
|
||||
def change_plan(request) -> dict: ...
|
||||
# Scoped: only invalidate user context for this specific user
|
||||
@client(affects=UserContext, affects_params=lambda req: {'user_id': req.user.pk})
|
||||
def update_avatar(request, url: str) -> dict: ...
|
||||
|
||||
Returns:
|
||||
A ServerFunction class that wraps the function
|
||||
@@ -311,6 +310,12 @@ def client(
|
||||
"A function cannot be both a context reader and a mutation."
|
||||
)
|
||||
|
||||
# Validate affects_params
|
||||
if affects_params is not None and affects is None:
|
||||
raise ValueError(
|
||||
"affects_params= requires affects= to be set."
|
||||
)
|
||||
|
||||
# Validate auth parameter
|
||||
if auth is not None:
|
||||
if isinstance(auth, str) and auth not in _VALID_AUTH_STRINGS:
|
||||
@@ -321,13 +326,15 @@ def client(
|
||||
|
||||
def decorator(fn: Callable) -> type[ServerFunction]:
|
||||
return _create_server_function(
|
||||
fn, context=resolved_context, affects=affects, websocket=websocket, auth=auth
|
||||
fn, context=resolved_context, affects=affects, affects_params=affects_params,
|
||||
websocket=websocket, auth=auth,
|
||||
)
|
||||
|
||||
# Support both @client and @client(...)
|
||||
if fn is not None:
|
||||
return _create_server_function(
|
||||
fn, context=resolved_context, affects=affects, websocket=websocket, auth=auth
|
||||
fn, context=resolved_context, affects=affects, affects_params=affects_params,
|
||||
websocket=websocket, auth=auth,
|
||||
)
|
||||
return decorator
|
||||
|
||||
@@ -365,6 +372,7 @@ def _create_server_function(
|
||||
*,
|
||||
context: str | Literal[False] = False,
|
||||
affects: str | type["ServerFunction"] | list[str | type["ServerFunction"]] | None = None,
|
||||
affects_params: Callable | None = None,
|
||||
websocket: bool = False,
|
||||
auth: bool | str | None = None,
|
||||
) -> type[ServerFunction]:
|
||||
@@ -468,6 +476,8 @@ def _create_server_function(
|
||||
normalized_affects = _normalize_affects(affects)
|
||||
if normalized_affects:
|
||||
meta["affects"] = normalized_affects
|
||||
if affects_params is not None:
|
||||
meta["affects_params"] = affects_params
|
||||
|
||||
# WebSocket: enable WebSocket transport
|
||||
if websocket:
|
||||
|
||||
@@ -829,6 +829,56 @@ class ServerDrivenInvalidationTests(TestCase):
|
||||
self.assertEqual(data["invalidate"], ["user", "notifications"])
|
||||
self.assertEqual(response["X-Mizan-Invalidate"], "user, notifications")
|
||||
|
||||
def test_scoped_invalidation_with_affects_params(self):
|
||||
"""affects_params produces scoped invalidation in body and header."""
|
||||
from mizan.client.executor import function_call_view
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
UserCtx = ReactContext("user")
|
||||
|
||||
@client(
|
||||
affects=UserCtx,
|
||||
affects_params=lambda req: {"user_id": getattr(req.user, "pk", 0)},
|
||||
auth=True,
|
||||
)
|
||||
def update_avatar(request: HttpRequest, url: str) -> ValidOutput:
|
||||
return ValidOutput(valid=True)
|
||||
|
||||
register(update_avatar, "update_avatar")
|
||||
|
||||
request = self.factory.post(
|
||||
"/api/mizan/call/",
|
||||
json.dumps({"fn": "update_avatar", "args": {"url": "https://example.com/pic.jpg"}}),
|
||||
content_type="application/json",
|
||||
)
|
||||
# Create a mock user with pk
|
||||
user = MagicMock()
|
||||
user.pk = 42
|
||||
user.is_authenticated = True
|
||||
request.user = user
|
||||
request._dont_enforce_csrf_checks = True
|
||||
|
||||
response = function_call_view(request)
|
||||
data = json.loads(response.content)
|
||||
|
||||
# Scoped invalidation in JSON body
|
||||
self.assertEqual(len(data["invalidate"]), 1)
|
||||
self.assertEqual(data["invalidate"][0]["context"], "user")
|
||||
self.assertEqual(data["invalidate"][0]["params"]["user_id"], 42)
|
||||
|
||||
# Scoped invalidation in header
|
||||
self.assertEqual(response["X-Mizan-Invalidate"], "user;user_id=42")
|
||||
|
||||
def test_affects_params_without_affects_raises(self):
|
||||
"""affects_params without affects raises ValueError."""
|
||||
with self.assertRaises(ValueError) as cm:
|
||||
|
||||
@client(affects_params=lambda req: {"user_id": 1})
|
||||
def bad(request: HttpRequest) -> ValidOutput:
|
||||
return ValidOutput(valid=True)
|
||||
|
||||
self.assertIn("requires affects", str(cm.exception))
|
||||
|
||||
def test_mutation_without_affects_has_no_invalidate(self):
|
||||
"""Mutation without affects= returns result only."""
|
||||
from mizan.client.executor import function_call_view
|
||||
|
||||
Reference in New Issue
Block a user