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:
2026-04-02 18:53:23 -04:00
parent f4d7c64e3c
commit 8aa20111b4
3 changed files with 233 additions and 37 deletions

View File

@@ -8,7 +8,7 @@
- **ReactContext class** — type-safe context/affects references with linting - **ReactContext class** — type-safe context/affects references with linting
- **Named contexts** — functions sharing a context name are grouped into one provider and one fetch - **Named contexts** — functions sharing a context name are grouped into one provider and one fetch
- **Context bundling endpoint** — `GET /api/mizan/ctx/<name>/` returns all functions in one response - **Context bundling endpoint** — `GET /api/mizan/ctx/<name>/` returns all functions in one response
- **Server-driven invalidation** — mutation responses carry `invalidate` from `affects=` metadata - **Server-driven invalidation (JSON body)** — mutation responses carry `{"result": ..., "invalidate": [...]}`
- **Scoped invalidation** — runtime supports `invalidate: [{context: "user", params: {user_id: 5}}]` - **Scoped invalidation** — runtime supports `invalidate: [{context: "user", params: {user_id: 5}}]`
- **Param elevation** — shared params become required provider props, non-shared become optional - **Param elevation** — shared params become required provider props, non-shared become optional
- **Schema export** — `x-mizan-functions` + `x-mizan-contexts` for codegen - **Schema export** — `x-mizan-functions` + `x-mizan-contexts` for codegen
@@ -17,20 +17,62 @@
- **Shapes** — Pydantic + django-readers for typed query projections - **Shapes** — Pydantic + django-readers for typed query projections
- **WebSocket channels** — real-time bidirectional communication - **WebSocket channels** — real-time bidirectional communication
- **Codegen** — generates typed React providers, hooks, mutations from schema - **Codegen** — generates typed React providers, hooks, mutations from schema
- **CDN-ready headers** — `Cache-Control`, `Vary`, deterministic JSON on context GETs, `no-store` on mutations
### Next: CDN-Ready Headers ### Next: X-Mizan-Invalidate Header
Context GETs must be cacheable by a CDN sitting in front of Django. Second invalidation transport. For view responses (redirects, HTML), invalidation goes in an HTTP header instead of the JSON body. Both transports are first-class AFI spec.
- `Cache-Control` headers on `GET /api/mizan/ctx/<name>/` responses - Header format: `X-Mizan-Invalidate: user;user_id=5, notifications`
- `Vary` headers for auth-dependent responses - Comma-separated contexts, semicolon-separated params per context
- Deterministic JSON output (sorted keys) - Decorator auto-adds header to any HttpResponse with `affects=`
- Mutation POSTs must return `Cache-Control: no-store` - Edge reads this header to purge cached pages
- Context error responses must not be cached - Runtime also reads it on XHR/fetch responses (htmx path)
### Next: Fold mizan-runtime into mizan-react ### Next: Return-Type Branching
The runtime (~150 lines: context registry, invalidation batcher, fetch primitives) merges into mizan-react. Framework-agnostic split is deferred until a second frontend adapter exists. `@client` serves both RPC developers (React/SPA) and view developers (htmx/templates). Return type determines behavior:
- **Data return** (dict, Shape, BaseModel) → RPC path. Generates typed hooks. Invalidation in JSON body.
- **HttpResponse return** (render, redirect) → View path. No codegen. Invalidation in `X-Mizan-Invalidate` header.
Same decorator. Same `affects=`. Same invalidation graph. Two paths.
### Next: affects_params
Scoped invalidation with a lambda that extracts which params were affected:
```python
@client(affects='user', affects_params=lambda req: {'user_id': req.user.pk})
def update_name(request, name: str) -> dict:
...
```
Produces `invalidate: [{context: "user", params: {user_id: 5}}]` in JSON body or `X-Mizan-Invalidate: user;user_id=5` in header.
### Next: Edge Manifest
`mizan-generate --manifest` compiles the decorator registry + Django URL conf into static JSON for Edge:
```json
{
"contexts": {
"user": {
"endpoints": ["/api/mizan/ctx/user/"],
"views": ["/profile/:user_id/"],
"params": ["user_id"]
}
}
}
```
Edge reads the manifest at deploy time. When it receives `X-Mizan-Invalidate: user;user_id=5`, it resolves URL patterns with params and purges `/profile/5/` and `/api/mizan/ctx/user/?user_id=5`.
Generated alongside React code. Covers both RPC and view-path functions.
### Next: Codegen Rewrite
Generated code uses the runtime directly (`mizanFetch`, `mizanCall`, `registerContext`) instead of the legacy `MizanProvider` pattern. Mutations have zero invalidation knowledge — the runtime reads the server response.
### Next: SSR Bridge ### Next: SSR Bridge
@@ -42,10 +84,6 @@ Django renders React components server-side via a persistent Bun subprocess.
- Hydration: `window.__MIZAN_SSR_DATA__` consumed by generated providers - Hydration: `window.__MIZAN_SSR_DATA__` consumed by generated providers
- Generated contexts check SSR data before first fetch - Generated contexts check SSR data before first fetch
### Next: Codegen Rewrite
Generated code uses the runtime directly (`mizanFetch`, `mizanCall`, `registerContext`) instead of the legacy `MizanProvider` pattern. Mutations have zero invalidation knowledge — the runtime reads the server response.
--- ---
## Mizan Cloud (closed-source) ## Mizan Cloud (closed-source)
@@ -54,10 +92,12 @@ Generated code uses the runtime directly (`mizanFetch`, `mizanCall`, `registerCo
Cloudflare Workers for automatic edge caching. Cloudflare Workers for automatic edge caching.
- Reads the Mizan schema to configure cache rules - Reads the Edge manifest to configure cache rules
- Context GETs cached at the edge, keyed by context name + params - Context GETs cached at edge, keyed by context name + params
- Mutation POSTs trigger cache purges from the `invalidate` response key - Reads `X-Mizan-Invalidate` header from mutation responses to purge caches
- Zero configuration — the schema IS the cache policy - Reads JSON `invalidate` key from RPC responses for the same purpose
- Resolves URL patterns from manifest to purge view pages
- Zero configuration — the manifest IS the cache policy
### Mizan Render ### Mizan Render
@@ -78,9 +118,9 @@ One-command deployment for Django + React apps.
--- ---
## Protocol Spec ## Protocol Spec (AFI)
The protocol is the product. Every endpoint is designed for a CDN. The protocol is the product. Two invalidation transports. Every endpoint CDN-ready.
### Context fetch ### Context fetch
@@ -88,7 +128,7 @@ The protocol is the product. Every endpoint is designed for a CDN.
GET /api/mizan/ctx/<name>/?param=value GET /api/mizan/ctx/<name>/?param=value
200 OK 200 OK
Cache-Control: public, max-age=60, stale-while-revalidate=300 Cache-Control: public, max-age=0, stale-while-revalidate=300
Vary: Authorization, Cookie Vary: Authorization, Cookie
{ {
@@ -97,7 +137,7 @@ Vary: Authorization, Cookie
} }
``` ```
### Mutation call ### Mutation call (RPC path — JSON body transport)
``` ```
POST /api/mizan/call/ POST /api/mizan/call/
@@ -109,9 +149,19 @@ Cache-Control: no-store
} }
``` ```
### Scoped invalidation ### Mutation call (View path — header transport)
``` ```
POST /profile/update/
302 Found
Location: /profile/5/
Cache-Control: no-store
X-Mizan-Invalidate: user;user_id=5, notifications
```
### Scoped invalidation (JSON)
```json
{ {
"result": { ... }, "result": { ... },
"invalidate": [ "invalidate": [
@@ -120,3 +170,23 @@ Cache-Control: no-store
] ]
} }
``` ```
### Scoped invalidation (Header)
```
X-Mizan-Invalidate: user;user_id=5, notifications
```
### Edge manifest
```json
{
"contexts": {
"user": {
"endpoints": ["/api/mizan/ctx/user/"],
"views": ["/profile/:user_id/"],
"params": ["user_id"]
}
}
}
```

View File

@@ -159,6 +159,72 @@ def _check_auth_requirement(
return None 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( def execute_function(
request: HttpRequest, request: HttpRequest,
fn_name: str, fn_name: str,
@@ -478,26 +544,21 @@ 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)
# Build response with server-driven invalidation # Build response with server-driven invalidation (both transports)
view_class = get_function(fn_name) view_class = get_function(fn_name)
response_data = {"result": result.data} response_data = {"result": result.data}
invalidate_contexts = _resolve_invalidation(view_class)
if view_class: if invalidate_contexts:
meta = getattr(view_class, "_meta", {}) response_data["invalidate"] = invalidate_contexts
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))
response = JsonResponse(response_data) response = JsonResponse(response_data)
response["Cache-Control"] = "no-store" 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 return response

View File

@@ -799,6 +799,36 @@ class ServerDrivenInvalidationTests(TestCase):
self.assertEqual(data["invalidate"], ["user"]) self.assertEqual(data["invalidate"], ["user"])
self.assertEqual(response["Cache-Control"], "no-store") 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): def test_mutation_without_affects_has_no_invalidate(self):
"""Mutation without affects= returns result only.""" """Mutation without affects= returns result only."""
from mizan.client.executor import function_call_view from mizan.client.executor import function_call_view
@@ -822,6 +852,41 @@ class ServerDrivenInvalidationTests(TestCase):
self.assertIn("result", data) self.assertIn("result", data)
self.assertNotIn("invalidate", 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): def test_context_fetch_returns_raw_data(self):
"""Context GET returns raw bundled data, not wrapped.""" """Context GET returns raw bundled data, not wrapped."""