diff --git a/ROADMAP.md b/ROADMAP.md index 8036652..7de5563 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,7 @@ - **ReactContext class** — type-safe context/affects references with linting - **Named contexts** — functions sharing a context name are grouped into one provider and one fetch - **Context bundling endpoint** — `GET /api/mizan/ctx//` 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}}]` - **Param elevation** — shared params become required provider props, non-shared become optional - **Schema export** — `x-mizan-functions` + `x-mizan-contexts` for codegen @@ -17,20 +17,62 @@ - **Shapes** — Pydantic + django-readers for typed query projections - **WebSocket channels** — real-time bidirectional communication - **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//` responses -- `Vary` headers for auth-dependent responses -- Deterministic JSON output (sorted keys) -- Mutation POSTs must return `Cache-Control: no-store` -- Context error responses must not be cached +- Header format: `X-Mizan-Invalidate: user;user_id=5, notifications` +- Comma-separated contexts, semicolon-separated params per context +- Decorator auto-adds header to any HttpResponse with `affects=` +- Edge reads this header to purge cached pages +- 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 @@ -42,10 +84,6 @@ Django renders React components server-side via a persistent Bun subprocess. - Hydration: `window.__MIZAN_SSR_DATA__` consumed by generated providers - 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) @@ -54,10 +92,12 @@ Generated code uses the runtime directly (`mizanFetch`, `mizanCall`, `registerCo Cloudflare Workers for automatic edge caching. -- Reads the Mizan schema to configure cache rules -- Context GETs cached at the edge, keyed by context name + params -- Mutation POSTs trigger cache purges from the `invalidate` response key -- Zero configuration — the schema IS the cache policy +- Reads the Edge manifest to configure cache rules +- Context GETs cached at edge, keyed by context name + params +- Reads `X-Mizan-Invalidate` header from mutation responses to purge caches +- 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 @@ -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 @@ -88,7 +128,7 @@ The protocol is the product. Every endpoint is designed for a CDN. GET /api/mizan/ctx//?param=value 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 { @@ -97,7 +137,7 @@ Vary: Authorization, Cookie } ``` -### Mutation call +### Mutation call (RPC path — JSON body transport) ``` 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": { ... }, "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"] + } + } +} +``` diff --git a/packages/mizan-django/src/mizan/client/executor.py b/packages/mizan-django/src/mizan/client/executor.py index eb5108f..0738ae8 100644 --- a/packages/mizan-django/src/mizan/client/executor.py +++ b/packages/mizan-django/src/mizan/client/executor.py @@ -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 diff --git a/packages/mizan-django/src/mizan/tests/test_core.py b/packages/mizan-django/src/mizan/tests/test_core.py index f9a0d6a..2ba9160 100644 --- a/packages/mizan-django/src/mizan/tests/test_core.py +++ b/packages/mizan-django/src/mizan/tests/test_core.py @@ -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."""